Merge branch 'next' into feat/servicedatabase-restore

This commit is contained in:
Andras Bacsai 2026-01-02 13:45:15 +01:00
commit 360a82ec5d
290 changed files with 18736 additions and 3492 deletions

37
.github/workflows/claude.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: '--model opus'

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,42 @@ ## Project Overview
Coolify is an open-source, self-hostable platform for deploying applications and managing servers - an alternative to Heroku/Netlify/Vercel. It's built with Laravel (PHP) and uses Docker for containerization.
## Git Worktree Shared Dependencies
This repository uses git worktrees for parallel development with **automatic shared dependency setup** via Conductor.
### How It Works
The `conductor.json` setup script (`scripts/conductor-setup.sh`) automatically:
1. Creates symlinks from worktree's `node_modules` and `vendor` to the main repository's directories
2. All worktrees share the same dependencies from the main repository
3. This happens automatically when Conductor creates a new worktree
### Benefits
- **Save disk space**: Only one copy of dependencies across all worktrees
- **Faster setup**: No need to run `npm install` or `composer install` for each worktree
- **Consistent versions**: All worktrees use the same dependency versions
- **Auto-configured**: Handled by Conductor's setup script
- **Simple**: Uses the main repo's existing directories, no extra folders
### Manual Setup (If Needed)
If you need to set up symlinks manually or for non-Conductor worktrees:
```bash
# From the worktree directory
rm -rf node_modules vendor
ln -sf ../../node_modules node_modules
ln -sf ../../vendor vendor
```
### Important Notes
- Dependencies are shared from the main repository (`$CONDUCTOR_ROOT_PATH`)
- Run `npm install` or `composer install` from the main repo or any worktree to update all
- If different branches need different dependency versions, this won't work - remove symlinks and use separate directories
## Development Commands
### Frontend Development

View file

@ -55,6 +55,14 @@ public function handle(Application $application, bool $previewDeployments = fals
return $e->getMessage();
}
}
// Reset restart tracking when application is manually stopped
$application->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
ServiceStatusChanged::dispatch($application->environment->project->team->id);
}
}

View file

@ -51,7 +51,7 @@ public function handle(StandaloneClickhouse $database)
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => "clickhouse-client --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'",
'test' => "clickhouse-client --user {$this->database->clickhouse_admin_user} --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'",
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
@ -152,12 +152,16 @@ private function generate_environment_variables()
$environment_variables->push("$env->key=$env->real_value");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_ADMIN_USER'))->isEmpty()) {
$environment_variables->push("CLICKHOUSE_ADMIN_USER={$this->database->clickhouse_admin_user}");
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) {
$environment_variables->push("CLICKHOUSE_USER={$this->database->clickhouse_admin_user}");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_ADMIN_PASSWORD'))->isEmpty()) {
$environment_variables->push("CLICKHOUSE_ADMIN_PASSWORD={$this->database->clickhouse_admin_password}");
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_PASSWORD'))->isEmpty()) {
$environment_variables->push("CLICKHOUSE_PASSWORD={$this->database->clickhouse_admin_password}");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_DB'))->isEmpty()) {
$environment_variables->push("CLICKHOUSE_DB={$this->database->clickhouse_db}");
}
add_coolify_default_environment_variables($this->database, $environment_variables, $environment_variables);

View file

@ -5,7 +5,6 @@
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneKeydb;
use Illuminate\Support\Facades\Storage;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
@ -270,10 +269,9 @@ private function add_custom_keydb()
return;
}
$filename = 'keydb.conf';
Storage::disk('local')->put("tmp/keydb.conf_{$this->database->uuid}", $this->database->keydb_conf);
$path = Storage::path("tmp/keydb.conf_{$this->database->uuid}");
instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server);
Storage::disk('local')->delete("tmp/keydb.conf_{$this->database->uuid}");
$content = $this->database->keydb_conf;
$content_base64 = base64_encode($content);
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
}
private function buildStartCommand(): string

View file

@ -5,7 +5,6 @@
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneRedis;
use Illuminate\Support\Facades\Storage;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
@ -316,9 +315,8 @@ private function add_custom_redis()
return;
}
$filename = 'redis.conf';
Storage::disk('local')->put("tmp/redis.conf_{$this->database->uuid}", $this->database->redis_conf);
$path = Storage::path("tmp/redis.conf_{$this->database->uuid}");
instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server);
Storage::disk('local')->delete("tmp/redis.conf_{$this->database->uuid}");
$content = $this->database->redis_conf;
$content_base64 = base64_encode($content);
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
}
}

View file

@ -28,6 +28,13 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
$this->stopContainer($database, $database->uuid, 30);
// Reset restart tracking when database is manually stopped
$database->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
if ($dockerCleanup) {
CleanupDocker::dispatch($server, false, false);
}

View file

@ -199,12 +199,26 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
$isPublic = data_get($database, 'is_public');
$foundDatabases[] = $database->id;
$statusFromDb = $database->status;
// Track restart count for databases (single-container)
$restartCount = data_get($container, 'RestartCount', 0);
$previousRestartCount = $database->restart_count ?? 0;
if ($statusFromDb !== $containerStatus) {
$database->update(['status' => $containerStatus]);
$updateData = ['status' => $containerStatus];
} else {
$database->update(['last_online_at' => now()]);
$updateData = ['last_online_at' => now()];
}
// Update restart tracking if restart count increased
if ($restartCount > $previousRestartCount) {
$updateData['restart_count'] = $restartCount;
$updateData['last_restart_at'] = now();
$updateData['last_restart_type'] = 'crash';
}
$database->update($updateData);
if ($isPublic) {
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
if ($this->server->isSwarm()) {
@ -365,7 +379,13 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
if (str($database->status)->startsWith('exited')) {
continue;
}
$database->update(['status' => 'exited']);
// Reset restart tracking when database exits completely
$database->update([
'status' => 'exited',
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
$name = data_get($database, 'name');
$fqdn = data_get($database, 'fqdn');

View file

@ -37,9 +37,15 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
$applicationCleanupLog = $this->cleanupApplicationImages($server, $applications);
$cleanupLog = array_merge($cleanupLog, $applicationCleanupLog);
// Build image prune command that excludes application images
// This ensures we clean up non-Coolify images while preserving rollback images
$imagePruneCmd = $this->buildImagePruneCommand($applicationImageRepos);
// Build image prune command that excludes application images and current Coolify infrastructure images
// This ensures we clean up non-Coolify images while preserving rollback images and current helper/realtime images
// Note: Only the current version is protected; old versions will be cleaned up by explicit commands below
// We pass the version strings so all registry variants are protected (ghcr.io, docker.io, no prefix)
$imagePruneCmd = $this->buildImagePruneCommand(
$applicationImageRepos,
$helperImageVersion,
$realtimeImageVersion
);
$commands = [
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
@ -78,33 +84,51 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
* Since docker image prune doesn't support excluding by repository name directly,
* we use a shell script approach to delete unused images while preserving application images.
*/
private function buildImagePruneCommand($applicationImageRepos): string
{
private function buildImagePruneCommand(
$applicationImageRepos,
string $helperImageVersion,
string $realtimeImageVersion
): string {
// Step 1: Always prune dangling images (untagged)
$commands = ['docker image prune -f'];
if ($applicationImageRepos->isEmpty()) {
// No applications, add original prune command for all unused images
$commands[] = 'docker image prune -af --filter "label!=coolify.managed=true"';
} else {
// Build grep pattern to exclude application image repositories
$excludePatterns = $applicationImageRepos->map(function ($repo) {
// Escape special characters for grep extended regex (ERE)
// ERE special chars: . \ + * ? [ ^ ] $ ( ) { } |
return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo);
})->implode('|');
// Build grep pattern to exclude application image repositories (matches repo:tag and repo_service:tag)
$appExcludePatterns = $applicationImageRepos->map(function ($repo) {
// Escape special characters for grep extended regex (ERE)
// ERE special chars: . \ + * ? [ ^ ] $ ( ) { } |
return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo);
})->implode('|');
// Delete unused images that:
// - Are not application images (don't match app repos)
// - Don't have coolify.managed=true label
// Images in use by containers will fail silently with docker rmi
// Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build)
$commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ".
"grep -v -E '^({$excludePatterns})[_:].+' | ".
"grep -v '<none>' | ".
"xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true";
// Build grep pattern to exclude Coolify infrastructure images (current version only)
// This pattern matches the image name regardless of registry prefix:
// - ghcr.io/coollabsio/coolify-helper:1.0.12
// - docker.io/coollabsio/coolify-helper:1.0.12
// - coollabsio/coolify-helper:1.0.12
// Pattern: (^|/)coollabsio/coolify-(helper|realtime):VERSION$
$escapedHelperVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $helperImageVersion);
$escapedRealtimeVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $realtimeImageVersion);
$infraExcludePattern = "(^|/)coollabsio/coolify-helper:{$escapedHelperVersion}$|(^|/)coollabsio/coolify-realtime:{$escapedRealtimeVersion}$";
// Delete unused images that:
// - Are not application images (don't match app repos)
// - Are not current Coolify infrastructure images (any registry)
// - Don't have coolify.managed=true label
// Images in use by containers will fail silently with docker rmi
// Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build)
$grepCommands = "grep -v '<none>'";
// Add application repo exclusion if there are applications
if ($applicationImageRepos->isNotEmpty()) {
$grepCommands .= " | grep -v -E '^({$appExcludePatterns})[_:].+'";
}
// Add infrastructure image exclusion (matches any registry prefix)
$grepCommands .= " | grep -v -E '{$infraExcludePattern}'";
$commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ".
$grepCommands.' | '.
"xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true";
return implode(' && ', $commands);
}

View file

@ -148,19 +148,6 @@ private function getSuseDockerInstallCommand(): string
')';
}
private function getArchDockerInstallCommand(): string
{
return 'pacman -Syyy --noconfirm && '.
'pacman -S docker docker-compose --noconfirm && '.
'systemctl start docker && '.
'systemctl enable docker';
}
private function getGenericDockerInstallCommand(): string
{
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion}";
}
private function getArchDockerInstallCommand(): string
{
// Use -Syu to perform full system upgrade before installing Docker
@ -171,4 +158,9 @@ private function getArchDockerInstallCommand(): string
'systemctl enable docker.service && '.
'systemctl start docker.service';
}
private function getGenericDockerInstallCommand(): string
{
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion}";
}
}

View file

@ -30,7 +30,6 @@ public function handle($manual_update = false)
if (! $this->server) {
return;
}
CleanupDocker::dispatch($this->server, false, false);
// Fetch fresh version from CDN instead of using cache
try {
@ -117,17 +116,12 @@ public function handle($manual_update = false)
private function update()
{
$helperImage = config('constants.coolify.helper_image');
$latest_version = getHelperVersion();
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
instant_remote_process(["docker pull -q $image"], $this->server, false);
$latestHelperImageVersion = getHelperVersion();
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
remote_process([
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
"bash /data/coolify/source/upgrade.sh $this->latestVersion",
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
], $this->server);
}
}

View file

@ -0,0 +1,81 @@
<?php
namespace App\Console\Commands\Cloud;
use App\Jobs\SyncStripeSubscriptionsJob;
use Illuminate\Console\Command;
class SyncStripeSubscriptions extends Command
{
protected $signature = 'cloud:sync-stripe-subscriptions {--fix : Actually fix discrepancies (default is check only)}';
protected $description = 'Sync subscription status with Stripe. By default only checks, use --fix to apply changes.';
public function handle(): int
{
if (! isCloud()) {
$this->error('This command can only be run on Coolify Cloud.');
return 1;
}
if (! isStripe()) {
$this->error('Stripe is not configured.');
return 1;
}
$fix = $this->option('fix');
if ($fix) {
$this->warn('Running with --fix: discrepancies will be corrected.');
} else {
$this->info('Running in check mode (no changes will be made). Use --fix to apply corrections.');
}
$this->newLine();
$job = new SyncStripeSubscriptionsJob($fix);
$result = $job->handle();
if (isset($result['error'])) {
$this->error($result['error']);
return 1;
}
$this->info("Total subscriptions checked: {$result['total_checked']}");
$this->newLine();
if (count($result['discrepancies']) > 0) {
$this->warn('Discrepancies found: '.count($result['discrepancies']));
$this->newLine();
foreach ($result['discrepancies'] as $discrepancy) {
$this->line(" - Subscription ID: {$discrepancy['subscription_id']}");
$this->line(" Team ID: {$discrepancy['team_id']}");
$this->line(" Stripe ID: {$discrepancy['stripe_subscription_id']}");
$this->line(" Stripe Status: {$discrepancy['stripe_status']}");
$this->newLine();
}
if ($fix) {
$this->info('All discrepancies have been fixed.');
} else {
$this->comment('Run with --fix to correct these discrepancies.');
}
} else {
$this->info('No discrepancies found. All subscriptions are in sync.');
}
if (count($result['errors']) > 0) {
$this->newLine();
$this->error('Errors encountered: '.count($result['errors']));
foreach ($result['errors'] as $error) {
$this->line(" - Subscription {$error['subscription_id']}: {$error['error']}");
}
}
return 0;
}
}

View file

@ -14,15 +14,11 @@
use App\Jobs\ServerManagerJob;
use App\Jobs\UpdateCoolifyJob;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
private $allServers;
private Schedule $scheduleInstance;
private InstanceSettings $settings;
@ -34,8 +30,6 @@ class Kernel extends ConsoleKernel
protected function schedule(Schedule $schedule): void
{
$this->scheduleInstance = $schedule;
$this->allServers = Server::where('ip', '!=', '1.2.3.4');
$this->settings = instanceSettings();
$this->updateCheckFrequency = $this->settings->update_check_frequency ?: '0 * * * *';
@ -95,14 +89,6 @@ protected function schedule(Schedule $schedule): void
private function pullImages(): void
{
if (isCloud()) {
$servers = $this->allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get();
$own = Team::find(0)->servers;
$servers = $servers->merge($own);
} else {
$servers = $this->allServers->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get();
}
// Sentinel update checks are now handled by ServerManagerJob
$this->scheduleInstance->job(new CheckHelperImageJob)
->cron($this->updateCheckFrequency)
->timezone($this->instanceTimezone)

View file

@ -0,0 +1,15 @@
<?php
namespace App\Exceptions;
use Exception;
class RateLimitException extends Exception
{
public function __construct(
string $message = 'Rate limit exceeded.',
public readonly ?int $retryAfter = null
) {
parent::__construct($message);
}
}

View file

@ -192,6 +192,7 @@ public function applications(Request $request)
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
],
)
),
@ -342,6 +343,7 @@ public function create_public_application(Request $request)
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
],
)
),
@ -492,6 +494,7 @@ public function create_private_gh_app_application(Request $request)
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
],
)
),
@ -626,6 +629,7 @@ public function create_private_deploy_key_application(Request $request)
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
],
)
),
@ -757,6 +761,7 @@ public function create_dockerfile_application(Request $request)
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
],
)
),
@ -927,7 +932,7 @@ private function create_application(Request $request, $type)
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override'];
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@ -940,6 +945,7 @@ private function create_application(Request $request, $type)
'is_http_basic_auth_enabled' => 'boolean',
'http_basic_auth_username' => 'string|nullable',
'http_basic_auth_password' => 'string|nullable',
'autogenerate_domain' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -964,6 +970,7 @@ private function create_application(Request $request, $type)
}
$serverUuid = $request->server_uuid;
$fqdn = $request->domains;
$autogenerateDomain = $request->boolean('autogenerate_domain', true);
$instantDeploy = $request->instant_deploy;
$githubAppUuid = $request->github_app_uuid;
$useBuildServer = $request->use_build_server;
@ -1087,6 +1094,11 @@ private function create_application(Request $request, $type)
$application->settings->save();
}
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
$application->save();
}
if ($application->settings->is_container_label_readonly_enabled) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
@ -1115,7 +1127,7 @@ private function create_application(Request $request, $type)
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'domains'),
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === 'private-gh-app') {
$validationRules = [
@ -1238,6 +1250,11 @@ private function create_application(Request $request, $type)
$application->save();
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
$application->save();
}
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@ -1270,7 +1287,7 @@ private function create_application(Request $request, $type)
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'domains'),
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === 'private-deploy-key') {
@ -1367,6 +1384,11 @@ private function create_application(Request $request, $type)
$application->environment_id = $environment->id;
$application->save();
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
$application->save();
}
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@ -1399,7 +1421,7 @@ private function create_application(Request $request, $type)
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'domains'),
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === 'dockerfile') {
$validationRules = [
@ -1461,6 +1483,11 @@ private function create_application(Request $request, $type)
$application->git_branch = 'main';
$application->save();
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
$application->save();
}
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@ -1489,7 +1516,7 @@ private function create_application(Request $request, $type)
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'domains'),
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === 'dockerimage') {
$validationRules = [
@ -1554,6 +1581,11 @@ private function create_application(Request $request, $type)
$application->git_branch = 'main';
$application->save();
$application->refresh();
// Auto-generate domain if requested and no custom domain provided
if ($autogenerateDomain && blank($fqdn)) {
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
$application->save();
}
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@ -1582,7 +1614,7 @@ private function create_application(Request $request, $type)
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'domains'),
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === 'dockercompose') {
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override'];

View file

@ -0,0 +1,531 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\CloudProviderToken;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use OpenApi\Attributes as OA;
class CloudProviderTokensController extends Controller
{
private function removeSensitiveData($token)
{
$token->makeHidden([
'id',
'token',
]);
return serializeApiResponse($token);
}
/**
* Validate a provider token against the provider's API.
*
* @return array{valid: bool, error: string|null}
*/
private function validateProviderToken(string $provider, string $token): array
{
try {
$response = match ($provider) {
'hetzner' => Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'),
'digitalocean' => Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.digitalocean.com/v2/account'),
default => null,
};
if ($response === null) {
return ['valid' => false, 'error' => 'Unsupported provider.'];
}
if ($response->successful()) {
return ['valid' => true, 'error' => null];
}
return ['valid' => false, 'error' => "Invalid {$provider} token. Please check your API token."];
} catch (\Throwable $e) {
Log::error('Failed to validate cloud provider token', [
'provider' => $provider,
'exception' => $e->getMessage(),
]);
return ['valid' => false, 'error' => 'Failed to validate token with provider API.'];
}
}
#[OA\Get(
summary: 'List Cloud Provider Tokens',
description: 'List all cloud provider tokens for the authenticated team.',
path: '/cloud-tokens',
operationId: 'list-cloud-tokens',
security: [
['bearerAuth' => []],
],
tags: ['Cloud Tokens'],
responses: [
new OA\Response(
response: 200,
description: 'Get all cloud provider tokens.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'uuid' => ['type' => 'string'],
'name' => ['type' => 'string'],
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean']],
'team_id' => ['type' => 'integer'],
'servers_count' => ['type' => 'integer'],
'created_at' => ['type' => 'string'],
'updated_at' => ['type' => 'string'],
]
)
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
]
)]
public function index(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$tokens = CloudProviderToken::whereTeamId($teamId)
->withCount('servers')
->get()
->map(function ($token) {
return $this->removeSensitiveData($token);
});
return response()->json($tokens);
}
#[OA\Get(
summary: 'Get Cloud Provider Token',
description: 'Get cloud provider token by UUID.',
path: '/cloud-tokens/{uuid}',
operationId: 'get-cloud-token-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Cloud Tokens'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Get cloud provider token by UUID',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'uuid' => ['type' => 'string'],
'name' => ['type' => 'string'],
'provider' => ['type' => 'string'],
'team_id' => ['type' => 'integer'],
'servers_count' => ['type' => 'integer'],
'created_at' => ['type' => 'string'],
'updated_at' => ['type' => 'string'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function show(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($request->uuid)
->withCount('servers')
->first();
if (is_null($token)) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
return response()->json($this->removeSensitiveData($token));
}
#[OA\Post(
summary: 'Create Cloud Provider Token',
description: 'Create a new cloud provider token. The token will be validated before being stored.',
path: '/cloud-tokens',
operationId: 'create-cloud-token',
security: [
['bearerAuth' => []],
],
tags: ['Cloud Tokens'],
requestBody: new OA\RequestBody(
required: true,
description: 'Cloud provider token details',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['provider', 'token', 'name'],
properties: [
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'],
'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'],
],
),
),
),
responses: [
new OA\Response(
response: 201,
description: 'Cloud provider token created.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the token.'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function store(Request $request)
{
$allowedFields = ['provider', 'token', 'name'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
// Use request body only (excludes any route parameters)
$body = $request->json()->all();
$validator = customApiValidator($body, [
'provider' => 'required|string|in:hetzner,digitalocean',
'token' => 'required|string',
'name' => 'required|string|max:255',
]);
$extraFields = array_diff(array_keys($body), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
// Validate token with the provider's API
$validation = $this->validateProviderToken($body['provider'], $body['token']);
if (! $validation['valid']) {
return response()->json(['message' => $validation['error']], 400);
}
$cloudProviderToken = CloudProviderToken::create([
'team_id' => $teamId,
'provider' => $body['provider'],
'token' => $body['token'],
'name' => $body['name'],
]);
return response()->json([
'uuid' => $cloudProviderToken->uuid,
])->setStatusCode(201);
}
#[OA\Patch(
summary: 'Update Cloud Provider Token',
description: 'Update cloud provider token name.',
path: '/cloud-tokens/{uuid}',
operationId: 'update-cloud-token-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Cloud Tokens'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: true,
description: 'Cloud provider token updated.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The friendly name for the token.'],
],
),
),
),
responses: [
new OA\Response(
response: 200,
description: 'Cloud provider token updated.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'uuid' => ['type' => 'string'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update(Request $request)
{
$allowedFields = ['name'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
// Use request body only (excludes route parameters like uuid)
$body = $request->json()->all();
$validator = customApiValidator($body, [
'name' => 'required|string|max:255',
]);
$extraFields = array_diff(array_keys($body), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
// Use route parameter for UUID lookup
$token = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first();
if (! $token) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
$token->update(array_intersect_key($body, array_flip($allowedFields)));
return response()->json([
'uuid' => $token->uuid,
]);
}
#[OA\Delete(
summary: 'Delete Cloud Provider Token',
description: 'Delete cloud provider token by UUID. Cannot delete if token is used by any servers.',
path: '/cloud-tokens/{uuid}',
operationId: 'delete-cloud-token-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Cloud Tokens'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the cloud provider token.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Cloud provider token deleted.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Cloud provider token deleted.'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function destroy(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if (! $request->uuid) {
return response()->json(['message' => 'UUID is required.'], 422);
}
$token = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->uuid)->first();
if (! $token) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
if ($token->hasServers()) {
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
}
$token->delete();
return response()->json(['message' => 'Cloud provider token deleted.']);
}
#[OA\Post(
summary: 'Validate Cloud Provider Token',
description: 'Validate a cloud provider token against the provider API.',
path: '/cloud-tokens/{uuid}/validate',
operationId: 'validate-cloud-token-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Cloud Tokens'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Token validation result.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'valid' => ['type' => 'boolean', 'example' => true],
'message' => ['type' => 'string', 'example' => 'Token is valid.'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function validateToken(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$cloudToken = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->uuid)->first();
if (! $cloudToken) {
return response()->json(['message' => 'Cloud provider token not found.'], 404);
}
$validation = $this->validateProviderToken($cloudToken->provider, $cloudToken->token);
return response()->json([
'valid' => $validation['valid'],
'message' => $validation['valid'] ? 'Token is valid.' : $validation['error'],
]);
}
}

View file

@ -388,7 +388,11 @@ private function by_uuids(string $uuid, int $teamId, bool $force = false, int $p
continue;
}
}
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force, $pr);
$result = $this->deploy_resource($resource, $force, $pr);
if (isset($result['status']) && $result['status'] === 429) {
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
}
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
if ($deployment_uuid) {
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
} else {
@ -430,7 +434,11 @@ public function by_tags(string $tags, int $team_id, bool $force = false)
continue;
}
foreach ($applications as $resource) {
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force);
$result = $this->deploy_resource($resource, $force);
if (isset($result['status']) && $result['status'] === 429) {
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
}
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
if ($deployment_uuid) {
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
}
@ -474,8 +482,11 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar
deployment_uuid: $deployment_uuid,
force_rebuild: $force,
pull_request_id: $pr,
is_api: true,
);
if ($result['status'] === 'skipped') {
if ($result['status'] === 'queue_full') {
return ['message' => $result['message'], 'deployment_uuid' => null, 'status' => 429];
} elseif ($result['status'] === 'skipped') {
$message = $result['message'];
} else {
$message = "Application {$resource->name} deployment queued.";

View file

@ -0,0 +1,738 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\ProxyTypes;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Controller;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\HetznerService;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class HetznerController extends Controller
{
/**
* Get cloud provider token UUID from request.
* Prefers cloud_provider_token_uuid over deprecated cloud_provider_token_id.
*/
private function getCloudProviderTokenUuid(Request $request): ?string
{
return $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
}
#[OA\Get(
summary: 'Get Hetzner Locations',
description: 'Get all available Hetzner datacenter locations.',
path: '/hetzner/locations',
operationId: 'get-hetzner-locations',
security: [
['bearerAuth' => []],
],
tags: ['Hetzner'],
parameters: [
new OA\Parameter(
name: 'cloud_provider_token_uuid',
in: 'query',
required: false,
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'cloud_provider_token_id',
in: 'query',
required: false,
deprecated: true,
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of Hetzner locations.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'name' => ['type' => 'string'],
'description' => ['type' => 'string'],
'country' => ['type' => 'string'],
'city' => ['type' => 'string'],
'latitude' => ['type' => 'number'],
'longitude' => ['type' => 'number'],
]
)
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function locations(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($tokenUuid)
->where('provider', 'hetzner')
->first();
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
try {
$hetznerService = new HetznerService($token->token);
$locations = $hetznerService->getLocations();
return response()->json($locations);
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500);
}
}
#[OA\Get(
summary: 'Get Hetzner Server Types',
description: 'Get all available Hetzner server types (instance sizes).',
path: '/hetzner/server-types',
operationId: 'get-hetzner-server-types',
security: [
['bearerAuth' => []],
],
tags: ['Hetzner'],
parameters: [
new OA\Parameter(
name: 'cloud_provider_token_uuid',
in: 'query',
required: false,
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'cloud_provider_token_id',
in: 'query',
required: false,
deprecated: true,
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of Hetzner server types.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'name' => ['type' => 'string'],
'description' => ['type' => 'string'],
'cores' => ['type' => 'integer'],
'memory' => ['type' => 'number'],
'disk' => ['type' => 'integer'],
'prices' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'location' => ['type' => 'string', 'description' => 'Datacenter location name'],
'price_hourly' => [
'type' => 'object',
'properties' => [
'net' => ['type' => 'string'],
'gross' => ['type' => 'string'],
],
],
'price_monthly' => [
'type' => 'object',
'properties' => [
'net' => ['type' => 'string'],
'gross' => ['type' => 'string'],
],
],
],
],
],
]
)
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function serverTypes(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($tokenUuid)
->where('provider', 'hetzner')
->first();
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
try {
$hetznerService = new HetznerService($token->token);
$serverTypes = $hetznerService->getServerTypes();
return response()->json($serverTypes);
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500);
}
}
#[OA\Get(
summary: 'Get Hetzner Images',
description: 'Get all available Hetzner system images (operating systems).',
path: '/hetzner/images',
operationId: 'get-hetzner-images',
security: [
['bearerAuth' => []],
],
tags: ['Hetzner'],
parameters: [
new OA\Parameter(
name: 'cloud_provider_token_uuid',
in: 'query',
required: false,
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'cloud_provider_token_id',
in: 'query',
required: false,
deprecated: true,
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of Hetzner images.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'name' => ['type' => 'string'],
'description' => ['type' => 'string'],
'type' => ['type' => 'string'],
'os_flavor' => ['type' => 'string'],
'os_version' => ['type' => 'string'],
'architecture' => ['type' => 'string'],
]
)
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function images(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($tokenUuid)
->where('provider', 'hetzner')
->first();
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
try {
$hetznerService = new HetznerService($token->token);
$images = $hetznerService->getImages();
// Filter out deprecated images (same as UI)
$filtered = array_filter($images, function ($image) {
if (isset($image['type']) && $image['type'] !== 'system') {
return false;
}
if (isset($image['deprecated']) && $image['deprecated'] === true) {
return false;
}
return true;
});
return response()->json(array_values($filtered));
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500);
}
}
#[OA\Get(
summary: 'Get Hetzner SSH Keys',
description: 'Get all SSH keys stored in the Hetzner account.',
path: '/hetzner/ssh-keys',
operationId: 'get-hetzner-ssh-keys',
security: [
['bearerAuth' => []],
],
tags: ['Hetzner'],
parameters: [
new OA\Parameter(
name: 'cloud_provider_token_uuid',
in: 'query',
required: false,
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'cloud_provider_token_id',
in: 'query',
required: false,
deprecated: true,
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of Hetzner SSH keys.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'name' => ['type' => 'string'],
'fingerprint' => ['type' => 'string'],
'public_key' => ['type' => 'string'],
]
)
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function sshKeys(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($tokenUuid)
->where('provider', 'hetzner')
->first();
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
try {
$hetznerService = new HetznerService($token->token);
$sshKeys = $hetznerService->getSshKeys();
return response()->json($sshKeys);
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500);
}
}
#[OA\Post(
summary: 'Create Hetzner Server',
description: 'Create a new server on Hetzner and register it in Coolify.',
path: '/servers/hetzner',
operationId: 'create-hetzner-server',
security: [
['bearerAuth' => []],
],
tags: ['Hetzner'],
requestBody: new OA\RequestBody(
required: true,
description: 'Hetzner server creation parameters',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['location', 'server_type', 'image', 'private_key_uuid'],
properties: [
'cloud_provider_token_uuid' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'],
'cloud_provider_token_id' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', 'deprecated' => true],
'location' => ['type' => 'string', 'example' => 'nbg1', 'description' => 'Hetzner location name'],
'server_type' => ['type' => 'string', 'example' => 'cx11', 'description' => 'Hetzner server type name'],
'image' => ['type' => 'integer', 'example' => 15512617, 'description' => 'Hetzner image ID'],
'name' => ['type' => 'string', 'example' => 'my-server', 'description' => 'Server name (auto-generated if not provided)'],
'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'],
'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'],
'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'],
'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'],
'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'],
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'],
],
),
),
),
responses: [
new OA\Response(
response: 201,
description: 'Hetzner server created.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the server.'],
'hetzner_server_id' => ['type' => 'integer', 'description' => 'The Hetzner server ID.'],
'ip' => ['type' => 'string', 'description' => 'The server IP address.'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
new OA\Response(
response: 429,
ref: '#/components/responses/429',
),
]
)]
public function createServer(Request $request)
{
$allowedFields = [
'cloud_provider_token_uuid',
'cloud_provider_token_id',
'location',
'server_type',
'image',
'name',
'private_key_uuid',
'enable_ipv4',
'enable_ipv6',
'hetzner_ssh_key_ids',
'cloud_init_script',
'instant_validate',
];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
'location' => 'required|string',
'server_type' => 'required|string',
'image' => 'required|integer',
'name' => ['nullable', 'string', 'max:253', new ValidHostname],
'private_key_uuid' => 'required|string',
'enable_ipv4' => 'nullable|boolean',
'enable_ipv6' => 'nullable|boolean',
'hetzner_ssh_key_ids' => 'nullable|array',
'hetzner_ssh_key_ids.*' => 'integer',
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'instant_validate' => 'nullable|boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
// Check server limit
if (Team::serverLimitReached()) {
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
}
// Set defaults
if (! $request->name) {
$request->offsetSet('name', generate_random_name());
}
if (is_null($request->enable_ipv4)) {
$request->offsetSet('enable_ipv4', true);
}
if (is_null($request->enable_ipv6)) {
$request->offsetSet('enable_ipv6', true);
}
if (is_null($request->hetzner_ssh_key_ids)) {
$request->offsetSet('hetzner_ssh_key_ids', []);
}
if (is_null($request->instant_validate)) {
$request->offsetSet('instant_validate', false);
}
// Validate cloud provider token
$tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($tokenUuid)
->where('provider', 'hetzner')
->first();
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
// Validate private key
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
if (! $privateKey) {
return response()->json(['message' => 'Private key not found.'], 404);
}
try {
$hetznerService = new HetznerService($token->token);
// Get public key and MD5 fingerprint
$publicKey = $privateKey->getPublicKey();
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
// Check if SSH key already exists on Hetzner
$existingSshKeys = $hetznerService->getSshKeys();
$existingKey = null;
foreach ($existingSshKeys as $key) {
if ($key['fingerprint'] === $md5Fingerprint) {
$existingKey = $key;
break;
}
}
// Upload SSH key if it doesn't exist
if ($existingKey) {
$sshKeyId = $existingKey['id'];
} else {
$sshKeyName = $privateKey->name;
$uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey);
$sshKeyId = $uploadedKey['id'];
}
// Normalize server name to lowercase for RFC 1123 compliance
$normalizedServerName = strtolower(trim($request->name));
// Prepare SSH keys array: Coolify key + user-selected Hetzner keys
$sshKeys = array_merge(
[$sshKeyId],
$request->hetzner_ssh_key_ids
);
// Remove duplicates
$sshKeys = array_unique($sshKeys);
$sshKeys = array_values($sshKeys);
// Prepare server creation parameters
$params = [
'name' => $normalizedServerName,
'server_type' => $request->server_type,
'image' => $request->image,
'location' => $request->location,
'start_after_create' => true,
'ssh_keys' => $sshKeys,
'public_net' => [
'enable_ipv4' => $request->enable_ipv4,
'enable_ipv6' => $request->enable_ipv6,
],
];
// Add cloud-init script if provided
if (! empty($request->cloud_init_script)) {
$params['user_data'] = $request->cloud_init_script;
}
// Create server on Hetzner
$hetznerServer = $hetznerService->createServer($params);
// Determine IP address to use (prefer IPv4, fallback to IPv6)
$ipAddress = null;
if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
$ipAddress = $hetznerServer['public_net']['ipv4']['ip'];
} elseif ($request->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) {
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
}
if (! $ipAddress) {
throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.');
}
// Create server in Coolify database
$server = Server::create([
'name' => $normalizedServerName,
'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => $teamId,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'hetzner_server_id' => $hetznerServer['id'],
]);
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
// Validate server if requested
if ($request->instant_validate) {
\App\Actions\Server\ValidateServer::dispatch($server);
}
return response()->json([
'uuid' => $server->uuid,
'hetzner_server_id' => $hetznerServer['id'],
'ip' => $ipAddress,
])->setStatusCode(201);
} catch (RateLimitException $e) {
$response = response()->json(['message' => $e->getMessage()], 429);
if ($e->retryAfter !== null) {
$response->header('Retry-After', $e->retryAfter);
}
return $response;
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500);
}
}
}

View file

@ -61,6 +61,22 @@
),
]
)),
new OA\Response(
response: 429,
description: 'Rate limit exceeded.',
headers: [
new OA\Header(
header: 'Retry-After',
description: 'Number of seconds to wait before retrying.',
schema: new OA\Schema(type: 'integer', example: 60)
),
],
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Rate limit exceeded. Please try again later.'),
]
)),
],
)]
class OpenApi

View file

@ -218,7 +218,10 @@ public function current_team(Request $request)
if (is_null($teamId)) {
return invalidTokenResponse();
}
$team = auth()->user()->currentTeam();
$team = auth()->user()->teams->where('id', $teamId)->first();
if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404);
}
return response()->json(
$this->removeSensitiveData($team),
@ -263,7 +266,10 @@ public function current_team_members(Request $request)
if (is_null($teamId)) {
return invalidTokenResponse();
}
$team = auth()->user()->currentTeam();
$team = auth()->user()->teams->where('id', $teamId)->first();
if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404);
}
$team->members->makeHidden([
'pivot',
'email_change_code',

View file

@ -90,7 +90,9 @@ public function manual(Request $request)
force_rebuild: false,
is_webhook: true
);
if ($result['status'] === 'skipped') {
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
@ -144,7 +146,9 @@ public function manual(Request $request)
is_webhook: true,
git_type: 'bitbucket'
);
if ($result['status'] === 'skipped') {
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',

View file

@ -99,7 +99,9 @@ public function manual(Request $request)
commit: data_get($payload, 'after', 'HEAD'),
is_webhook: true,
);
if ($result['status'] === 'skipped') {
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
@ -169,7 +171,9 @@ public function manual(Request $request)
is_webhook: true,
git_type: 'gitea'
);
if ($result['status'] === 'skipped') {
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',

View file

@ -111,7 +111,9 @@ public function manual(Request $request)
commit: data_get($payload, 'after', 'HEAD'),
is_webhook: true,
);
if ($result['status'] === 'skipped') {
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
@ -197,7 +199,9 @@ public function manual(Request $request)
is_webhook: true,
git_type: 'github'
);
if ($result['status'] === 'skipped') {
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
@ -305,7 +309,9 @@ public function normal(Request $request)
if (! $id || ! $branch) {
return response('Nothing to do. No id or branch found.');
}
$applications = Application::where('repository_project_id', $id)->whereRelation('source', 'is_public', false);
$applications = Application::where('repository_project_id', $id)
->where('source_id', $github_app->id)
->whereRelation('source', 'is_public', false);
if ($x_github_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get();
if ($applications->isEmpty()) {
@ -347,12 +353,15 @@ public function normal(Request $request)
force_rebuild: false,
is_webhook: true,
);
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
}
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
'deployment_uuid' => $result['deployment_uuid'] ?? null,
]);
} else {
$paths = str($application->watch_paths)->explode("\n");
@ -411,7 +420,9 @@ public function normal(Request $request)
is_webhook: true,
git_type: 'github'
);
if ($result['status'] === 'skipped') {
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',

View file

@ -131,7 +131,9 @@ public function manual(Request $request)
force_rebuild: false,
is_webhook: true,
);
if ($result['status'] === 'skipped') {
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
@ -202,7 +204,9 @@ public function manual(Request $request)
is_webhook: true,
git_type: 'gitlab'
);
if ($result['status'] === 'skipped') {
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',

View file

@ -18,14 +18,21 @@ public function handle(Request $request, Closure $next): Response
}
if (auth()?->user()?->currentTeam()) {
refreshSession(auth()->user()->currentTeam());
} elseif (auth()?->user()?->teams?->count() > 0) {
// User's session team is invalid (e.g., removed from team), switch to first available team
refreshSession(auth()->user()->teams->first());
}
if (! auth()->user() || ! isCloud() || isInstanceAdmin()) {
if (! auth()->user() || ! isCloud()) {
if (! isCloud() && showBoarding() && ! in_array($request->path(), allowedPathsForBoardingAccounts())) {
return redirect()->route('onboarding');
}
return $next($request);
}
// Instance admins can access settings and admin routes regardless of subscription
if (isInstanceAdmin() && ($request->routeIs('settings.*') || $request->path() === 'admin')) {
return $next($request);
}
if (! auth()->user()->hasVerifiedEmail()) {
if ($request->path() === 'verify' || in_array($request->path(), allowedPathsForInvalidAccounts()) || $request->routeIs('verify.verify')) {
return $next($request);

View file

@ -371,7 +371,7 @@ public function handle(): void
try {
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
$this->graceful_shutdown_container($this->deployment_uuid);
$this->graceful_shutdown_container($this->deployment_uuid, skipRemove: true);
} catch (Exception $e) {
// Log but don't fail - container cleanup errors are expected when container is already gone
\Log::warning('Failed to shutdown container '.$this->deployment_uuid.': '.$e->getMessage());
@ -486,15 +486,38 @@ private function decide_what_to_do()
private function post_deployment()
{
GetContainersStatus::dispatch($this->server);
// Mark deployment as complete FIRST, before any other operations
// This ensures the deployment status is FINISHED even if subsequent operations fail
$this->completeDeployment();
// Then handle side effects - these should not fail the deployment
try {
GetContainersStatus::dispatch($this->server);
} catch (\Exception $e) {
\Log::warning('Failed to dispatch GetContainersStatus for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
if ($this->pull_request_id !== 0) {
if ($this->application->is_github_based()) {
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
try {
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
} catch (\Exception $e) {
\Log::warning('Failed to dispatch PR update for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
}
}
$this->run_post_deployment_command();
$this->application->isConfigurationChanged(true);
try {
$this->run_post_deployment_command();
} catch (\Exception $e) {
\Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage());
}
try {
$this->application->isConfigurationChanged(true);
} catch (\Exception $e) {
\Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
}
private function deploy_simple_dockerfile()
@ -1945,7 +1968,7 @@ private function prepare_builder_image(bool $firstTry = true)
$this->application_deployment_queue->addLogEntry('Preparing container with helper image with updated envs.');
}
$this->graceful_shutdown_container($this->deployment_uuid);
$this->graceful_shutdown_container($this->deployment_uuid, skipRemove: true);
$this->execute_remote_command(
[
$runCommand,
@ -1960,8 +1983,8 @@ private function prepare_builder_image(bool $firstTry = true)
private function restart_builder_container_with_actual_commit()
{
// Stop and remove the current helper container
$this->graceful_shutdown_container($this->deployment_uuid);
// Stop the current helper container (no need for rm -f as it was started with --rm)
$this->graceful_shutdown_container($this->deployment_uuid, skipRemove: true);
// Clear cached env_args to force regeneration with actual SOURCE_COMMIT value
$this->env_args = null;
@ -3148,14 +3171,20 @@ private function build_image()
$this->application_deployment_queue->addLogEntry('Building docker image completed.');
}
private function graceful_shutdown_container(string $containerName)
private function graceful_shutdown_container(string $containerName, bool $skipRemove = false)
{
try {
$timeout = isDev() ? 1 : 30;
$this->execute_remote_command(
["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
);
if ($skipRemove) {
$this->execute_remote_command(
["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true]
);
} else {
$this->execute_remote_command(
["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
);
}
} catch (Exception $error) {
$this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr');
}
@ -3934,13 +3963,17 @@ private function transitionToStatus(ApplicationDeploymentStatus $status): void
}
/**
* Check if deployment is in a terminal state (FAILED or CANCELLED).
* Check if deployment is in a terminal state (FINISHED, FAILED or CANCELLED).
* Terminal states cannot be changed.
*/
private function isInTerminalState(): bool
{
$this->application_deployment_queue->refresh();
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FINISHED->value) {
return true;
}
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
return true;
}
@ -3980,6 +4013,15 @@ private function handleStatusTransition(ApplicationDeploymentStatus $status): vo
*/
private function handleSuccessfulDeployment(): void
{
// Reset restart count after successful deployment
// This is done here (not in Livewire) to avoid race conditions
// with GetContainersStatus reading old container restart counts
$this->application->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
event(new ApplicationConfigurationChanged($this->application->team()->id));
if (! $this->only_this_server) {

View file

@ -0,0 +1,55 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Laravel\Horizon\Contracts\Silenced;
/**
* Asynchronously connects the coolify-proxy to Docker networks.
*
* This job is dispatched from PushServerUpdateJob when the proxy is found running
* to ensure it's connected to all required networks without blocking the status update.
*/
class ConnectProxyToNetworksJob implements ShouldBeEncrypted, ShouldQueue, Silenced
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 60;
public function middleware(): array
{
// Prevent overlapping executions for the same server and throttle to max once per 10 seconds
return [
(new WithoutOverlapping('connect-proxy-networks-'.$this->server->uuid))
->expireAfter(60)
->dontRelease(),
];
}
public function __construct(public Server $server) {}
public function handle()
{
if (! $this->server->isFunctional()) {
return;
}
$connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
if (empty($connectProxyToDockerNetworks)) {
return;
}
instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
}
}

View file

@ -9,6 +9,7 @@
use App\Actions\Server\StartLogDrain;
use App\Actions\Shared\ComplexStatusCheck;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
@ -134,29 +135,29 @@ public function handle()
if ($this->containers->isEmpty()) {
return;
}
$this->applications = $this->server->applications();
$this->databases = $this->server->databases();
$this->previews = $this->server->previews();
$this->services = $this->server->services()->get();
// 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->allApplicationIds = $this->applications->filter(function ($application) {
return $application->additional_servers->count() === 0;
return $application->additional_servers_count === 0;
})->pluck('id');
$this->allApplicationsWithAdditionalServers = $this->applications->filter(function ($application) {
return $application->additional_servers->count() > 0;
return $application->additional_servers_count > 0;
});
$this->allApplicationPreviewsIds = $this->previews->map(function ($preview) {
return $preview->application_id.':'.$preview->pull_request_id;
});
$this->allDatabaseUuids = $this->databases->pluck('uuid');
$this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid');
$this->services->each(function ($service) {
$service->applications()->pluck('id')->each(function ($applicationId) {
$this->allServiceApplicationIds->push($applicationId);
});
$service->databases()->pluck('id')->each(function ($databaseId) {
$this->allServiceDatabaseIds->push($databaseId);
});
});
// 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'));
foreach ($this->containers as $container) {
$containerStatus = data_get($container, 'state', 'exited');
@ -236,8 +237,9 @@ public function handle()
$this->foundProxy = true;
} elseif ($type === 'service' && $this->isRunning($containerStatus)) {
} else {
if ($this->allDatabaseUuids->contains($uuid) && $this->isRunning($containerStatus)) {
if ($this->allDatabaseUuids->contains($uuid) && $this->isActiveOrTransient($containerStatus)) {
$this->foundDatabaseUuids->push($uuid);
// TCP proxy should only be started/managed when database is actually running
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true);
} else {
@ -402,66 +404,59 @@ private function updateApplicationPreviewStatus(string $applicationId, string $p
private function updateNotFoundApplicationStatus()
{
$notFoundApplicationIds = $this->allApplicationIds->diff($this->foundApplicationIds);
if ($notFoundApplicationIds->isNotEmpty()) {
$notFoundApplicationIds->each(function ($applicationId) {
$application = Application::find($applicationId);
if ($application) {
// Don't mark as exited if already exited
if (str($application->status)->startsWith('exited')) {
return;
}
// Only protection: Verify we received any container data at all
// If containers collection is completely empty, Sentinel might have failed
if ($this->containers->isEmpty()) {
return;
}
if ($application->status !== 'exited') {
$application->status = 'exited';
$application->save();
}
}
});
if ($notFoundApplicationIds->isEmpty()) {
return;
}
// Only protection: Verify we received any container data at all
// If containers collection is completely empty, Sentinel might have failed
if ($this->containers->isEmpty()) {
return;
}
// Batch update: mark all not-found applications as exited (excluding already exited ones)
Application::whereIn('id', $notFoundApplicationIds)
->where('status', 'not like', 'exited%')
->update(['status' => 'exited']);
}
private function updateNotFoundApplicationPreviewStatus()
{
$notFoundApplicationPreviewsIds = $this->allApplicationPreviewsIds->diff($this->foundApplicationPreviewsIds);
if ($notFoundApplicationPreviewsIds->isNotEmpty()) {
$notFoundApplicationPreviewsIds->each(function ($previewKey) {
// Parse the previewKey format "application_id:pull_request_id"
$parts = explode(':', $previewKey);
if (count($parts) !== 2) {
return;
}
if ($notFoundApplicationPreviewsIds->isEmpty()) {
return;
}
$applicationId = $parts[0];
$pullRequestId = $parts[1];
// Only protection: Verify we received any container data at all
// If containers collection is completely empty, Sentinel might have failed
if ($this->containers->isEmpty()) {
return;
}
$applicationPreview = $this->previews->where('application_id', $applicationId)
->where('pull_request_id', $pullRequestId)
->first();
// Collect IDs of previews that need to be marked as exited
$previewIdsToUpdate = collect();
foreach ($notFoundApplicationPreviewsIds as $previewKey) {
// Parse the previewKey format "application_id:pull_request_id"
$parts = explode(':', $previewKey);
if (count($parts) !== 2) {
continue;
}
if ($applicationPreview) {
// Don't mark as exited if already exited
if (str($applicationPreview->status)->startsWith('exited')) {
return;
}
$applicationId = $parts[0];
$pullRequestId = $parts[1];
// Only protection: Verify we received any container data at all
// If containers collection is completely empty, Sentinel might have failed
if ($this->containers->isEmpty()) {
$applicationPreview = $this->previews->where('application_id', $applicationId)
->where('pull_request_id', $pullRequestId)
->first();
return;
}
if ($applicationPreview->status !== 'exited') {
$applicationPreview->status = 'exited';
$applicationPreview->save();
}
}
});
if ($applicationPreview && ! str($applicationPreview->status)->startsWith('exited')) {
$previewIdsToUpdate->push($applicationPreview->id);
}
}
// Batch update all collected preview IDs
if ($previewIdsToUpdate->isNotEmpty()) {
ApplicationPreview::whereIn('id', $previewIdsToUpdate)->update(['status' => 'exited']);
}
}
@ -478,8 +473,8 @@ private function updateProxyStatus()
} catch (\Throwable $e) {
}
} else {
$connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
// Connect proxy to networks asynchronously to avoid blocking the status update
ConnectProxyToNetworksJob::dispatch($this->server);
}
}
}
@ -509,20 +504,28 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
private function updateNotFoundDatabaseStatus()
{
$notFoundDatabaseUuids = $this->allDatabaseUuids->diff($this->foundDatabaseUuids);
if ($notFoundDatabaseUuids->isNotEmpty()) {
$notFoundDatabaseUuids->each(function ($databaseUuid) {
$database = $this->databases->where('uuid', $databaseUuid)->first();
if ($database) {
if ($database->status !== 'exited') {
$database->status = 'exited';
$database->save();
}
if ($database->is_public) {
StopDatabaseProxy::dispatch($database);
}
}
});
if ($notFoundDatabaseUuids->isEmpty()) {
return;
}
// Only protection: Verify we received any container data at all
// If containers collection is completely empty, Sentinel might have failed
if ($this->containers->isEmpty()) {
return;
}
$notFoundDatabaseUuids->each(function ($databaseUuid) {
$database = $this->databases->where('uuid', $databaseUuid)->first();
if ($database) {
if (! str($database->status)->startsWith('exited')) {
$database->status = 'exited';
$database->save();
}
if ($database->is_public) {
StopDatabaseProxy::dispatch($database);
}
}
});
}
private function updateServiceSubStatus(string $serviceId, string $subType, string $subId, string $containerStatus)
@ -554,27 +557,19 @@ private function updateNotFoundServiceStatus()
{
$notFoundServiceApplicationIds = $this->allServiceApplicationIds->diff($this->foundServiceApplicationIds);
$notFoundServiceDatabaseIds = $this->allServiceDatabaseIds->diff($this->foundServiceDatabaseIds);
// Batch update service applications
if ($notFoundServiceApplicationIds->isNotEmpty()) {
$notFoundServiceApplicationIds->each(function ($serviceApplicationId) {
$application = ServiceApplication::find($serviceApplicationId);
if ($application) {
if ($application->status !== 'exited') {
$application->status = 'exited';
$application->save();
}
}
});
ServiceApplication::whereIn('id', $notFoundServiceApplicationIds)
->where('status', '!=', 'exited')
->update(['status' => 'exited']);
}
// Batch update service databases
if ($notFoundServiceDatabaseIds->isNotEmpty()) {
$notFoundServiceDatabaseIds->each(function ($serviceDatabaseId) {
$database = ServiceDatabase::find($serviceDatabaseId);
if ($database) {
if ($database->status !== 'exited') {
$database->status = 'exited';
$database->save();
}
}
});
ServiceDatabase::whereIn('id', $notFoundServiceDatabaseIds)
->where('status', '!=', 'exited')
->update(['status' => 'exited']);
}
}
@ -590,6 +585,23 @@ private function isRunning(string $containerStatus)
return str($containerStatus)->contains('running');
}
/**
* Check if container is in an active or transient state.
* Active states: running
* Transient states: restarting, starting, created, paused
*
* These states indicate the container exists and should be tracked.
* Terminal states (exited, dead, removing) should NOT be tracked.
*/
private function isActiveOrTransient(string $containerStatus): bool
{
return str($containerStatus)->contains('running') ||
str($containerStatus)->contains('restarting') ||
str($containerStatus)->contains('starting') ||
str($containerStatus)->contains('created') ||
str($containerStatus)->contains('paused');
}
private function checkLogDrainContainer()
{
if ($this->server->isLogDrainEnabled() && $this->foundLogDrainContainer === false) {

View file

@ -76,8 +76,7 @@ public function handle()
} else {
$this->server->proxy->status = data_get($foundProxyContainer, 'State.Status');
$this->server->save();
$connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
ConnectProxyToNetworksJob::dispatchSync($this->server);
}
}
}

View file

@ -160,13 +160,10 @@ private function processServerTasks(Server $server): void
ServerPatchCheckJob::dispatch($server);
}
// Check for sentinel updates hourly (independent of user-configurable update_check_frequency)
if ($server->isSentinelEnabled()) {
$shouldCheckSentinel = $this->shouldRunNow('0 * * * *', $serverTimezone);
if ($shouldCheckSentinel) {
CheckAndStartSentinelJob::dispatch($server);
}
// Sentinel update checks (hourly) - check for updates to Sentinel version
// No timezone needed for hourly - runs at top of every hour
if ($isSentinelEnabled && $this->shouldRunNow('0 * * * *')) {
CheckAndStartSentinelJob::dispatch($server);
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace App\Jobs;
use App\Models\Subscription;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SyncStripeSubscriptionsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 1800; // 30 minutes max
public function __construct(public bool $fix = false)
{
$this->onQueue('high');
}
public function handle(): array
{
if (! isCloud() || ! isStripe()) {
return ['error' => 'Not running on Cloud or Stripe not configured'];
}
$subscriptions = Subscription::whereNotNull('stripe_subscription_id')
->where('stripe_invoice_paid', true)
->get();
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$discrepancies = [];
$errors = [];
foreach ($subscriptions as $subscription) {
try {
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
// Check if Stripe says cancelled but we think it's active
if (in_array($stripeSubscription->status, ['canceled', 'incomplete_expired', 'unpaid'])) {
$discrepancies[] = [
'subscription_id' => $subscription->id,
'team_id' => $subscription->team_id,
'stripe_subscription_id' => $subscription->stripe_subscription_id,
'stripe_status' => $stripeSubscription->status,
];
// Only fix if --fix flag is passed
if ($this->fix) {
$subscription->update([
'stripe_invoice_paid' => false,
'stripe_past_due' => false,
]);
if ($stripeSubscription->status === 'canceled') {
$subscription->team?->subscriptionEnded();
}
}
}
// Small delay to avoid Stripe rate limits
usleep(100000); // 100ms
} catch (\Exception $e) {
$errors[] = [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
];
}
}
// Only notify if discrepancies found and fixed
if ($this->fix && count($discrepancies) > 0) {
send_internal_notification(
'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n".
json_encode($discrepancies, JSON_PRETTY_PRINT)
);
}
return [
'total_checked' => $subscriptions->count(),
'discrepancies' => $discrepancies,
'errors' => $errors,
'fixed' => $this->fix,
];
}
}

View file

@ -29,7 +29,8 @@ public function handle(ProxyStatusChanged $event)
$server->proxy->set('status', $status);
$server->save();
ProxyStatusChangedUI::dispatch($server->team_id);
$versionCheckDispatched = false;
if ($status === 'running') {
$server->setupDefaultRedirect();
$server->setupDynamicProxyConfiguration();
@ -40,7 +41,9 @@ public function handle(ProxyStatusChanged $event)
if ($server->proxyType() === ProxyTypes::TRAEFIK->value) {
$traefikVersions = get_traefik_versions();
if ($traefikVersions !== null) {
// Version check job will dispatch ProxyStatusChangedUI when complete
CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions);
$versionCheckDispatched = true;
} else {
Log::warning('Traefik version check skipped after proxy status change: versions.json data unavailable', [
'server_id' => $server->id,
@ -49,6 +52,13 @@ public function handle(ProxyStatusChanged $event)
}
}
}
// Only dispatch UI refresh if version check wasn't dispatched
// (version check job handles its own UI refresh with updated version data)
if (! $versionCheckDispatched) {
ProxyStatusChangedUI::dispatch($server->team_id);
}
if ($status === 'created') {
instant_remote_process([
'docker rm -f coolify-proxy',

View file

@ -79,8 +79,10 @@ public function polling()
$causer_id = data_get($this->activity, 'causer_id');
$user = User::find($causer_id);
if ($user) {
$teamId = $user->currentTeam()->id;
if (! self::$eventDispatched) {
$teamId = data_get($this->activity, 'properties.team_id')
?? $user->currentTeam()?->id
?? $user->teams->first()?->id;
if ($teamId && ! self::$eventDispatched) {
if (filled($this->eventData)) {
$this->eventToDispatch::dispatch($teamId, $this->eventData);
} else {

View file

@ -38,6 +38,12 @@ public function deploymentCount()
return $this->deployments->count();
}
#[Computed]
public function shouldReduceOpacity(): bool
{
return request()->routeIs('project.application.deployment.*');
}
public function toggleExpanded()
{
$this->expanded = ! $this->expanded;

View file

@ -95,7 +95,7 @@ public function submit()
]);
}
}
$this->redirect(route('destination.show', $docker->uuid));
redirectRoute($this, 'destination.show', [$docker->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -1314,15 +1314,10 @@ private function completeResourceCreation()
'server_id' => $this->selectedServerId,
];
// PostgreSQL requires a database_image parameter
if ($this->selectedResourceType === 'postgresql') {
$queryParams['database_image'] = 'postgres:16-alpine';
}
$this->redirect(route('project.resource.create', [
redirectRoute($this, 'project.resource.create', [
'project_uuid' => $this->selectedProjectUuid,
'environment_uuid' => $this->selectedEnvironmentUuid,
] + $queryParams));
] + $queryParams);
}
}
@ -1341,6 +1336,42 @@ public function cancelResourceSelection()
$this->autoOpenResource = null;
}
public function goBack()
{
// From Environment Selection → go back to Project (if multiple) or further
if ($this->selectedProjectUuid !== null) {
$this->selectedProjectUuid = null;
$this->selectedEnvironmentUuid = null;
if (count($this->availableProjects) > 1) {
return; // Stop here - user can choose a project
}
}
// From Project Selection → go back to Destination (if multiple) or further
if ($this->selectedDestinationUuid !== null) {
$this->selectedDestinationUuid = null;
$this->selectedProjectUuid = null;
$this->selectedEnvironmentUuid = null;
if (count($this->availableDestinations) > 1) {
return; // Stop here - user can choose a destination
}
}
// From Destination Selection → go back to Server (if multiple) or cancel
if ($this->selectedServerId !== null) {
$this->selectedServerId = null;
$this->selectedDestinationUuid = null;
$this->selectedProjectUuid = null;
$this->selectedEnvironmentUuid = null;
if (count($this->availableServers) > 1) {
return; // Stop here - user can choose a server
}
}
// All previous steps were auto-selected, cancel entirely
$this->cancelResourceSelection();
}
public function getFilteredCreatableItemsProperty()
{
$query = strtolower(trim($this->searchQuery));

View file

@ -2,10 +2,8 @@
namespace App\Livewire;
use App\Models\InstanceSettings;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class NavbarDeleteTeam extends Component
@ -19,12 +17,8 @@ public function mount()
public function delete($password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$currentTeam = currentTeam();
@ -43,7 +37,7 @@ public function delete($password)
refreshSession();
return redirect()->route('team.index');
return redirectRoute($this, 'team.index');
}
public function render()

View file

@ -20,12 +20,13 @@ class Show extends Component
public bool $is_debug_enabled = false;
public bool $fullscreen = false;
private bool $deploymentFinishedDispatched = false;
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
'refreshQueue',
];
}
@ -91,24 +92,52 @@ private function isKeepAliveOn()
public function polling()
{
$this->dispatch('deploymentFinished');
$this->application_deployment_queue->refresh();
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
$this->isKeepAliveOn();
// Dispatch event when deployment finishes to stop auto-scroll (only once)
if (! $this->isKeepAliveOn && ! $this->deploymentFinishedDispatched) {
$this->deploymentFinishedDispatched = true;
$this->dispatch('deploymentFinished');
}
}
public function getLogLinesProperty()
{
return decode_remote_command_output($this->application_deployment_queue)->map(function ($logLine) {
$logLine['line'] = e($logLine['line']);
$logLine['line'] = preg_replace(
'/(https?:\/\/[^\s]+)/',
'<a href="$1" target="_blank" rel="noopener noreferrer" class="underline text-neutral-400">$1</a>',
$logLine['line'],
);
return decode_remote_command_output($this->application_deployment_queue);
}
return $logLine;
});
public function copyLogs(): string
{
$logs = decode_remote_command_output($this->application_deployment_queue)
->map(function ($line) {
return $line['timestamp'].' '.
(isset($line['command']) && $line['command'] ? '[CMD]: ' : '').
trim($line['line']);
})
->join("\n");
return sanitizeLogsForExport($logs);
}
public function downloadAllLogs(): string
{
$logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true)
->map(function ($line) {
$prefix = '';
if ($line['hidden']) {
$prefix = '[DEBUG] ';
}
if (isset($line['command']) && $line['command']) {
$prefix .= '[CMD]: ';
}
return $line['timestamp'].' '.$prefix.trim($line['line']);
})
->join("\n");
return sanitizeLogsForExport($logs);
}
public function render()

View file

@ -558,8 +558,11 @@ public function loadComposeFile($isInit = false, $showToast = true, ?string $res
$this->dispatch('refreshStorages');
$this->dispatch('refreshEnvs');
} catch (\Throwable $e) {
$this->application->docker_compose_location = $this->initialDockerComposeLocation;
$this->application->save();
// Refresh model to get restored values from Application::loadComposeFile
$this->application->refresh();
// Sync restored values back to component properties for UI update
$this->syncData();
return handleError($e, $this);
} finally {
@ -936,73 +939,6 @@ public function downloadConfig()
]);
}
private function updateServiceEnvironmentVariables()
{
$domains = collect(json_decode($this->application->docker_compose_domains, true)) ?? collect([]);
foreach ($domains as $serviceName => $service) {
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
$domain = data_get($service, 'domain');
// Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed
$this->application->environment_variables()->where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%")
->delete();
$this->application->environment_variables()->where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%")
->delete();
if ($domain) {
// Create or update SERVICE_FQDN_ and SERVICE_URL_ variables
$fqdn = Url::fromString($domain);
$port = $fqdn->getPort();
$path = $fqdn->getPath();
$urlValue = $fqdn->getScheme().'://'.$fqdn->getHost();
if ($path !== '/') {
$urlValue = $urlValue.$path;
}
$fqdnValue = str($domain)->after('://');
if ($path !== '/') {
$fqdnValue = $fqdnValue.$path;
}
// Create/update SERVICE_FQDN_
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceNameFormatted}",
], [
'value' => $fqdnValue,
'is_preview' => false,
]);
// Create/update SERVICE_URL_
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceNameFormatted}",
], [
'value' => $urlValue,
'is_preview' => false,
]);
// Create/update port-specific variables if port exists
if (filled($port)) {
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}",
], [
'value' => $fqdnValue,
'is_preview' => false,
]);
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}",
], [
'value' => $urlValue,
'is_preview' => false,
]);
}
}
}
}
public function getDetectedPortInfoProperty(): ?array
{
$detectedPort = $this->application->detectPortFromEnvironment();

View file

@ -100,19 +100,17 @@ public function deploy(bool $force_rebuild = false)
deployment_uuid: $this->deploymentUuid,
force_rebuild: $force_rebuild,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('error', 'Deployment skipped', $result['message']);
return;
}
// Reset restart count on successful deployment
$this->application->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
@ -151,19 +149,17 @@ public function restart()
deployment_uuid: $this->deploymentUuid,
restart_only: true,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);
return;
}
// Reset restart count on manual restart
$this->application->update([
'restart_count' => 0,
'last_restart_at' => now(),
'last_restart_type' => 'manual',
]);
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],

View file

@ -249,6 +249,11 @@ public function deploy(int $pull_request_id, ?string $pull_request_html_url = nu
pull_request_id: $pull_request_id,
git_type: $found->git_type ?? null,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);

View file

@ -52,7 +52,7 @@ public function rollbackImage($commit)
$deployment_uuid = new Cuid2;
queue_application_deployment(
$result = queue_application_deployment(
application: $this->application,
deployment_uuid: $deployment_uuid,
commit: $commit,
@ -60,7 +60,13 @@ public function rollbackImage($commit)
force_rebuild: false,
);
return redirect()->route('project.application.deployment.show', [
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
return redirectRoute($this, 'project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $deployment_uuid,

View file

@ -2,12 +2,9 @@
namespace App\Livewire\Project\Database;
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
@ -154,12 +151,8 @@ public function delete($password)
{
$this->authorize('manageBackups', $this->backup->database);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
try {

View file

@ -2,11 +2,9 @@
namespace App\Livewire\Project\Database;
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class BackupExecutions extends Component
@ -69,12 +67,8 @@ public function cleanupDeleted()
public function deleteBackup($executionId, $password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$execution = $this->backup->executions()->where('id', $executionId)->first();

View file

@ -39,7 +39,7 @@ public function delete()
if ($environment->isEmpty()) {
$environment->delete();
return redirect()->route('project.show', parameters: ['project_uuid' => $this->parameters['project_uuid']]);
return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]);
}
return $this->dispatch('error', "<strong>Environment {$environment->name}</strong> has defined resources, please delete them first.");

View file

@ -35,7 +35,7 @@ public function delete()
if ($project->isEmpty()) {
$project->delete();
return redirect()->route('project.index');
return redirectRoute($this, 'project.index');
}
return $this->dispatch('error', "<strong>Project {$project->name}</strong> has resources defined, please delete them first.");

View file

@ -63,7 +63,7 @@ public function submit()
{
try {
$this->syncData(true);
$this->redirectRoute('project.environment.edit', [
redirectRoute($this, 'project.environment.edit', [
'environment_uuid' => $this->environment->uuid,
'project_uuid' => $this->project->uuid,
]);

View file

@ -154,7 +154,7 @@ public function submit()
'fqdn' => $fqdn,
]);
return redirect()->route('project.application.configuration', [
return redirectRoute($this, 'project.application.configuration', [
'application_uuid' => $application->uuid,
'environment_uuid' => $environment->uuid,
'project_uuid' => $project->uuid,

View file

@ -16,6 +16,6 @@ public function createEmptyProject()
'uuid' => (string) new Cuid2,
]);
return redirect()->route('project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
return redirectRoute($this, 'project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
}
}

View file

@ -128,6 +128,7 @@ public function loadBranches()
$this->loadBranchByPage();
}
}
$this->branches = sortBranchesByPriority($this->branches);
$this->selected_branch_name = data_get($this->branches, '0.name', 'main');
}

View file

@ -53,6 +53,8 @@ class Select extends Component
protected $queryString = [
'server_id',
'type' => ['except' => ''],
'destination_uuid' => ['except' => '', 'as' => 'destination'],
];
public function mount()
@ -66,6 +68,20 @@ public function mount()
$project = Project::whereUuid($projectUuid)->firstOrFail();
$this->environments = $project->environments;
$this->selectedEnvironment = $this->environments->where('uuid', data_get($this->parameters, 'environment_uuid'))->firstOrFail()->name;
// Check if we have all required params for PostgreSQL type selection
// This handles navigation from global search
$queryType = request()->query('type');
$queryServerId = request()->query('server_id');
$queryDestination = request()->query('destination');
if ($queryType === 'postgresql' && $queryServerId !== null && $queryDestination) {
$this->type = $queryType;
$this->server_id = $queryServerId;
$this->destination_uuid = $queryDestination;
$this->server = Server::find($queryServerId);
$this->current_step = 'select-postgresql-type';
}
} catch (\Exception $e) {
return handleError($e, $this);
}

View file

@ -35,6 +35,13 @@ public function mount()
if (in_array($type, DATABASE_TYPES)) {
if ($type->value() === 'postgresql') {
// PostgreSQL requires database_image to be explicitly set
// If not provided, fall through to Select component for version selection
if (! $database_image) {
$this->type = $type->value();
return;
}
$database = create_standalone_postgresql(
environmentId: $environment->id,
destinationUuid: $destination_uuid,

View file

@ -1,35 +0,0 @@
<?php
namespace App\Livewire\Project\Resource;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Component;
class EnvironmentSelect extends Component
{
public Collection $environments;
public string $project_uuid = '';
public string $selectedEnvironment = '';
public function mount()
{
$this->selectedEnvironment = request()->route('environment_uuid');
$this->project_uuid = request()->route('project_uuid');
}
public function updatedSelectedEnvironment($value)
{
if ($value === 'edit') {
return redirect()->route('project.show', [
'project_uuid' => $this->project_uuid,
]);
} else {
return redirect()->route('project.resource.index', [
'project_uuid' => $this->project_uuid,
'environment_uuid' => $value,
]);
}
}
}

View file

@ -4,12 +4,9 @@
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Models\InstanceSettings;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class Database extends Component
@ -96,18 +93,14 @@ public function delete($password)
try {
$this->authorize('delete', $this->database);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->database->delete();
$this->dispatch('success', 'Database deleted.');
return redirect()->route('project.service.configuration', $this->parameters);
return redirectRoute($this, 'project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -171,7 +164,7 @@ public function convertToApplication()
$serviceDatabase->delete();
});
return redirect()->route('project.service.configuration', $redirectParams);
return redirectRoute($this, 'project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -3,7 +3,6 @@
namespace App\Livewire\Project\Service;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
@ -16,8 +15,6 @@
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
@ -62,7 +59,7 @@ public function mount()
$this->fs_path = $this->fileStorage->fs_path;
}
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
$this->syncData();
}
@ -104,7 +101,8 @@ public function convertToDirectory()
public function loadStorageOnServer()
{
try {
$this->authorize('update', $this->resource);
// Loading content is a read operation, so we use 'view' permission
$this->authorize('view', $this->resource);
$this->fileStorage->loadStorageOnServer();
$this->syncData();
@ -140,12 +138,8 @@ public function delete($password)
{
$this->authorize('update', $this->resource);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
try {

View file

@ -2,12 +2,9 @@
namespace App\Livewire\Project\Service;
use App\Models\InstanceSettings;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
@ -128,12 +125,8 @@ public function delete($password)
try {
$this->authorize('delete', $this->application);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->application->delete();

View file

@ -67,7 +67,7 @@ public function refreshStoragesFromEvent()
public function refreshStorages()
{
$this->fileStorage = $this->resource->fileStorages()->get();
$this->resource->refresh();
$this->resource->load('persistentStorages.resource');
}
public function getFilesProperty()

View file

@ -3,13 +3,10 @@
namespace App\Livewire\Project\Shared;
use App\Jobs\DeleteResourceJob;
use App\Models\InstanceSettings;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@ -93,12 +90,8 @@ public function mount()
public function delete($password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
if (! $this->resource) {
@ -118,7 +111,7 @@ public function delete($password)
$this->docker_cleanup
);
return redirect()->route('project.resource.index', [
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $this->projectUuid,
'environment_uuid' => $this->environmentUuid,
]);

View file

@ -5,12 +5,9 @@
use App\Actions\Application\StopApplicationOneServer;
use App\Actions\Docker\GetContainersStatus;
use App\Events\ApplicationStatusChanged;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\StandaloneDocker;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@ -89,13 +86,18 @@ public function redeploy(int $network_id, int $server_id)
only_this_server: true,
no_questions_asked: true,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);
return;
}
return redirect()->route('project.application.deployment.show', [
return redirectRoute($this, 'project.application.deployment.show', [
'project_uuid' => data_get($this->resource, 'environment.project.uuid'),
'application_uuid' => data_get($this->resource, 'uuid'),
'deployment_uuid' => $deployment_uuid,
@ -135,12 +137,8 @@ public function addServer(int $network_id, int $server_id)
public function removeServer(int $network_id, int $server_id, $password)
{
try {
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) {

View file

@ -21,6 +21,10 @@
class GetLogs extends Component
{
public const MAX_LOG_LINES = 50000;
public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
public string $outputs = '';
public string $errors = '';
@ -67,11 +71,6 @@ public function mount()
}
}
public function doSomethingWithThisChunkOfOutput($output)
{
$this->outputs .= removeAnsiColors($output);
}
public function instantSave()
{
if (! is_null($this->resource)) {
@ -128,6 +127,9 @@ public function getLogs($refresh = false)
if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) {
$this->numberOfLines = 1000;
}
if ($this->numberOfLines > self::MAX_LOG_LINES) {
$this->numberOfLines = self::MAX_LOG_LINES;
}
if ($this->container) {
if ($this->showTimeStamps) {
if ($this->server->isSwarm()) {
@ -162,23 +164,107 @@ public function getLogs($refresh = false)
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
}
}
if ($refresh) {
$this->outputs = '';
}
Process::run($sshCommand, function (string $type, string $output) {
$this->doSomethingWithThisChunkOfOutput($output);
// Collect new logs into temporary variable first to prevent flickering
// (avoids clearing output before new data is ready)
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
$logChunks = [];
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) {
$logChunks[] = removeAnsiColors($output);
});
$newOutputs = implode('', $logChunks);
if ($this->showTimeStamps) {
$this->outputs = str($this->outputs)->split('/\n/')->sort(function ($a, $b) {
$newOutputs = str($newOutputs)->split('/\n/')->sort(function ($a, $b) {
$a = explode(' ', $a);
$b = explode(' ', $b);
return $a[0] <=> $b[0];
})->join("\n");
}
// Only update outputs after new data is ready (atomic update prevents flicker)
$this->outputs = $newOutputs;
}
}
public function copyLogs(): string
{
return sanitizeLogsForExport($this->outputs);
}
public function downloadAllLogs(): string
{
if (! $this->server->isFunctional() || ! $this->container) {
return '';
}
if ($this->showTimeStamps) {
if ($this->server->isSwarm()) {
$command = "docker service logs -t {$this->container}";
} else {
$command = "docker logs -t {$this->container}";
}
} else {
if ($this->server->isSwarm()) {
$command = "docker service logs {$this->container}";
} else {
$command = "docker logs {$this->container}";
}
}
if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
// Enforce 50MB size limit to prevent memory exhaustion from large logs
$logChunks = [];
$accumulatedBytes = 0;
$truncated = false;
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) {
if ($truncated) {
return;
}
$output = removeAnsiColors($output);
$outputBytes = strlen($output);
if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) {
$remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes;
if ($remaining > 0) {
$logChunks[] = substr($output, 0, $remaining);
}
$truncated = true;
return;
}
$logChunks[] = $output;
$accumulatedBytes += $outputBytes;
});
$allLogs = implode('', $logChunks);
if ($truncated) {
$allLogs .= "\n\n[... Output truncated at 50MB limit ...]";
}
if ($this->showTimeStamps) {
$allLogs = str($allLogs)->split('/\n/')->sort(function ($a, $b) {
$a = explode(' ', $a);
$b = explode(' ', $b);
return $a[0] <=> $b[0];
})->join("\n");
}
return sanitizeLogsForExport($allLogs);
}
public function render()
{
return view('livewire.project.shared.get-logs');

View file

@ -2,11 +2,8 @@
namespace App\Livewire\Project\Shared\Storages;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class Show extends Component
@ -67,7 +64,7 @@ private function syncData(bool $toModel = false): void
public function mount()
{
$this->syncData(false);
$this->isReadOnly = $this->storage->isReadOnlyVolume();
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
}
public function submit()
@ -84,12 +81,8 @@ public function delete($password)
{
$this->authorize('update', $this->resource);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->storage->delete();

View file

@ -57,7 +57,14 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
$command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'");
// Add sudo for non-root users to access Docker socket
$dockerCommand = "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'";
if ($server->isNonRoot()) {
$dockerCommand = "sudo {$dockerCommand}";
}
$command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand);
} else {
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.

View file

@ -48,7 +48,7 @@ public function submit()
'uuid' => (string) new Cuid2,
]);
return redirect()->route('project.resource.index', [
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
]);
@ -59,7 +59,7 @@ public function submit()
public function navigateToEnvironment($projectUuid, $environmentUuid)
{
return redirect()->route('project.resource.index', [
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $projectUuid,
'environment_uuid' => $environmentUuid,
]);

View file

@ -114,7 +114,7 @@ private function validatePrivateKey()
private function redirectAfterCreation(PrivateKey $privateKey)
{
return $this->from === 'server'
? redirect()->route('dashboard')
: redirect()->route('security.private-key.show', ['private_key_uuid' => $privateKey->uuid]);
? redirectRoute($this, 'dashboard')
: redirectRoute($this, 'security.private-key.show', ['private_key_uuid' => $privateKey->uuid]);
}
}

View file

@ -107,7 +107,7 @@ public function delete()
$this->private_key->safeDelete();
currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get();
return redirect()->route('security.private-key.index');
return redirectRoute($this, 'security.private-key.index');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
} catch (\Throwable $e) {

View file

@ -24,6 +24,9 @@ class Advanced extends Component
#[Validate(['integer', 'min:1'])]
public int $dynamicTimeout = 1;
#[Validate(['integer', 'min:1'])]
public int $deploymentQueueLimit = 25;
public function mount(string $server_uuid)
{
try {
@ -43,12 +46,14 @@ public function syncData(bool $toModel = false)
$this->validate();
$this->server->settings->concurrent_builds = $this->concurrentBuilds;
$this->server->settings->dynamic_timeout = $this->dynamicTimeout;
$this->server->settings->deployment_queue_limit = $this->deploymentQueueLimit;
$this->server->settings->server_disk_usage_notification_threshold = $this->serverDiskUsageNotificationThreshold;
$this->server->settings->server_disk_usage_check_frequency = $this->serverDiskUsageCheckFrequency;
$this->server->settings->save();
} else {
$this->concurrentBuilds = $this->server->settings->concurrent_builds;
$this->dynamicTimeout = $this->server->settings->dynamic_timeout;
$this->deploymentQueueLimit = $this->server->settings->deployment_queue_limit;
$this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold;
$this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency;
}

View file

@ -3,11 +3,8 @@
namespace App\Livewire\Server;
use App\Actions\Server\DeleteServer;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class Delete extends Component
@ -29,12 +26,8 @@ public function mount(string $server_uuid)
public function delete($password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
try {
$this->authorize('delete', $this->server);
@ -53,7 +46,7 @@ public function delete($password)
$this->server->team_id
);
return redirect()->route('server.index');
return redirectRoute($this, 'server.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -2,6 +2,7 @@
namespace App\Livewire\Server;
use App\Jobs\ConnectProxyToNetworksJob;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
@ -29,8 +30,7 @@ public function mount(string $server_uuid)
private function createNetworkAndAttachToProxy()
{
$connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
ConnectProxyToNetworksJob::dispatchSync($this->server);
}
public function add($name)

View file

@ -567,10 +567,10 @@ public function submit()
]);
refreshSession();
return $this->redirect(route('server.show', $server->uuid));
return redirectRoute($this, 'server.show', [$server->uuid]);
}
return redirect()->route('server.show', $server->uuid);
return redirectRoute($this, 'server.show', [$server->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -128,7 +128,7 @@ public function submit()
$server->settings->is_build_server = $this->is_build_server;
$server->settings->save();
return redirect()->route('server.show', $server->uuid);
return redirectRoute($this, 'server.show', [$server->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -4,6 +4,7 @@
use App\Enums\ProxyTypes;
use App\Models\Server;
use App\Rules\ValidProxyConfigFilename;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Symfony\Component\Yaml\Yaml;
@ -38,11 +39,11 @@ public function addDynamicConfiguration()
try {
$this->authorize('update', $this->server);
$this->validate([
'fileName' => 'required',
'fileName' => ['required', new ValidProxyConfigFilename],
'value' => 'required',
]);
// Validate filename to prevent command injection
// Additional security validation to prevent command injection
validateShellSafePath($this->fileName, 'proxy configuration filename');
if (data_get($this->parameters, 'server_uuid')) {

View file

@ -4,7 +4,6 @@
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
class Resources extends Component
@ -15,7 +14,7 @@ class Resources extends Component
public $parameters = [];
public Collection $containers;
public array $unmanagedContainers = [];
public $activeTab = 'managed';
@ -64,7 +63,7 @@ public function loadManagedContainers()
{
try {
$this->activeTab = 'managed';
$this->containers = $this->server->refresh()->definedResources();
$this->server->refresh();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -74,7 +73,7 @@ public function loadUnmanagedContainers()
{
$this->activeTab = 'unmanaged';
try {
$this->containers = $this->server->loadUnmanagedContainers();
$this->unmanagedContainers = $this->server->loadUnmanagedContainers()->toArray();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -82,14 +81,12 @@ public function loadUnmanagedContainers()
public function mount()
{
$this->containers = collect();
$this->parameters = get_route_parameters();
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->first();
if (is_null($this->server)) {
return redirect()->route('server.index');
}
$this->loadManagedContainers();
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -2,11 +2,8 @@
namespace App\Livewire\Server\Security;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
@ -44,13 +41,9 @@ public function toggleTerminal($password)
throw new \Exception('Only team administrators and owners can modify terminal access.');
}
// Verify password unless two-step confirmation is disabled
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
// Verify password
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
// Toggle the terminal setting

View file

@ -0,0 +1,172 @@
<?php
namespace App\Livewire\Server;
use App\Actions\Server\StartSentinel;
use App\Actions\Server\StopSentinel;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Sentinel extends Component
{
use AuthorizesRequests;
public Server $server;
public array $parameters = [];
public bool $isMetricsEnabled;
#[Validate(['required'])]
public string $sentinelToken;
public ?string $sentinelUpdatedAt = null;
#[Validate(['required', 'integer', 'min:1'])]
public int $sentinelMetricsRefreshRateSeconds;
#[Validate(['required', 'integer', 'min:1'])]
public int $sentinelMetricsHistoryDays;
#[Validate(['required', 'integer', 'min:10'])]
public int $sentinelPushIntervalSeconds;
#[Validate(['nullable', 'url'])]
public ?string $sentinelCustomUrl = null;
public bool $isSentinelEnabled;
public bool $isSentinelDebugEnabled;
public ?string $sentinelCustomDockerImage = null;
public function getListeners()
{
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},SentinelRestarted" => 'handleSentinelRestarted',
];
}
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->parameters = get_route_parameters();
$this->syncData();
} catch (\Throwable) {
return redirect()->route('server.index');
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->authorize('update', $this->server);
$this->validate();
$this->server->settings->is_metrics_enabled = $this->isMetricsEnabled;
$this->server->settings->sentinel_token = $this->sentinelToken;
$this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds;
$this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays;
$this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds;
$this->server->settings->sentinel_custom_url = $this->sentinelCustomUrl;
$this->server->settings->is_sentinel_enabled = $this->isSentinelEnabled;
$this->server->settings->is_sentinel_debug_enabled = $this->isSentinelDebugEnabled;
$this->server->settings->save();
} else {
$this->isMetricsEnabled = $this->server->settings->is_metrics_enabled;
$this->sentinelToken = $this->server->settings->sentinel_token;
$this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds;
$this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days;
$this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds;
$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;
$this->isSentinelEnabled = $this->server->settings->is_sentinel_enabled;
$this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled;
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
}
}
public function handleSentinelRestarted($event)
{
if ($event['serverUuid'] === $this->server->uuid) {
$this->server->refresh();
$this->syncData();
$this->dispatch('success', 'Sentinel has been restarted successfully.');
}
}
public function restartSentinel()
{
try {
$this->authorize('manageSentinel', $this->server);
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
$this->server->restartSentinel($customImage);
$this->dispatch('info', 'Restarting Sentinel.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function updatedIsSentinelEnabled($value)
{
try {
$this->authorize('manageSentinel', $this->server);
if ($value === true) {
if ($this->server->isBuildServer()) {
$this->isSentinelEnabled = false;
$this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
return;
}
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
StartSentinel::run($this->server, true, null, $customImage);
} else {
$this->isMetricsEnabled = false;
$this->isSentinelDebugEnabled = false;
StopSentinel::dispatch($this->server);
}
$this->submit();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function regenerateSentinelToken()
{
try {
$this->authorize('manageSentinel', $this->server);
$this->server->settings->generateSentinelToken();
$this->dispatch('success', 'Token regenerated. Restarting Sentinel.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submit()
{
try {
$this->syncData(true);
$this->dispatch('success', 'Sentinel settings updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
$this->syncData(true);
$this->restartSentinel();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.sentinel');
}
}

View file

@ -5,9 +5,12 @@
use App\Actions\Server\StartSentinel;
use App\Actions\Server\StopSentinel;
use App\Events\ServerReachabilityChanged;
use App\Models\CloudProviderToken;
use App\Models\Server;
use App\Services\HetznerService;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
@ -73,6 +76,19 @@ class Show extends Component
public bool $isValidating = false;
// Hetzner linking properties
public Collection $availableHetznerTokens;
public ?int $selectedHetznerTokenId = null;
public ?string $manualHetznerServerId = null;
public ?array $matchedHetznerServer = null;
public ?string $hetznerSearchError = null;
public bool $hetznerNoMatchFound = false;
public function getListeners()
{
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
@ -150,6 +166,9 @@ public function mount(string $server_uuid)
$this->hetznerServerStatus = $this->server->hetzner_server_status;
$this->isValidating = $this->server->is_validating ?? false;
// Load Hetzner tokens for linking
$this->loadHetznerTokens();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -430,6 +449,10 @@ public function handleServerValidated($event = null)
// Update validation state
$this->isValidating = $this->server->is_validating ?? false;
// Reload Hetzner tokens in case the linking section should now be shown
$this->loadHetznerTokens();
$this->dispatch('refreshServerShow');
$this->dispatch('refreshServer');
}
@ -465,6 +488,140 @@ public function submit()
}
}
public function loadHetznerTokens(): void
{
$this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->get();
}
public function searchHetznerServer(): void
{
$this->hetznerSearchError = null;
$this->hetznerNoMatchFound = false;
$this->matchedHetznerServer = null;
if (! $this->selectedHetznerTokenId) {
$this->hetznerSearchError = 'Please select a Hetzner token.';
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
if (! $token) {
$this->hetznerSearchError = 'Invalid token selected.';
return;
}
$hetznerService = new HetznerService($token->token);
$matched = $hetznerService->findServerByIp($this->server->ip);
if ($matched) {
$this->matchedHetznerServer = $matched;
} else {
$this->hetznerNoMatchFound = true;
}
} catch (\Throwable $e) {
$this->hetznerSearchError = 'Failed to search Hetzner servers: '.$e->getMessage();
}
}
public function searchHetznerServerById(): void
{
$this->hetznerSearchError = null;
$this->hetznerNoMatchFound = false;
$this->matchedHetznerServer = null;
if (! $this->selectedHetznerTokenId) {
$this->hetznerSearchError = 'Please select a Hetzner token first.';
return;
}
if (! $this->manualHetznerServerId) {
$this->hetznerSearchError = 'Please enter a Hetzner Server ID.';
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
if (! $token) {
$this->hetznerSearchError = 'Invalid token selected.';
return;
}
$hetznerService = new HetznerService($token->token);
$serverData = $hetznerService->getServer((int) $this->manualHetznerServerId);
if (! empty($serverData)) {
$this->matchedHetznerServer = $serverData;
} else {
$this->hetznerNoMatchFound = true;
}
} catch (\Throwable $e) {
$this->hetznerSearchError = 'Failed to fetch Hetzner server: '.$e->getMessage();
}
}
public function linkToHetzner()
{
if (! $this->matchedHetznerServer) {
$this->dispatch('error', 'No Hetzner server selected.');
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
if (! $token) {
$this->dispatch('error', 'Invalid token selected.');
return;
}
// Verify the server exists and is accessible with the token
$hetznerService = new HetznerService($token->token);
$serverData = $hetznerService->getServer($this->matchedHetznerServer['id']);
if (empty($serverData)) {
$this->dispatch('error', 'Could not find Hetzner server with ID: '.$this->matchedHetznerServer['id']);
return;
}
// Update the server with Hetzner details
$this->server->update([
'cloud_provider_token_id' => $this->selectedHetznerTokenId,
'hetzner_server_id' => $this->matchedHetznerServer['id'],
'hetzner_server_status' => $serverData['status'] ?? null,
]);
$this->hetznerServerStatus = $serverData['status'] ?? null;
// Clear the linking state
$this->matchedHetznerServer = null;
$this->selectedHetznerTokenId = null;
$this->manualHetznerServerId = null;
$this->hetznerNoMatchFound = false;
$this->hetznerSearchError = null;
$this->dispatch('success', 'Server successfully linked to Hetzner Cloud!');
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.show');

View file

@ -0,0 +1,59 @@
<?php
namespace App\Livewire\Server;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Swarm extends Component
{
use AuthorizesRequests;
public Server $server;
public array $parameters = [];
public bool $isSwarmManager;
public bool $isSwarmWorker;
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->parameters = get_route_parameters();
$this->syncData();
} catch (\Throwable) {
return redirect()->route('server.index');
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->authorize('update', $this->server);
$this->server->settings->is_swarm_manager = $this->isSwarmManager;
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
$this->server->settings->save();
} else {
$this->isSwarmManager = $this->server->settings->is_swarm_manager;
$this->isSwarmWorker = $this->server->settings->is_swarm_worker;
}
}
public function instantSave()
{
try {
$this->syncData(true);
$this->dispatch('success', 'Swarm settings updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.swarm');
}
}

View file

@ -3,18 +3,12 @@
namespace App\Livewire\Settings;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Rules\ValidIpOrCidr;
use Auth;
use Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Advanced extends Component
{
#[Validate('required')]
public Server $server;
public InstanceSettings $settings;
#[Validate('boolean')]
@ -40,10 +34,12 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $disable_two_step_confirmation;
#[Validate('boolean')]
public bool $is_wire_navigate_enabled;
public function rules()
{
return [
'server' => 'required',
'is_registration_enabled' => 'boolean',
'do_not_track' => 'boolean',
'is_dns_validation_enabled' => 'boolean',
@ -52,6 +48,7 @@ public function rules()
'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr],
'is_sponsorship_popup_enabled' => 'boolean',
'disable_two_step_confirmation' => 'boolean',
'is_wire_navigate_enabled' => 'boolean',
];
}
@ -60,7 +57,6 @@ public function mount()
if (! isInstanceAdmin()) {
return redirect()->route('dashboard');
}
$this->server = Server::findOrFail(0);
$this->settings = instanceSettings();
$this->custom_dns_servers = $this->settings->custom_dns_servers;
$this->allowed_ips = $this->settings->allowed_ips;
@ -70,6 +66,7 @@ public function mount()
$this->is_api_enabled = $this->settings->is_api_enabled;
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
}
public function submit()
@ -148,6 +145,7 @@ public function instantSave()
$this->settings->allowed_ips = $this->allowed_ips;
$this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled;
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
} catch (\Exception $e) {
@ -157,9 +155,7 @@ public function instantSave()
public function toggleTwoStepConfirmation($password): bool
{
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
if (! verifyPasswordConfirmation($password, $this)) {
return false;
}

View file

@ -12,7 +12,7 @@ class Index extends Component
{
public InstanceSettings $settings;
public Server $server;
public ?Server $server = null;
#[Validate('nullable|string|max:255')]
public ?string $fqdn = null;
@ -57,7 +57,9 @@ public function mount()
return redirect()->route('dashboard');
}
$this->settings = instanceSettings();
$this->server = Server::findOrFail(0);
if (! isCloud()) {
$this->server = Server::findOrFail(0);
}
$this->fqdn = $this->settings->fqdn;
$this->public_port_min = $this->settings->public_port_min;
$this->public_port_max = $this->settings->public_port_max;
@ -80,7 +82,7 @@ public function timezones(): array
public function instantSave($isSave = true)
{
$this->validate();
$this->settings->fqdn = $this->fqdn;
$this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn;
$this->settings->public_port_min = $this->public_port_min;
$this->settings->public_port_max = $this->public_port_max;
$this->settings->instance_name = $this->instance_name;
@ -119,9 +121,15 @@ public function submit()
return;
}
// Trim FQDN to remove leading/trailing whitespace before validation
if ($this->fqdn) {
$this->fqdn = trim($this->fqdn);
}
$this->validate();
if ($this->settings->is_dns_validation_enabled && $this->fqdn) {
if ($this->settings->is_dns_validation_enabled && $this->fqdn && $this->server) {
if (! validateDNSEntry($this->fqdn, $this->server)) {
$this->dispatch('error', "Validating DNS failed.<br><br>Make sure you have added the DNS records correctly.<br><br>{$this->fqdn}->{$this->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$error_show = true;
@ -145,7 +153,9 @@ public function submit()
$this->instantSave(isSave: false);
$this->settings->save();
$this->server->setupDynamicProxyConfiguration();
if ($this->server) {
$this->server->setupDynamicProxyConfiguration();
}
if (! $error_show) {
$this->dispatch('success', 'Instance settings updated successfully!');
}
@ -163,6 +173,12 @@ public function buildHelperImage()
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not available.');
return;
}
$version = $this->dev_helper_version ?: config('constants.coolify.helper_version');
if (empty($version)) {
$this->dispatch('error', 'Please specify a version to build.');

View file

@ -12,7 +12,7 @@ class Updates extends Component
{
public InstanceSettings $settings;
public Server $server;
public ?Server $server = null;
#[Validate('string')]
public string $auto_update_frequency;
@ -25,7 +25,9 @@ class Updates extends Component
public function mount()
{
$this->server = Server::findOrFail(0);
if (! isCloud()) {
$this->server = Server::findOrFail(0);
}
$this->settings = instanceSettings();
$this->auto_update_frequency = $this->settings->auto_update_frequency;
@ -76,7 +78,9 @@ public function submit()
}
$this->instantSave();
$this->server->setupDynamicProxyConfiguration();
if ($this->server) {
$this->server->setupDynamicProxyConfiguration();
}
} catch (\Exception $e) {
return handleError($e, $this);
}

View file

@ -58,7 +58,7 @@ public function createGitHubApp()
session(['from' => session('from') + ['source_id' => $github_app->id]]);
}
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -116,7 +116,7 @@ public function submit()
$this->storage->testConnection();
$this->storage->save();
return redirect()->route('storage.show', $this->storage->uuid);
return redirectRoute($this, 'storage.show', [$this->storage->uuid]);
} catch (\Throwable $e) {
$this->dispatch('error', 'Failed to create storage.', $e->getMessage());
// return handleError($e, $this);

View file

@ -2,10 +2,7 @@
namespace App\Livewire\Team;
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class AdminView extends Component
@ -58,12 +55,8 @@ public function delete($id, $password)
return redirect()->route('dashboard');
}
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
if (! auth()->user()->isInstanceAdmin()) {

View file

@ -37,7 +37,7 @@ public function submit()
auth()->user()->teams()->attach($team, ['role' => 'admin']);
refreshSession($team);
return redirect()->route('team.index');
return redirectRoute($this, 'team.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -48,7 +48,7 @@ private function generateInviteLink(bool $sendEmail = false)
// Prevent privilege escalation: users cannot invite someone with higher privileges
$userRole = auth()->user()->role();
if ($userRole === 'member' && in_array($this->role, ['admin', 'owner'])) {
if (is_null($userRole) || ($userRole === 'member' && in_array($this->role, ['admin', 'owner']))) {
throw new \Exception('Members cannot invite admins or owners.');
}
if ($userRole === 'admin' && $this->role === 'owner') {

View file

@ -71,11 +71,11 @@ public function remove()
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
throw new \Exception('You are not authorized to perform this action.');
}
$teamId = currentTeam()->id;
$this->member->teams()->detach(currentTeam());
// Clear cache for the removed user - both old and new key formats
Cache::forget("team:{$this->member->id}");
Cache::remember('team:'.$this->member->id, 3600, function () {
return $this->member->teams()->first();
});
Cache::forget("user:{$this->member->id}:team:{$teamId}");
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());

View file

@ -4,6 +4,7 @@
use App\Actions\Server\UpdateCoolify;
use App\Models\InstanceSettings;
use App\Models\Server;
use Livewire\Component;
class Upgrade extends Component
@ -14,12 +15,23 @@ class Upgrade extends Component
public string $latestVersion = '';
public string $currentVersion = '';
public bool $devMode = false;
protected $listeners = ['updateAvailable' => 'checkUpdate'];
public function mount()
{
$this->currentVersion = config('constants.coolify.version');
$this->devMode = isDev();
}
public function checkUpdate()
{
try {
$this->latestVersion = get_latest_version_of_coolify();
$this->currentVersion = config('constants.coolify.version');
$this->isUpgradeAvailable = data_get(InstanceSettings::get(), 'new_version_available', false);
if (isDev()) {
$this->isUpgradeAvailable = true;
@ -41,4 +53,71 @@ public function upgrade()
return handleError($e, $this);
}
}
public function getUpgradeStatus(): array
{
// Only root team members can view upgrade status
if (auth()->user()?->currentTeam()?->id !== 0) {
return ['status' => 'none'];
}
$server = Server::find(0);
if (! $server) {
return ['status' => 'none'];
}
$statusFile = '/data/coolify/source/.upgrade-status';
try {
$content = instant_remote_process(
["cat {$statusFile} 2>/dev/null || echo ''"],
$server,
false
);
$content = trim($content ?? '');
} catch (\Throwable $e) {
return ['status' => 'none'];
}
if (empty($content)) {
return ['status' => 'none'];
}
$parts = explode('|', $content);
if (count($parts) < 3) {
return ['status' => 'none'];
}
[$step, $message, $timestamp] = $parts;
// Check if status is stale (older than 10 minutes)
try {
$statusTime = new \DateTime($timestamp);
$now = new \DateTime;
$diffMinutes = ($now->getTimestamp() - $statusTime->getTimestamp()) / 60;
if ($diffMinutes > 10) {
return ['status' => 'none'];
}
} catch (\Throwable $e) {
return ['status' => 'none'];
}
if ($step === 'error') {
return [
'status' => 'error',
'step' => 0,
'message' => $message,
];
}
$stepInt = (int) $step;
$status = $stepInt >= 6 ? 'complete' : 'in_progress';
return [
'status' => $status,
'step' => $stepInt,
'message' => $message,
];
}
}

View file

@ -6,6 +6,7 @@
use App\Services\ConfigurationGenerator;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasConfiguration;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -111,7 +112,7 @@
class Application extends BaseModel
{
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
private static $parserVersion = '5';
@ -1584,6 +1585,11 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
try {
$composeFileContent = instant_remote_process($commands, $this->destination->server);
} catch (\Exception $e) {
// Restore original values on failure only
$this->docker_compose_location = $initialDockerComposeLocation;
$this->base_directory = $initialBaseDirectory;
$this->save();
if (str($e->getMessage())->contains('No such file')) {
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
@ -1595,9 +1601,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
}
throw new \RuntimeException($e->getMessage());
} finally {
$this->docker_compose_location = $initialDockerComposeLocation;
$this->base_directory = $initialBaseDirectory;
$this->save();
// Cleanup only - restoration happens in catch block
$commands = collect([
"rm -rf /tmp/{$uuid}",
]);
@ -1643,6 +1647,11 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
'initialDockerComposeLocation' => $this->docker_compose_location,
];
} else {
// Restore original values before throwing
$this->docker_compose_location = $initialDockerComposeLocation;
$this->base_directory = $initialBaseDirectory;
$this->save();
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
}
@ -1969,54 +1978,6 @@ public static function getDomainsByUuid(string $uuid): array
return [];
}
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
if ($server->isMetricsEnabled()) {
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
if ($server->isMetricsEnabled()) {
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
}
public function getLimits(): array
{
return [

View file

@ -77,21 +77,21 @@ public function generate_preview_fqdn()
if ($this->application->fqdn) {
if (str($this->application->fqdn)->contains(',')) {
$url = Url::fromString(str($this->application->fqdn)->explode(',')[0]);
$preview_fqdn = getFqdnWithoutPort(str($this->application->fqdn)->explode(',')[0]);
} else {
$url = Url::fromString($this->application->fqdn);
if ($this->fqdn) {
$preview_fqdn = getFqdnWithoutPort($this->fqdn);
}
}
$template = $this->application->preview_url_template;
$host = $url->getHost();
$schema = $url->getScheme();
$portInt = $url->getPort();
$port = $portInt !== null ? ':'.$portInt : '';
$urlPath = $url->getPath();
$path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
$random = new Cuid2;
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn);
$preview_fqdn = "$schema://$preview_fqdn";
$preview_fqdn = "$schema://$preview_fqdn{$port}{$path}";
$this->fqdn = $preview_fqdn;
$this->save();
}
@ -147,11 +147,13 @@ public function generate_preview_fqdn_compose()
$schema = $url->getScheme();
$portInt = $url->getPort();
$port = $portInt !== null ? ':'.$portInt : '';
$urlPath = $url->getPath();
$path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : '';
$random = new Cuid2;
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn);
$preview_fqdn = "$schema://$preview_fqdn{$port}";
$preview_fqdn = "$schema://$preview_fqdn{$port}{$path}";
$preview_domains[] = $preview_fqdn;
}

View file

@ -2,9 +2,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CloudProviderToken extends Model
class CloudProviderToken extends BaseModel
{
protected $guarded = [];

View file

@ -29,6 +29,7 @@ class InstanceSettings extends Model
'auto_update_frequency' => 'string',
'update_check_frequency' => 'string',
'sentinel_token' => 'encrypted',
'is_wire_navigate_enabled' => 'boolean',
];
protected static function booted(): void

View file

@ -209,6 +209,23 @@ public function scopeWherePlainMountPath($query, $path)
return $query->get()->where('plain_mount_path', $path);
}
// Check if this volume belongs to a service resource
public function isServiceResource(): bool
{
return in_array($this->resource_type, [
'App\Models\ServiceApplication',
'App\Models\ServiceDatabase',
]);
}
// Determine if this volume should be read-only in the UI
// File/directory mounts can be edited even for services
public function shouldBeReadOnlyInUI(): bool
{
// Check for explicit :ro flag in compose (existing logic)
return $this->isReadOnlyVolume();
}
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
@ -239,22 +256,40 @@ public function isReadOnlyVolume(): bool
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
// Note: We match on mount_path (container path) only, since fs_path gets transformed
// from relative (./file) to absolute (/data/coolify/services/uuid/file) during parsing
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
$parts = explode(':', $volume);
// Check if this volume matches our fs_path and mount_path
// Check if this volume matches our mount_path
if (count($parts) >= 2) {
$hostPath = $parts[0];
$containerPath = $parts[1];
$options = $parts[2] ?? null;
// Match based on fs_path and mount_path
if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) {
// Match based on mount_path
// Remove leading slash from mount_path if present for comparison
$mountPath = str($this->mount_path)->ltrim('/')->toString();
$containerPathClean = str($containerPath)->ltrim('/')->toString();
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
return $options === 'ro';
}
}
} elseif (is_array($volume)) {
// Long-form syntax: { type: bind, source: ..., target: ..., read_only: true }
$containerPath = data_get($volume, 'target');
$readOnly = data_get($volume, 'read_only', false);
// Match based on mount_path
// Remove leading slash from mount_path if present for comparison
$mountPath = str($this->mount_path)->ltrim('/')->toString();
$containerPathClean = str($containerPath)->ltrim('/')->toString();
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
return $readOnly === true;
}
}
}

View file

@ -10,6 +10,11 @@ class LocalPersistentVolume extends Model
{
protected $guarded = [];
public function resource()
{
return $this->morphTo('resource');
}
public function application()
{
return $this->morphTo('resource');
@ -50,6 +55,54 @@ protected function hostPath(): Attribute
);
}
// Check if this volume belongs to a service resource
public function isServiceResource(): bool
{
return in_array($this->resource_type, [
'App\Models\ServiceApplication',
'App\Models\ServiceDatabase',
]);
}
// Check if this volume belongs to a dockercompose application
public function isDockerComposeResource(): bool
{
if ($this->resource_type !== 'App\Models\Application') {
return false;
}
// Only access relationship if already eager loaded to avoid N+1
if (! $this->relationLoaded('resource')) {
return false;
}
$application = $this->resource;
if (! $application) {
return false;
}
return data_get($application, 'build_pack') === 'dockercompose';
}
// Determine if this volume should be read-only in the UI
// Service volumes and dockercompose application volumes are read-only
// (users should edit compose file directly)
public function shouldBeReadOnlyInUI(): bool
{
// All service volumes should be read-only in UI
if ($this->isServiceResource()) {
return true;
}
// All dockercompose application volumes should be read-only in UI
if ($this->isDockerComposeResource()) {
return true;
}
// Check for explicit :ro flag in compose (existing logic)
return $this->isReadOnlyVolume();
}
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
@ -85,6 +138,7 @@ public function isReadOnlyVolume(): bool
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
// Note: We match on mount_path (container path) only, since host paths get transformed
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
@ -104,6 +158,19 @@ public function isReadOnlyVolume(): bool
return $options === 'ro';
}
}
} elseif (is_array($volume)) {
// Long-form syntax: { type: bind/volume, source: ..., target: ..., read_only: true }
$containerPath = data_get($volume, 'target');
$readOnly = data_get($volume, 'read_only', false);
// Match based on mount_path
// Remove leading slash from mount_path if present for comparison
$mountPath = str($this->mount_path)->ltrim('/')->toString();
$containerPathClean = str($containerPath)->ltrim('/')->toString();
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
return $readOnly === true;
}
}
}

View file

@ -20,6 +20,28 @@ class S3Storage extends BaseModel
'secret' => 'encrypted',
];
/**
* Boot the model and register event listeners.
*/
protected static function boot(): void
{
parent::boot();
// Trim whitespace from credentials before saving to prevent
// "Malformed Access Key Id" errors from accidental whitespace in pasted values.
// Note: We use the saving event instead of Attribute mutators because key/secret
// use Laravel's 'encrypted' cast. Attribute mutators fire before casts, which
// would cause issues with the encryption/decryption cycle.
static::saving(function (S3Storage $storage) {
if ($storage->key !== null) {
$storage->key = trim($storage->key);
}
if ($storage->secret !== null) {
$storage->secret = trim($storage->secret);
}
});
}
public static function ownedByCurrentTeam(array $select = ['*'])
{
$selectArray = collect($select)->concat(['id']);
@ -55,6 +77,36 @@ protected function path(): Attribute
);
}
/**
* Trim whitespace from endpoint to prevent malformed URLs.
*/
protected function endpoint(): Attribute
{
return Attribute::make(
set: fn (?string $value) => $value ? trim($value) : null,
);
}
/**
* Trim whitespace from bucket name to prevent connection errors.
*/
protected function bucket(): Attribute
{
return Attribute::make(
set: fn (?string $value) => $value ? trim($value) : null,
);
}
/**
* Trim whitespace from region to prevent connection errors.
*/
protected function region(): Attribute
{
return Attribute::make(
set: fn (?string $value) => $value ? trim($value) : null,
);
}
public function testConnection(bool $shouldSave = false)
{
try {

View file

@ -16,6 +16,7 @@
use App\Notifications\Server\Unreachable;
use App\Services\ConfigurationRepository;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -103,7 +104,7 @@
class Server extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, SchemalessAttributesTrait, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes;
public static $batch_counter = 0;
@ -667,51 +668,6 @@ public function checkSentinel()
CheckAndStartSentinelJob::dispatch($this);
}
public function getCpuMetrics(int $mins = 5)
{
if ($this->isMetricsEnabled()) {
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$cpu = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://localhost:8888/api/cpu/history?from=$from'"], $this, false);
if (str($cpu)->contains('error')) {
$error = json_decode($cpu, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$cpu = json_decode($cpu, true);
return collect($cpu)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
}
}
public function getMemoryMetrics(int $mins = 5)
{
if ($this->isMetricsEnabled()) {
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$memory = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://localhost:8888/api/memory/history?from=$from'"], $this, false);
if (str($memory)->contains('error')) {
$error = json_decode($memory, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$memory = json_decode($memory, true);
$parsedCollection = collect($memory)->map(function ($metric) {
$usedPercent = $metric['usedPercent'] ?? 0.0;
return [(int) $metric['time'], (float) $usedPercent];
});
return $parsedCollection->toArray();
}
}
public function getDiskUsage(): ?string
{
return instant_remote_process(['df / --output=pcent | tr -cd 0-9'], $this, false);
@ -831,34 +787,67 @@ public function hasDefinedResources()
public function databases()
{
return $this->destinations()->map(function ($standaloneDocker) {
$postgresqls = data_get($standaloneDocker, 'postgresqls', collect([]));
$redis = data_get($standaloneDocker, 'redis', collect([]));
$mongodbs = data_get($standaloneDocker, 'mongodbs', collect([]));
$mysqls = data_get($standaloneDocker, 'mysqls', collect([]));
$mariadbs = data_get($standaloneDocker, 'mariadbs', collect([]));
$keydbs = data_get($standaloneDocker, 'keydbs', collect([]));
$dragonflies = data_get($standaloneDocker, 'dragonflies', collect([]));
$clickhouses = data_get($standaloneDocker, 'clickhouses', collect([]));
// Get destination IDs for this server in two efficient queries
$standaloneDockerIds = StandaloneDocker::where('server_id', $this->id)->pluck('id');
$swarmDockerIds = SwarmDocker::where('server_id', $this->id)->pluck('id');
return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs)->concat($keydbs)->concat($dragonflies)->concat($clickhouses);
})->flatten()->filter(function ($item) {
return data_get($item, 'name') !== 'coolify-db';
});
$destinationCondition = function ($query) use ($standaloneDockerIds, $swarmDockerIds) {
$query->where(function ($q) use ($standaloneDockerIds) {
$q->where('destination_type', StandaloneDocker::class)
->whereIn('destination_id', $standaloneDockerIds);
})->orWhere(function ($q) use ($swarmDockerIds) {
$q->where('destination_type', SwarmDocker::class)
->whereIn('destination_id', $swarmDockerIds);
});
};
// Query each database type with the destination condition
$postgresqls = StandalonePostgresql::where($destinationCondition)->get();
$redis = StandaloneRedis::where($destinationCondition)->get();
$mongodbs = StandaloneMongodb::where($destinationCondition)->get();
$mysqls = StandaloneMysql::where($destinationCondition)->get();
$mariadbs = StandaloneMariadb::where($destinationCondition)->get();
$keydbs = StandaloneKeydb::where($destinationCondition)->get();
$dragonflies = StandaloneDragonfly::where($destinationCondition)->get();
$clickhouses = StandaloneClickhouse::where($destinationCondition)->get();
return $postgresqls
->concat($redis)
->concat($mongodbs)
->concat($mysqls)
->concat($mariadbs)
->concat($keydbs)
->concat($dragonflies)
->concat($clickhouses)
->filter(fn ($item) => data_get($item, 'name') !== 'coolify-db');
}
public function applications()
{
$applications = $this->destinations()->map(function ($standaloneDocker) {
return $standaloneDocker->applications;
})->flatten();
$additionalApplicationIds = DB::table('additional_destinations')->where('server_id', $this->id)->get('application_id');
$additionalApplicationIds = collect($additionalApplicationIds)->map(function ($item) {
return $item->application_id;
});
Application::whereIn('id', $additionalApplicationIds)->get()->each(function ($application) use ($applications) {
$applications->push($application);
});
// Get destination IDs for this server in two efficient queries
$standaloneDockerIds = StandaloneDocker::where('server_id', $this->id)->pluck('id');
$swarmDockerIds = SwarmDocker::where('server_id', $this->id)->pluck('id');
// Query all applications in a single query using polymorphic conditions
$applications = Application::where(function ($query) use ($standaloneDockerIds, $swarmDockerIds) {
$query->where(function ($q) use ($standaloneDockerIds) {
$q->where('destination_type', StandaloneDocker::class)
->whereIn('destination_id', $standaloneDockerIds);
})->orWhere(function ($q) use ($swarmDockerIds) {
$q->where('destination_type', SwarmDocker::class)
->whereIn('destination_id', $swarmDockerIds);
});
})->get();
// Get additional server applications
$additionalApplicationIds = DB::table('additional_destinations')
->where('server_id', $this->id)
->pluck('application_id');
if ($additionalApplicationIds->isNotEmpty()) {
$additionalApps = Application::whereIn('id', $additionalApplicationIds)->get();
$applications = $applications->concat($additionalApps);
}
return $applications;
}

View file

@ -13,6 +13,7 @@
properties: [
'id' => ['type' => 'integer'],
'concurrent_builds' => ['type' => 'integer'],
'deployment_queue_limit' => ['type' => 'integer'],
'dynamic_timeout' => ['type' => 'integer'],
'force_disabled' => ['type' => 'boolean'],
'force_server_cleanup' => ['type' => 'boolean'],

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -10,7 +11,7 @@
class StandaloneClickhouse extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@ -18,6 +19,9 @@ class StandaloneClickhouse extends BaseModel
protected $casts = [
'clickhouse_password' => 'encrypted',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
'last_restart_type' => 'string',
];
protected static function booted()
@ -25,7 +29,7 @@ protected static function booted()
static::created(function ($database) {
LocalPersistentVolume::create([
'name' => 'clickhouse-data-'.$database->uuid,
'mount_path' => '/bitnami/clickhouse',
'mount_path' => '/var/lib/clickhouse',
'host_path' => null,
'resource_id' => $database->id,
'resource_type' => $database->getMorphClass(),
@ -246,8 +250,9 @@ protected function internalDbUrl(): Attribute
get: function () {
$encodedUser = rawurlencode($this->clickhouse_admin_user);
$encodedPass = rawurlencode($this->clickhouse_admin_password);
$database = $this->clickhouse_db ?? 'default';
return "clickhouse://{$encodedUser}:{$encodedPass}@{$this->uuid}:9000/{$this->clickhouse_db}";
return "clickhouse://{$encodedUser}:{$encodedPass}@{$this->uuid}:9000/{$database}";
},
);
}
@ -263,8 +268,9 @@ protected function externalDbUrl(): Attribute
}
$encodedUser = rawurlencode($this->clickhouse_admin_user);
$encodedPass = rawurlencode($this->clickhouse_admin_password);
$database = $this->clickhouse_db ?? 'default';
return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->clickhouse_db}";
return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$database}";
}
return null;
@ -315,50 +321,6 @@ public function scheduledBackups()
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
}
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable()
{
return false;

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Jobs\ConnectProxyToNetworksJob;
use App\Traits\HasSafeStringAttribute;
class StandaloneDocker extends BaseModel
@ -18,8 +19,7 @@ protected static function boot()
instant_remote_process([
"docker network inspect $newStandaloneDocker->network >/dev/null 2>&1 || docker network create --driver overlay --attachable $newStandaloneDocker->network >/dev/null",
], $server, false);
$connectProxyToDockerNetworks = connectProxyToNetworks($server);
instant_remote_process($connectProxyToDockerNetworks, $server, false);
ConnectProxyToNetworksJob::dispatchSync($server);
});
}

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -10,7 +11,7 @@
class StandaloneDragonfly extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@ -18,6 +19,9 @@ class StandaloneDragonfly extends BaseModel
protected $casts = [
'dragonfly_password' => 'encrypted',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
'last_restart_type' => 'string',
];
protected static function booted()
@ -313,50 +317,6 @@ public function scheduledBackups()
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
}
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable()
{
return false;

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -10,7 +11,7 @@
class StandaloneKeydb extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $guarded = [];
@ -18,6 +19,9 @@ class StandaloneKeydb extends BaseModel
protected $casts = [
'keydb_password' => 'encrypted',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
'last_restart_type' => 'string',
];
protected static function booted()
@ -313,50 +317,6 @@ public function scheduledBackups()
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
}
public function getCpuMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
return $parsedCollection->toArray();
}
public function getMemoryMetrics(int $mins = 5)
{
$server = $this->destination->server;
$container_name = $this->uuid;
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false);
if (str($metrics)->contains('error')) {
$error = json_decode($metrics, true);
$error = data_get($error, 'error', 'Something is not okay, are you okay?');
if ($error === 'Unauthorized') {
$error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.';
}
throw new \Exception($error);
}
$metrics = json_decode($metrics, true);
$parsedCollection = collect($metrics)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['used']];
});
return $parsedCollection->toArray();
}
public function isBackupSolutionAvailable()
{
return false;

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