diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 076f7d0c5..65a41db18 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -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 '' | ". - "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 ''"; + + // 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); } diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 5caae6afc..eb53b32ee 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -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}"; + } } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 8687104e0..d82d3a1b9 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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) diff --git a/app/Exceptions/RateLimitException.php b/app/Exceptions/RateLimitException.php new file mode 100644 index 000000000..fde0235dd --- /dev/null +++ b/app/Exceptions/RateLimitException.php @@ -0,0 +1,15 @@ + ['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']; diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php new file mode 100644 index 000000000..5a03fe59a --- /dev/null +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -0,0 +1,531 @@ +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'], + ]); + } +} diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 16a7b6f71..136fcf557 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -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."; diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php new file mode 100644 index 000000000..2645c2df1 --- /dev/null +++ b/app/Http/Controllers/Api/HetznerController.php @@ -0,0 +1,738 @@ +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); + } + } +} diff --git a/app/Http/Controllers/Api/OpenApi.php b/app/Http/Controllers/Api/OpenApi.php index 69f71feaf..33d21ba5d 100644 --- a/app/Http/Controllers/Api/OpenApi.php +++ b/app/Http/Controllers/Api/OpenApi.php @@ -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 diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index d322452d3..183186711 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -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', diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index f85d14089..b46db0b59 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -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', diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 93f225773..b2c211fa8 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -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', @@ -347,12 +351,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 +418,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', diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 9062d2875..5b8dd5686 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -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', diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 6b13d2cb7..cc1a44f9a 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -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() @@ -3934,13 +3957,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 +4007,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) { diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index 20dc9987e..a4619354d 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -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); } } diff --git a/app/Livewire/DeploymentsIndicator.php b/app/Livewire/DeploymentsIndicator.php index 268aed152..5c945ac01 100644 --- a/app/Livewire/DeploymentsIndicator.php +++ b/app/Livewire/DeploymentsIndicator.php @@ -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; diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php index 87f7cff8a..44ab419c2 100644 --- a/app/Livewire/Project/Application/Deployment/Show.php +++ b/app/Livewire/Project/Application/Deployment/Show.php @@ -20,12 +20,11 @@ class Show extends Component public bool $is_debug_enabled = false; + private bool $deploymentFinishedDispatched = false; + public function getListeners() { - $teamId = auth()->user()->currentTeam()->id; - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', 'refreshQueue', ]; } @@ -91,10 +90,15 @@ 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() diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index fc63c7f4b..a46b2f19c 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -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'], diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 45371678b..41f352c14 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -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']); diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index d6b490c79..e53784db5 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -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,6 +60,12 @@ public function rollbackImage($commit) force_rebuild: false, ); + if ($result['status'] === 'queue_full') { + $this->dispatch('error', 'Deployment queue full', $result['message']); + + return; + } + return redirect()->route('project.application.deployment.show', [ 'project_uuid' => $this->parameters['project_uuid'], 'application_uuid' => $this->parameters['application_uuid'], diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 40291d2b0..28e3f23e7 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -89,6 +89,11 @@ 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']); diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php index 8d17bb557..dba1b4903 100644 --- a/app/Livewire/Server/Advanced.php +++ b/app/Livewire/Server/Advanced.php @@ -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; } diff --git a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php index baf7b6b50..31a1dfc7e 100644 --- a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php +++ b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php @@ -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')) { diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 607040269..700ab0992 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -2,9 +2,7 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Model; - -class CloudProviderToken extends Model +class CloudProviderToken extends BaseModel { protected $guarded = []; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 4b33df300..0ad0fcf84 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -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'], diff --git a/app/Rules/ValidProxyConfigFilename.php b/app/Rules/ValidProxyConfigFilename.php new file mode 100644 index 000000000..871cc6eeb --- /dev/null +++ b/app/Rules/ValidProxyConfigFilename.php @@ -0,0 +1,73 @@ + 255) { + $fail('The :attribute must not exceed 255 characters.'); + + return; + } + + // Check for path separators (prevent path traversal) + if (str_contains($filename, '/') || str_contains($filename, '\\')) { + $fail('The :attribute cannot contain path separators.'); + + return; + } + + // Check for hidden files (starting with dot) + if (str_starts_with($filename, '.')) { + $fail('The :attribute cannot start with a dot (hidden files not allowed).'); + + return; + } + + // Check for valid characters only: alphanumeric, dashes, underscores, dots + if (! preg_match('/^[a-zA-Z0-9._-]+$/', $filename)) { + $fail('The :attribute may only contain letters, numbers, dashes, underscores, and dots.'); + + return; + } + + // Check for reserved filenames (case-sensitive for coolify.yaml/yml, case-insensitive check not needed as Caddyfile is exact) + if (in_array($filename, self::RESERVED_FILENAMES, true)) { + $fail('The :attribute uses a reserved filename.'); + + return; + } + } +} diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index dd4d6e631..f7855090a 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Exceptions\RateLimitException; use Illuminate\Support\Facades\Http; class HetznerService @@ -46,6 +47,19 @@ private function request(string $method, string $endpoint, array $data = []) ->{$method}($this->baseUrl.$endpoint, $data); if (! $response->successful()) { + if ($response->status() === 429) { + $retryAfter = $response->header('Retry-After'); + if ($retryAfter === null) { + $resetTime = $response->header('RateLimit-Reset'); + $retryAfter = $resetTime ? max(0, (int) $resetTime - time()) : null; + } + + throw new RateLimitException( + 'Rate limit exceeded. Please try again later.', + $retryAfter !== null ? (int) $retryAfter : null + ); + } + throw new \Exception('Hetzner API error: '.$response->json('error.message', 'Unknown error')); } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 58ae5f249..a60a47b93 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -140,9 +140,15 @@ public function execute_remote_command(...$commands) // If we exhausted all retries and still failed if (! $commandExecuted && $lastError) { // Now we can set the status to FAILED since all retries have been exhausted + // But only if the deployment hasn't already been marked as FINISHED if (isset($this->application_deployment_queue)) { - $this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value; - $this->application_deployment_queue->save(); + // Avoid clobbering a deployment that may have just been marked FINISHED + $this->application_deployment_queue->newQuery() + ->where('id', $this->application_deployment_queue->id) + ->where('status', '!=', ApplicationDeploymentStatus::FINISHED->value) + ->update([ + 'status' => ApplicationDeploymentStatus::FAILED->value, + ]); } throw $lastError; } diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 488653fb1..84bde5393 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -178,4 +178,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('use_build_server'); $request->offsetUnset('is_static'); $request->offsetUnset('force_domain_override'); + $request->offsetUnset('autogenerate_domain'); } diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 7a36c4b63..03c53989c 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -28,6 +28,20 @@ function queue_application_deployment(Application $application, string $deployme $destination_id = $destination->id; } + // Check if the deployment queue is full for this server + $serverForQueueCheck = $server ?? Server::find($server_id); + $queue_limit = $serverForQueueCheck->settings->deployment_queue_limit ?? 25; + $queued_count = ApplicationDeploymentQueue::where('server_id', $server_id) + ->where('status', ApplicationDeploymentStatus::QUEUED->value) + ->count(); + + if ($queued_count >= $queue_limit) { + return [ + 'status' => 'queue_full', + 'message' => 'Deployment queue is full. Please wait for existing deployments to complete.', + ]; + } + // Check if there's already a deployment in progress or queued for this application and commit $existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id) ->where('commit', $commit) diff --git a/config/constants.php b/config/constants.php index c55bec981..15ec73625 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.453', + 'version' => '4.0.0-beta.454', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/database/migrations/2025_12_04_134435_add_deployment_queue_limit_to_server_settings.php b/database/migrations/2025_12_04_134435_add_deployment_queue_limit_to_server_settings.php new file mode 100644 index 000000000..a1bcab5bb --- /dev/null +++ b/database/migrations/2025_12_04_134435_add_deployment_queue_limit_to_server_settings.php @@ -0,0 +1,28 @@ +integer('deployment_queue_limit')->default(25)->after('concurrent_builds'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('deployment_queue_limit'); + }); + } +}; diff --git a/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php b/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php new file mode 100644 index 000000000..bd285c180 --- /dev/null +++ b/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php @@ -0,0 +1,46 @@ +string('uuid')->nullable()->unique()->after('id'); + }); + + // Generate UUIDs for existing records using chunked processing + DB::table('cloud_provider_tokens') + ->whereNull('uuid') + ->chunkById(500, function ($tokens) { + foreach ($tokens as $token) { + DB::table('cloud_provider_tokens') + ->where('id', $token->id) + ->update(['uuid' => (string) new Cuid2]); + } + }); + + // Make uuid non-nullable after filling in values + Schema::table('cloud_provider_tokens', function (Blueprint $table) { + $table->string('uuid')->nullable(false)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('cloud_provider_tokens', function (Blueprint $table) { + $table->dropColumn('uuid'); + }); + } +}; diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index f012c1534..f5a00fe15 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -15,6 +15,7 @@ class ApplicationSeeder extends Seeder public function run(): void { Application::create([ + 'uuid' => 'docker-compose', 'name' => 'Docker Compose Example', 'repository_project_id' => 603035348, 'git_repository' => 'coollabsio/coolify-examples', @@ -30,6 +31,7 @@ public function run(): void 'source_type' => GithubApp::class, ]); Application::create([ + 'uuid' => 'nodejs', 'name' => 'NodeJS Fastify Example', 'fqdn' => 'http://nodejs.127.0.0.1.sslip.io', 'repository_project_id' => 603035348, @@ -45,6 +47,7 @@ public function run(): void 'source_type' => GithubApp::class, ]); Application::create([ + 'uuid' => 'dockerfile', 'name' => 'Dockerfile Example', 'fqdn' => 'http://dockerfile.127.0.0.1.sslip.io', 'repository_project_id' => 603035348, @@ -60,6 +63,7 @@ public function run(): void 'source_type' => GithubApp::class, ]); Application::create([ + 'uuid' => 'dockerfile-pure', 'name' => 'Pure Dockerfile Example', 'fqdn' => 'http://pure-dockerfile.127.0.0.1.sslip.io', 'git_repository' => 'coollabsio/coolify', @@ -75,6 +79,23 @@ public function run(): void 'dockerfile' => 'FROM nginx EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] +', + ]); + Application::create([ + 'uuid' => 'crashloop', + 'name' => 'Crash Loop Example', + 'git_repository' => 'coollabsio/coolify', + 'git_branch' => 'v4.x', + 'git_commit_sha' => 'HEAD', + 'build_pack' => 'dockerfile', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 0, + 'source_type' => GithubApp::class, + 'dockerfile' => 'FROM alpine +CMD ["sh", "-c", "echo Crashing in 5 seconds... && sleep 5 && exit 1"] ', ]); } diff --git a/database/seeders/GithubAppSeeder.php b/database/seeders/GithubAppSeeder.php index b34c00473..10e23c36a 100644 --- a/database/seeders/GithubAppSeeder.php +++ b/database/seeders/GithubAppSeeder.php @@ -14,6 +14,7 @@ public function run(): void { GithubApp::create([ 'id' => 0, + 'uuid' => 'github-public', 'name' => 'Public GitHub', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', @@ -22,7 +23,7 @@ public function run(): void ]); GithubApp::create([ 'name' => 'coolify-laravel-dev-public', - 'uuid' => '69420', + 'uuid' => 'github-app', 'organization' => 'coollabsio', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', diff --git a/database/seeders/GitlabAppSeeder.php b/database/seeders/GitlabAppSeeder.php index ec2b7ec5e..5dfb59902 100644 --- a/database/seeders/GitlabAppSeeder.php +++ b/database/seeders/GitlabAppSeeder.php @@ -14,6 +14,7 @@ public function run(): void { GitlabApp::create([ 'id' => 1, + 'uuid' => 'gitlab-public', 'name' => 'Public GitLab', 'api_url' => 'https://gitlab.com/api/v4', 'html_url' => 'https://gitlab.com', diff --git a/database/seeders/PrivateKeySeeder.php b/database/seeders/PrivateKeySeeder.php index 6b44d0867..0aa4153b3 100644 --- a/database/seeders/PrivateKeySeeder.php +++ b/database/seeders/PrivateKeySeeder.php @@ -13,6 +13,7 @@ class PrivateKeySeeder extends Seeder public function run(): void { PrivateKey::create([ + 'uuid' => 'ssh', 'team_id' => 0, 'name' => 'Testing Host Key', 'description' => 'This is a test docker container', @@ -27,6 +28,7 @@ public function run(): void ]); PrivateKey::create([ + 'uuid' => 'github-key', 'team_id' => 0, 'name' => 'development-github-app', 'description' => 'This is the key for using the development GitHub app', diff --git a/database/seeders/ProjectSeeder.php b/database/seeders/ProjectSeeder.php index 33cd8cd06..ab8e54051 100644 --- a/database/seeders/ProjectSeeder.php +++ b/database/seeders/ProjectSeeder.php @@ -9,10 +9,14 @@ class ProjectSeeder extends Seeder { public function run(): void { - Project::create([ + $project = Project::create([ + 'uuid' => 'project', 'name' => 'My first project', 'description' => 'This is a test project in development', 'team_id' => 0, ]); + + // Update the auto-created environment with a deterministic UUID + $project->environments()->first()->update(['uuid' => 'production']); } } diff --git a/database/seeders/S3StorageSeeder.php b/database/seeders/S3StorageSeeder.php index 9fa531447..b38df6ad5 100644 --- a/database/seeders/S3StorageSeeder.php +++ b/database/seeders/S3StorageSeeder.php @@ -13,6 +13,7 @@ class S3StorageSeeder extends Seeder public function run(): void { S3Storage::create([ + 'uuid' => 'minio', 'name' => 'Local MinIO', 'description' => 'Local MinIO S3 Storage', 'key' => 'minioadmin', diff --git a/database/seeders/ServerSeeder.php b/database/seeders/ServerSeeder.php index d32843107..2d8746691 100644 --- a/database/seeders/ServerSeeder.php +++ b/database/seeders/ServerSeeder.php @@ -13,6 +13,7 @@ public function run(): void { Server::create([ 'id' => 0, + 'uuid' => 'localhost', 'name' => 'localhost', 'description' => 'This is a test docker container in development mode', 'ip' => 'coolify-testing-host', diff --git a/database/seeders/StandaloneDockerSeeder.php b/database/seeders/StandaloneDockerSeeder.php index a466de56b..e31c62d9f 100644 --- a/database/seeders/StandaloneDockerSeeder.php +++ b/database/seeders/StandaloneDockerSeeder.php @@ -15,6 +15,7 @@ public function run(): void if (StandaloneDocker::find(0) == null) { StandaloneDocker::create([ 'id' => 0, + 'uuid' => 'docker', 'name' => 'Standalone Docker 1', 'network' => 'coolify', 'server_id' => 0, diff --git a/database/seeders/StandalonePostgresqlSeeder.php b/database/seeders/StandalonePostgresqlSeeder.php index 1fc96a610..59ee6fd42 100644 --- a/database/seeders/StandalonePostgresqlSeeder.php +++ b/database/seeders/StandalonePostgresqlSeeder.php @@ -11,6 +11,7 @@ class StandalonePostgresqlSeeder extends Seeder public function run(): void { StandalonePostgresql::create([ + 'uuid' => 'postgresql', 'name' => 'Local PostgreSQL', 'description' => 'Local PostgreSQL for testing', 'postgres_password' => 'postgres', diff --git a/openapi.json b/openapi.json index dd3c6783a..fe8ca863e 100644 --- a/openapi.json +++ b/openapi.json @@ -361,6 +361,11 @@ "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." } }, "type": "object" @@ -771,6 +776,11 @@ "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." } }, "type": "object" @@ -1181,6 +1191,11 @@ "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." } }, "type": "object" @@ -1520,6 +1535,11 @@ "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." } }, "type": "object" @@ -1842,6 +1862,11 @@ "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." } }, "type": "object" @@ -3275,6 +3300,387 @@ ] } }, + "\/cloud-tokens": { + "get": { + "tags": [ + "Cloud Tokens" + ], + "summary": "List Cloud Provider Tokens", + "description": "List all cloud provider tokens for the authenticated team.", + "operationId": "list-cloud-tokens", + "responses": { + "200": { + "description": "Get all cloud provider tokens.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "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" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Cloud Tokens" + ], + "summary": "Create Cloud Provider Token", + "description": "Create a new cloud provider token. The token will be validated before being stored.", + "operationId": "create-cloud-token", + "requestBody": { + "description": "Cloud provider token details", + "required": true, + "content": { + "application\/json": { + "schema": { + "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." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Cloud provider token created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "og888os", + "description": "The UUID of the token." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/cloud-tokens\/{uuid}": { + "get": { + "tags": [ + "Cloud Tokens" + ], + "summary": "Get Cloud Provider Token", + "description": "Get cloud provider token by UUID.", + "operationId": "get-cloud-token-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Token UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get cloud provider token by UUID", + "content": { + "application\/json": { + "schema": { + "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" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Cloud Tokens" + ], + "summary": "Delete Cloud Provider Token", + "description": "Delete cloud provider token by UUID. Cannot delete if token is used by any servers.", + "operationId": "delete-cloud-token-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the cloud provider token.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Cloud provider token deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Cloud provider token deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Cloud Tokens" + ], + "summary": "Update Cloud Provider Token", + "description": "Update cloud provider token name.", + "operationId": "update-cloud-token-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Token UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Cloud provider token updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The friendly name for the token." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Cloud provider token updated.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/cloud-tokens\/{uuid}\/validate": { + "post": { + "tags": [ + "Cloud Tokens" + ], + "summary": "Validate Cloud Provider Token", + "description": "Validate a cloud provider token against the provider API.", + "operationId": "validate-cloud-token-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Token UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Token validation result.", + "content": { + "application\/json": { + "schema": { + "properties": { + "valid": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Token is valid." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/databases": { "get": { "tags": [ @@ -6314,6 +6720,486 @@ ] } }, + "\/hetzner\/locations": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner Locations", + "description": "Get all available Hetzner datacenter locations.", + "operationId": "get-hetzner-locations", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner locations.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "country": { + "type": "string" + }, + "city": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hetzner\/server-types": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner Server Types", + "description": "Get all available Hetzner server types (instance sizes).", + "operationId": "get-hetzner-server-types", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner server types.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "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" + } + } + } + } + } + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hetzner\/images": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner Images", + "description": "Get all available Hetzner system images (operating systems).", + "operationId": "get-hetzner-images", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner images.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string" + }, + "os_flavor": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "architecture": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hetzner\/ssh-keys": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner SSH Keys", + "description": "Get all SSH keys stored in the Hetzner account.", + "operationId": "get-hetzner-ssh-keys", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner SSH keys.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "public_key": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/hetzner": { + "post": { + "tags": [ + "Hetzner" + ], + "summary": "Create Hetzner Server", + "description": "Create a new server on Hetzner and register it in Coolify.", + "operationId": "create-hetzner-server", + "requestBody": { + "description": "Hetzner server creation parameters", + "required": true, + "content": { + "application\/json": { + "schema": { + "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" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Hetzner server created.", + "content": { + "application\/json": { + "schema": { + "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." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + }, + "429": { + "$ref": "#\/components\/responses\/429" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/version": { "get": { "summary": "Version", @@ -9816,6 +10702,9 @@ "concurrent_builds": { "type": "integer" }, + "deployment_queue_limit": { + "type": "integer" + }, "dynamic_timeout": { "type": "integer" }, @@ -10174,6 +11063,31 @@ } } } + }, + "429": { + "description": "Rate limit exceeded.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying.", + "schema": { + "type": "integer", + "example": 60 + } + } + }, + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Rate limit exceeded. Please try again later." + } + }, + "type": "object" + } + } + } } }, "securitySchemes": { @@ -10189,6 +11103,10 @@ "name": "Applications", "description": "Applications" }, + { + "name": "Cloud Tokens", + "description": "Cloud Tokens" + }, { "name": "Databases", "description": "Databases" @@ -10201,6 +11119,10 @@ "name": "GitHub Apps", "description": "GitHub Apps" }, + { + "name": "Hetzner", + "description": "Hetzner" + }, { "name": "Projects", "description": "Projects" diff --git a/openapi.yaml b/openapi.yaml index 754b7ec6f..a7faa8c72 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -265,6 +265,10 @@ paths: 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." type: object responses: '201': @@ -531,6 +535,10 @@ paths: 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." type: object responses: '201': @@ -797,6 +805,10 @@ paths: 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." type: object responses: '201': @@ -1010,6 +1022,10 @@ paths: 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." type: object responses: '201': @@ -1214,6 +1230,10 @@ paths: 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." type: object responses: '201': @@ -2075,6 +2095,224 @@ paths: security: - bearerAuth: [] + /cloud-tokens: + get: + tags: + - 'Cloud Tokens' + summary: 'List Cloud Provider Tokens' + description: 'List all cloud provider tokens for the authenticated team.' + operationId: list-cloud-tokens + responses: + '200': + description: 'Get all cloud provider tokens.' + content: + application/json: + schema: + type: array + items: + 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 } } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] + post: + tags: + - 'Cloud Tokens' + summary: 'Create Cloud Provider Token' + description: 'Create a new cloud provider token. The token will be validated before being stored.' + operationId: create-cloud-token + requestBody: + description: 'Cloud provider token details' + required: true + content: + application/json: + schema: + 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.' + type: object + responses: + '201': + description: 'Cloud provider token created.' + content: + application/json: + schema: + properties: + uuid: { type: string, example: og888os, description: 'The UUID of the token.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/cloud-tokens/{uuid}': + get: + tags: + - 'Cloud Tokens' + summary: 'Get Cloud Provider Token' + description: 'Get cloud provider token by UUID.' + operationId: get-cloud-token-by-uuid + parameters: + - + name: uuid + in: path + description: 'Token UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Get cloud provider token by UUID' + content: + application/json: + schema: + 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 } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + delete: + tags: + - 'Cloud Tokens' + summary: 'Delete Cloud Provider Token' + description: 'Delete cloud provider token by UUID. Cannot delete if token is used by any servers.' + operationId: delete-cloud-token-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the cloud provider token.' + required: true + schema: + type: string + format: uuid + responses: + '200': + description: 'Cloud provider token deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Cloud provider token deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Cloud Tokens' + summary: 'Update Cloud Provider Token' + description: 'Update cloud provider token name.' + operationId: update-cloud-token-by-uuid + parameters: + - + name: uuid + in: path + description: 'Token UUID' + required: true + schema: + type: string + requestBody: + description: 'Cloud provider token updated.' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The friendly name for the token.' + type: object + responses: + '200': + description: 'Cloud provider token updated.' + content: + application/json: + schema: + properties: + uuid: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/cloud-tokens/{uuid}/validate': + post: + tags: + - 'Cloud Tokens' + summary: 'Validate Cloud Provider Token' + description: 'Validate a cloud provider token against the provider API.' + operationId: validate-cloud-token-by-uuid + parameters: + - + name: uuid + in: path + description: 'Token UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Token validation result.' + content: + application/json: + schema: + properties: + valid: { type: boolean, example: true } + message: { type: string, example: 'Token is valid.' } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /databases: get: tags: @@ -4084,6 +4322,258 @@ paths: security: - bearerAuth: [] + /hetzner/locations: + get: + tags: + - Hetzner + summary: 'Get Hetzner Locations' + description: 'Get all available Hetzner datacenter locations.' + operationId: get-hetzner-locations + parameters: + - + name: cloud_provider_token_uuid + in: query + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.' + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of Hetzner locations.' + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, name: { type: string }, description: { type: string }, country: { type: string }, city: { type: string }, latitude: { type: number }, longitude: { type: number } } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /hetzner/server-types: + get: + tags: + - Hetzner + summary: 'Get Hetzner Server Types' + description: 'Get all available Hetzner server types (instance sizes).' + operationId: get-hetzner-server-types + parameters: + - + name: cloud_provider_token_uuid + in: query + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.' + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of Hetzner server types.' + content: + application/json: + schema: + type: array + items: + 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 } } } } } } } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /hetzner/images: + get: + tags: + - Hetzner + summary: 'Get Hetzner Images' + description: 'Get all available Hetzner system images (operating systems).' + operationId: get-hetzner-images + parameters: + - + name: cloud_provider_token_uuid + in: query + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.' + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of Hetzner images.' + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, name: { type: string }, description: { type: string }, type: { type: string }, os_flavor: { type: string }, os_version: { type: string }, architecture: { type: string } } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /hetzner/ssh-keys: + get: + tags: + - Hetzner + summary: 'Get Hetzner SSH Keys' + description: 'Get all SSH keys stored in the Hetzner account.' + operationId: get-hetzner-ssh-keys + parameters: + - + name: cloud_provider_token_uuid + in: query + description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.' + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of Hetzner SSH keys.' + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, name: { type: string }, fingerprint: { type: string }, public_key: { type: string } } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /servers/hetzner: + post: + tags: + - Hetzner + summary: 'Create Hetzner Server' + description: 'Create a new server on Hetzner and register it in Coolify.' + operationId: create-hetzner-server + requestBody: + description: 'Hetzner server creation parameters' + required: true + content: + application/json: + schema: + 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' + type: object + responses: + '201': + description: 'Hetzner server created.' + content: + application/json: + schema: + 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.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + '429': + $ref: '#/components/responses/429' + security: + - + bearerAuth: [] /version: get: summary: Version @@ -6312,6 +6802,8 @@ components: type: integer concurrent_builds: type: integer + deployment_queue_limit: + type: integer dynamic_timeout: type: integer force_disabled: @@ -6556,6 +7048,22 @@ components: type: array items: { type: string } type: object + '429': + description: 'Rate limit exceeded.' + headers: + Retry-After: + description: 'Number of seconds to wait before retrying.' + schema: + type: integer + example: 60 + content: + application/json: + schema: + properties: + message: + type: string + example: 'Rate limit exceeded. Please try again later.' + type: object securitySchemes: bearerAuth: type: http @@ -6565,6 +7073,9 @@ tags: - name: Applications description: Applications + - + name: 'Cloud Tokens' + description: 'Cloud Tokens' - name: Databases description: Databases @@ -6574,6 +7085,9 @@ tags: - name: 'GitHub Apps' description: 'GitHub Apps' + - + name: Hetzner + description: Hetzner - name: Projects description: Projects diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index 14eede4ee..bfcd11095 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -66,7 +66,7 @@ fi if [ -f /data/coolify/source/docker-compose.custom.yml ]; then echo "docker-compose.custom.yml detected." >>"$LOGFILE" - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 else - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 fi diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 6d3f90371..1441c7c5e 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.453" + "version": "4.0.0-beta.454" }, "nightly": { - "version": "4.0.0-beta.454" + "version": "4.0.0-beta.455" }, "helper": { "version": "1.0.12" diff --git a/resources/views/livewire/deployments-indicator.blade.php b/resources/views/livewire/deployments-indicator.blade.php index 746bc970c..2102004a1 100644 --- a/resources/views/livewire/deployments-indicator.blade.php +++ b/resources/views/livewire/deployments-indicator.blade.php @@ -1,16 +1,17 @@
@if ($this->deploymentCount > 0) -
+
@@ -32,9 +33,8 @@ class="flex items-center gap-2 px-4 py-2 rounded-lg shadow-lg transition-all dur
@@ -46,16 +46,15 @@ class="flex items-start gap-3 p-3 rounded-lg dark:bg-coolgray-200 bg-gray-50 tra @if ($deployment->status === 'in_progress') - + + @else - + @@ -68,7 +67,8 @@ class="flex items-start gap-3 p-3 rounded-lg dark:bg-coolgray-200 bg-gray-50 tra {{ $deployment->application_name }}

- {{ $deployment->application?->environment?->project?->name }} / {{ $deployment->application?->environment?->name }} + {{ $deployment->application?->environment?->project?->name }} / + {{ $deployment->application?->environment?->name }}

{{ $deployment->server_name }} @@ -78,11 +78,10 @@ class="flex items-start gap-3 p-3 rounded-lg dark:bg-coolgray-200 bg-gray-50 tra PR #{{ $deployment->pull_request_id }}

@endif -

+

{{ str_replace('_', ' ', $deployment->status) }}

@@ -92,4 +91,4 @@ class="flex items-start gap-3 p-3 rounded-lg dark:bg-coolgray-200 bg-gray-50 tra
@endif -
+ \ No newline at end of file diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index bd5f5b1e8..907500dfa 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -13,12 +13,10 @@ helper="Allow to automatically deploy Preview Deployments for all opened PR's.

Closing a PR will delete Preview Deployments." instantSave id="isPreviewDeploymentsEnabled" label="Preview Deployments" canGate="update" :canResource="$application" /> - @if ($isPreviewDeploymentsEnabled) - - @endif + @endif diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index e5d1ce8e6..f2cde05cf 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -15,21 +15,14 @@ deploymentId: '{{ $application_deployment_queue->deployment_uuid ?? 'deployment' }}', makeFullscreen() { this.fullscreen = !this.fullscreen; - if (this.fullscreen === false) { - this.alwaysScroll = false; - clearInterval(this.intervalId); - } }, - isScrolling: false, toggleScroll() { this.alwaysScroll = !this.alwaysScroll; if (this.alwaysScroll) { this.intervalId = setInterval(() => { const logsContainer = document.getElementById('logsContainer'); if (logsContainer) { - this.isScrolling = true; logsContainer.scrollTop = logsContainer.scrollHeight; - setTimeout(() => { this.isScrolling = false; }, 50); } }, 100); } else { @@ -37,17 +30,6 @@ this.intervalId = null; } }, - handleScroll(event) { - if (!this.alwaysScroll || this.isScrolling) return; - const el = event.target; - // Check if user scrolled away from the bottom - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - if (distanceFromBottom > 50) { - this.alwaysScroll = false; - clearInterval(this.intervalId); - this.intervalId = null; - } - }, matchesSearch(text) { if (!this.searchQuery.trim()) return true; return text.toLowerCase().includes(this.searchQuery.toLowerCase()); @@ -134,6 +116,18 @@ a.click(); URL.revokeObjectURL(url); }, + stopScroll() { + // Scroll to the end one final time before disabling + const logsContainer = document.getElementById('logsContainer'); + if (logsContainer) { + logsContainer.scrollTop = logsContainer.scrollHeight; + } + this.alwaysScroll = false; + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + }, init() { // Re-render logs after Livewire updates document.addEventListener('livewire:navigated', () => { @@ -144,14 +138,19 @@ this.$nextTick(() => { this.renderTrigger++; }); }); }); + // Stop auto-scroll when deployment finishes + Livewire.on('deploymentFinished', () => { + // Wait for DOM to update with final logs before scrolling to end + setTimeout(() => { + this.stopScroll(); + }, 500); + }); // Start auto-scroll if deployment is in progress if (this.alwaysScroll) { this.intervalId = setInterval(() => { const logsContainer = document.getElementById('logsContainer'); if (logsContainer) { - this.isScrolling = true; logsContainer.scrollTop = logsContainer.scrollHeight; - setTimeout(() => { this.isScrolling = false; }, 50); } }, 100); } @@ -254,9 +253,9 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text- -
+ :class="fullscreen ? 'flex-1' : 'max-h-[30rem]'">
diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index e34e57de8..8504a160f 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -6,9 +6,9 @@ fullscreen: false, alwaysScroll: false, intervalId: null, + scrollDebounce: null, colorLogs: localStorage.getItem('coolify-color-logs') === 'true', searchQuery: '', - renderTrigger: 0, containerName: '{{ $container ?? "logs" }}', makeFullscreen() { this.fullscreen = !this.fullscreen; @@ -35,15 +35,22 @@ } }, handleScroll(event) { + // Skip if follow logs is disabled or this is a programmatic scroll if (!this.alwaysScroll || this.isScrolling) return; - const el = event.target; - // Check if user scrolled away from the bottom - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - if (distanceFromBottom > 50) { - this.alwaysScroll = false; - clearInterval(this.intervalId); - this.intervalId = null; - } + + // Debounce scroll handling to avoid false positives from DOM mutations + // when Livewire re-renders and adds new log lines + clearTimeout(this.scrollDebounce); + this.scrollDebounce = setTimeout(() => { + const el = event.target; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + // Use larger threshold (100px) to avoid accidental disables + if (distanceFromBottom > 100) { + this.alwaysScroll = false; + clearInterval(this.intervalId); + this.intervalId = null; + } + }, 150); }, toggleColorLogs() { this.colorLogs = !this.colorLogs; @@ -157,20 +164,14 @@ }, init() { if (this.expanded) { - this.$wire.getLogs(); + this.$wire.getLogs(true); this.logsLoaded = true; } - // Re-render logs after Livewire updates - Livewire.hook('commit', ({ succeed }) => { - succeed(() => { - this.$nextTick(() => { this.renderTrigger++; }); - }); - }); } }"> @if ($collapsible)
+ x-on:click="expanded = !expanded; if (expanded && !logsLoaded) { $wire.getLogs(true); logsLoaded = true; }"> @@ -191,9 +192,10 @@
@endif
-
+ :class="fullscreen ? 'fullscreen flex flex-col !overflow-visible' : 'relative w-full {{ $collapsible ? 'py-4' : '' }} mx-auto'" + :style="fullscreen ? 'max-height: none !important; height: 100% !important;' : ''"> +
@@ -215,7 +217,7 @@ class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"> -
@else
Refresh to get the logs...
+ class="font-mono whitespace-pre-wrap break-all max-w-full text-neutral-400">No logs yet. @endif
diff --git a/resources/views/livewire/server/advanced.blade.php b/resources/views/livewire/server/advanced.blade.php index 6622961c5..33086aea1 100644 --- a/resources/views/livewire/server/advanced.blade.php +++ b/resources/views/livewire/server/advanced.blade.php @@ -36,6 +36,9 @@ +
diff --git a/routes/api.php b/routes/api.php index 366a97d74..aaf7d794b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,9 +1,11 @@ middleware(['api.ability:write']); Route::delete('/security/keys/{uuid}', [SecurityController::class, 'delete_key'])->middleware(['api.ability:write']); + Route::get('/cloud-tokens', [CloudProviderTokensController::class, 'index'])->middleware(['api.ability:read']); + Route::post('/cloud-tokens', [CloudProviderTokensController::class, 'store'])->middleware(['api.ability:write']); + Route::get('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'update'])->middleware(['api.ability:write']); + Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:write']); + Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:read']); + Route::match(['get', 'post'], '/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']); Route::get('/deployments', [DeployController::class, 'deployments'])->middleware(['api.ability:read']); Route::get('/deployments/{uuid}', [DeployController::class, 'deployment_by_uuid'])->middleware(['api.ability:read']); @@ -80,6 +89,12 @@ Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']); Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']); + Route::get('/hetzner/locations', [HetznerController::class, 'locations'])->middleware(['api.ability:read']); + Route::get('/hetzner/server-types', [HetznerController::class, 'serverTypes'])->middleware(['api.ability:read']); + Route::get('/hetzner/images', [HetznerController::class, 'images'])->middleware(['api.ability:read']); + Route::get('/hetzner/ssh-keys', [HetznerController::class, 'sshKeys'])->middleware(['api.ability:read']); + Route::post('/servers/hetzner', [HetznerController::class, 'createServer'])->middleware(['api.ability:write']); + Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']); Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']); diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 9a55f330a..f091d2fdb 100644 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -66,7 +66,7 @@ fi if [ -f /data/coolify/source/docker-compose.custom.yml ]; then echo "docker-compose.custom.yml detected." >>"$LOGFILE" - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 else - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60" >>"$LOGFILE" 2>&1 fi diff --git a/templates/compose/garage.yaml b/templates/compose/garage.yaml index 493ff53bf..0c559cebd 100644 --- a/templates/compose/garage.yaml +++ b/templates/compose/garage.yaml @@ -1,4 +1,3 @@ -# ignore: true # documentation: https://garagehq.deuxfleurs.fr/documentation/ # slogan: Garage is an S3-compatible distributed object storage service designed for self-hosting. # category: storage diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index db9c040ff..43be1374f 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -1399,6 +1399,23 @@ "minversion": "0.0.0", "port": "80" }, + "garage": { + "documentation": "https://garagehq.deuxfleurs.fr/documentation/?utm_source=coolify.io", + "slogan": "Garage is an S3-compatible distributed object storage service designed for self-hosting.", + "compose": "c2VydmljZXM6CiAgZ2FyYWdlOgogICAgaW1hZ2U6ICdkeGZscnMvZ2FyYWdlOnYyLjEuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIEdBUkFHRV9TM19BUElfVVJMPSRHQVJBR0VfUzNfQVBJX1VSTAogICAgICAtIEdBUkFHRV9XRUJfVVJMPSRHQVJBR0VfV0VCX1VSTAogICAgICAtIEdBUkFHRV9BRE1JTl9VUkw9JEdBUkFHRV9BRE1JTl9VUkwKICAgICAgLSAnR0FSQUdFX1JQQ19TRUNSRVQ9JHtTRVJWSUNFX0hFWF8zMl9SUENTRUNSRVR9JwogICAgICAtIEdBUkFHRV9BRE1JTl9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0UKICAgICAgLSBHQVJBR0VfTUVUUklDU19UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0VNRVRSSUNTCiAgICAgIC0gR0FSQUdFX0FMTE9XX1dPUkxEX1JFQURBQkxFX1NFQ1JFVFM9dHJ1ZQogICAgICAtICdSVVNUX0xPRz0ke1JVU1RfTE9HOi1nYXJhZ2U9aW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdnYXJhZ2UtbWV0YTovdmFyL2xpYi9nYXJhZ2UvbWV0YScKICAgICAgLSAnZ2FyYWdlLWRhdGE6L3Zhci9saWIvZ2FyYWdlL2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2dhcmFnZS50b21sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2dhcmFnZS50b21sCiAgICAgICAgY29udGVudDogIm1ldGFkYXRhX2RpciA9IFwiL3Zhci9saWIvZ2FyYWdlL21ldGFcIlxuZGF0YV9kaXIgPSBcIi92YXIvbGliL2dhcmFnZS9kYXRhXCJcbmRiX2VuZ2luZSA9IFwibG1kYlwiXG5cbnJlcGxpY2F0aW9uX2ZhY3RvciA9IDFcbmNvbnNpc3RlbmN5X21vZGUgPSBcImNvbnNpc3RlbnRcIlxuXG5jb21wcmVzc2lvbl9sZXZlbCA9IDFcbmJsb2NrX3NpemUgPSBcIjFNXCJcblxucnBjX2JpbmRfYWRkciA9IFwiWzo6XTozOTAxXCJcbnJwY19zZWNyZXRfZmlsZSA9IFwiZW52OkdBUkFHRV9SUENfU0VDUkVUXCJcbmJvb3RzdHJhcF9wZWVycyA9IFtdXG5cbltzM19hcGldXG5zM19yZWdpb24gPSBcImdhcmFnZVwiXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDBcIlxucm9vdF9kb21haW4gPSBcIi5zMy5nYXJhZ2UubG9jYWxob3N0XCJcblxuW3MzX3dlYl1cbmJpbmRfYWRkciA9IFwiWzo6XTozOTAyXCJcbnJvb3RfZG9tYWluID0gXCIud2ViLmdhcmFnZS5sb2NhbGhvc3RcIlxuXG5bYWRtaW5dXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDNcIlxuYWRtaW5fdG9rZW5fZmlsZSA9IFwiZW52OkdBUkFHRV9BRE1JTl9UT0tFTlwiXG5tZXRyaWNzX3Rva2VuX2ZpbGUgPSBcImVudjpHQVJBR0VfTUVUUklDU19UT0tFTlwiIgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9nYXJhZ2UKICAgICAgICAtIHN0YXRzCiAgICAgICAgLSAnLWEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", + "tags": [ + "object", + "storage", + "server", + "s3", + "api", + "distributed" + ], + "category": "storage", + "logo": "svgs/garage.svg", + "minversion": "0.0.0", + "port": "3900" + }, "getoutline": { "documentation": "https://docs.getoutline.com/s/hosting/doc/hosting-outline-nipGaCRBDu?utm_source=coolify.io", "slogan": "Your team\u2019s knowledge base", diff --git a/templates/service-templates.json b/templates/service-templates.json index 0fa619192..a5e7d632e 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -1399,6 +1399,23 @@ "minversion": "0.0.0", "port": "80" }, + "garage": { + "documentation": "https://garagehq.deuxfleurs.fr/documentation/?utm_source=coolify.io", + "slogan": "Garage is an S3-compatible distributed object storage service designed for self-hosting.", + "compose": "c2VydmljZXM6CiAgZ2FyYWdlOgogICAgaW1hZ2U6ICdkeGZscnMvZ2FyYWdlOnYyLjEuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIEdBUkFHRV9TM19BUElfVVJMPSRHQVJBR0VfUzNfQVBJX1VSTAogICAgICAtIEdBUkFHRV9XRUJfVVJMPSRHQVJBR0VfV0VCX1VSTAogICAgICAtIEdBUkFHRV9BRE1JTl9VUkw9JEdBUkFHRV9BRE1JTl9VUkwKICAgICAgLSAnR0FSQUdFX1JQQ19TRUNSRVQ9JHtTRVJWSUNFX0hFWF8zMl9SUENTRUNSRVR9JwogICAgICAtIEdBUkFHRV9BRE1JTl9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0UKICAgICAgLSBHQVJBR0VfTUVUUklDU19UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0VNRVRSSUNTCiAgICAgIC0gR0FSQUdFX0FMTE9XX1dPUkxEX1JFQURBQkxFX1NFQ1JFVFM9dHJ1ZQogICAgICAtICdSVVNUX0xPRz0ke1JVU1RfTE9HOi1nYXJhZ2U9aW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdnYXJhZ2UtbWV0YTovdmFyL2xpYi9nYXJhZ2UvbWV0YScKICAgICAgLSAnZ2FyYWdlLWRhdGE6L3Zhci9saWIvZ2FyYWdlL2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2dhcmFnZS50b21sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2dhcmFnZS50b21sCiAgICAgICAgY29udGVudDogIm1ldGFkYXRhX2RpciA9IFwiL3Zhci9saWIvZ2FyYWdlL21ldGFcIlxuZGF0YV9kaXIgPSBcIi92YXIvbGliL2dhcmFnZS9kYXRhXCJcbmRiX2VuZ2luZSA9IFwibG1kYlwiXG5cbnJlcGxpY2F0aW9uX2ZhY3RvciA9IDFcbmNvbnNpc3RlbmN5X21vZGUgPSBcImNvbnNpc3RlbnRcIlxuXG5jb21wcmVzc2lvbl9sZXZlbCA9IDFcbmJsb2NrX3NpemUgPSBcIjFNXCJcblxucnBjX2JpbmRfYWRkciA9IFwiWzo6XTozOTAxXCJcbnJwY19zZWNyZXRfZmlsZSA9IFwiZW52OkdBUkFHRV9SUENfU0VDUkVUXCJcbmJvb3RzdHJhcF9wZWVycyA9IFtdXG5cbltzM19hcGldXG5zM19yZWdpb24gPSBcImdhcmFnZVwiXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDBcIlxucm9vdF9kb21haW4gPSBcIi5zMy5nYXJhZ2UubG9jYWxob3N0XCJcblxuW3MzX3dlYl1cbmJpbmRfYWRkciA9IFwiWzo6XTozOTAyXCJcbnJvb3RfZG9tYWluID0gXCIud2ViLmdhcmFnZS5sb2NhbGhvc3RcIlxuXG5bYWRtaW5dXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDNcIlxuYWRtaW5fdG9rZW5fZmlsZSA9IFwiZW52OkdBUkFHRV9BRE1JTl9UT0tFTlwiXG5tZXRyaWNzX3Rva2VuX2ZpbGUgPSBcImVudjpHQVJBR0VfTUVUUklDU19UT0tFTlwiIgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9nYXJhZ2UKICAgICAgICAtIHN0YXRzCiAgICAgICAgLSAnLWEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", + "tags": [ + "object", + "storage", + "server", + "s3", + "api", + "distributed" + ], + "category": "storage", + "logo": "svgs/garage.svg", + "minversion": "0.0.0", + "port": "3900" + }, "getoutline": { "documentation": "https://docs.getoutline.com/s/hosting/doc/hosting-outline-nipGaCRBDu?utm_source=coolify.io", "slogan": "Your team\u2019s knowledge base", diff --git a/tests/Feature/CloudProviderTokenApiTest.php b/tests/Feature/CloudProviderTokenApiTest.php new file mode 100644 index 000000000..da3acfd56 --- /dev/null +++ b/tests/Feature/CloudProviderTokenApiTest.php @@ -0,0 +1,413 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + // Set the current team session before creating the token + session(['currentTeam' => $this->team]); + + // Create an API token for the user + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; +}); + +describe('GET /api/v1/cloud-tokens', function () { + test('lists all cloud provider tokens for the team', function () { + // Create some tokens + CloudProviderToken::factory()->count(3)->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/cloud-tokens'); + + $response->assertStatus(200); + $response->assertJsonCount(3); + $response->assertJsonStructure([ + '*' => ['uuid', 'name', 'provider', 'team_id', 'servers_count', 'created_at', 'updated_at'], + ]); + }); + + test('does not include tokens from other teams', function () { + // Create tokens for this team + CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + ]); + + // Create tokens for another team + $otherTeam = Team::factory()->create(); + CloudProviderToken::factory()->count(2)->create([ + 'team_id' => $otherTeam->id, + 'provider' => 'hetzner', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/cloud-tokens'); + + $response->assertStatus(200); + $response->assertJsonCount(1); + }); + + test('rejects request without authentication', function () { + $response = $this->getJson('/api/v1/cloud-tokens'); + $response->assertStatus(401); + }); +}); + +describe('GET /api/v1/cloud-tokens/{uuid}', function () { + test('gets cloud provider token by UUID', function () { + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'name' => 'My Hetzner Token', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson("/api/v1/cloud-tokens/{$token->uuid}"); + + $response->assertStatus(200); + $response->assertJsonFragment(['name' => 'My Hetzner Token', 'provider' => 'hetzner']); + }); + + test('returns 404 for non-existent token', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/cloud-tokens/non-existent-uuid'); + + $response->assertStatus(404); + }); + + test('cannot access token from another team', function () { + $otherTeam = Team::factory()->create(); + $token = CloudProviderToken::factory()->create([ + 'team_id' => $otherTeam->id, + 'provider' => 'hetzner', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson("/api/v1/cloud-tokens/{$token->uuid}"); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/cloud-tokens', function () { + test('creates a Hetzner cloud provider token', function () { + // Mock Hetzner API validation + Http::fake([ + 'https://api.hetzner.cloud/v1/servers' => Http::response([], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/cloud-tokens', [ + 'provider' => 'hetzner', + 'token' => 'test-hetzner-token', + 'name' => 'My Hetzner Token', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid']); + + // Verify token was created + $this->assertDatabaseHas('cloud_provider_tokens', [ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'name' => 'My Hetzner Token', + ]); + }); + + test('creates a DigitalOcean cloud provider token', function () { + // Mock DigitalOcean API validation + Http::fake([ + 'https://api.digitalocean.com/v2/account' => Http::response([], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/cloud-tokens', [ + 'provider' => 'digitalocean', + 'token' => 'test-do-token', + 'name' => 'My DO Token', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid']); + }); + + test('validates provider is required', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/cloud-tokens', [ + 'token' => 'test-token', + 'name' => 'My Token', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['provider']); + }); + + test('validates token is required', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/cloud-tokens', [ + 'provider' => 'hetzner', + 'name' => 'My Token', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['token']); + }); + + test('validates name is required', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/cloud-tokens', [ + 'provider' => 'hetzner', + 'token' => 'test-token', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['name']); + }); + + test('validates provider must be hetzner or digitalocean', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/cloud-tokens', [ + 'provider' => 'invalid-provider', + 'token' => 'test-token', + 'name' => 'My Token', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['provider']); + }); + + test('rejects invalid Hetzner token', function () { + // Mock failed Hetzner API validation + Http::fake([ + 'https://api.hetzner.cloud/v1/servers' => Http::response([], 401), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/cloud-tokens', [ + 'provider' => 'hetzner', + 'token' => 'invalid-token', + 'name' => 'My Token', + ]); + + $response->assertStatus(400); + $response->assertJson(['message' => 'Invalid hetzner token. Please check your API token.']); + }); + + test('rejects extra fields not in allowed list', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/servers' => Http::response([], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/cloud-tokens', [ + 'provider' => 'hetzner', + 'token' => 'test-token', + 'name' => 'My Token', + 'invalid_field' => 'invalid_value', + ]); + + $response->assertStatus(422); + }); +}); + +describe('PATCH /api/v1/cloud-tokens/{uuid}', function () { + test('updates cloud provider token name', function () { + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'name' => 'Old Name', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", [ + 'name' => 'New Name', + ]); + + $response->assertStatus(200); + + // Verify token name was updated + $this->assertDatabaseHas('cloud_provider_tokens', [ + 'uuid' => $token->uuid, + 'name' => 'New Name', + ]); + }); + + test('validates name is required', function () { + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", []); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['name']); + }); + + test('cannot update token from another team', function () { + $otherTeam = Team::factory()->create(); + $token = CloudProviderToken::factory()->create([ + 'team_id' => $otherTeam->id, + 'provider' => 'hetzner', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", [ + 'name' => 'New Name', + ]); + + $response->assertStatus(404); + }); +}); + +describe('DELETE /api/v1/cloud-tokens/{uuid}', function () { + test('deletes cloud provider token', function () { + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->deleteJson("/api/v1/cloud-tokens/{$token->uuid}"); + + $response->assertStatus(200); + $response->assertJson(['message' => 'Cloud provider token deleted.']); + + // Verify token was deleted + $this->assertDatabaseMissing('cloud_provider_tokens', [ + 'uuid' => $token->uuid, + ]); + }); + + test('cannot delete token from another team', function () { + $otherTeam = Team::factory()->create(); + $token = CloudProviderToken::factory()->create([ + 'team_id' => $otherTeam->id, + 'provider' => 'hetzner', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->deleteJson("/api/v1/cloud-tokens/{$token->uuid}"); + + $response->assertStatus(404); + }); + + test('returns 404 for non-existent token', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->deleteJson('/api/v1/cloud-tokens/non-existent-uuid'); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/cloud-tokens/{uuid}/validate', function () { + test('validates a valid Hetzner token', function () { + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + ]); + + Http::fake([ + 'https://api.hetzner.cloud/v1/servers' => Http::response([], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate"); + + $response->assertStatus(200); + $response->assertJson(['valid' => true, 'message' => 'Token is valid.']); + }); + + test('detects invalid Hetzner token', function () { + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + ]); + + Http::fake([ + 'https://api.hetzner.cloud/v1/servers' => Http::response([], 401), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate"); + + $response->assertStatus(200); + $response->assertJson(['valid' => false, 'message' => 'Invalid hetzner token. Please check your API token.']); + }); + + test('validates a valid DigitalOcean token', function () { + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'digitalocean', + ]); + + Http::fake([ + 'https://api.digitalocean.com/v2/account' => Http::response([], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate"); + + $response->assertStatus(200); + $response->assertJson(['valid' => true, 'message' => 'Token is valid.']); + }); +}); diff --git a/tests/Feature/HetznerApiTest.php b/tests/Feature/HetznerApiTest.php new file mode 100644 index 000000000..bd316ca49 --- /dev/null +++ b/tests/Feature/HetznerApiTest.php @@ -0,0 +1,448 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + // Create an API token for the user + session(['currentTeam' => $this->team]); + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + // Create a Hetzner cloud provider token + $this->hetznerToken = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'token' => 'test-hetzner-api-token', + ]); + + // Create a private key + $this->privateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +describe('GET /api/v1/hetzner/locations', function () { + test('gets Hetzner locations', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/locations*' => Http::response([ + 'locations' => [ + ['id' => 1, 'name' => 'nbg1', 'description' => 'Nuremberg 1 DC Park 1', 'country' => 'DE', 'city' => 'Nuremberg'], + ['id' => 2, 'name' => 'hel1', 'description' => 'Helsinki 1 DC Park 8', 'country' => 'FI', 'city' => 'Helsinki'], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(200); + $response->assertJsonCount(2); + $response->assertJsonFragment(['name' => 'nbg1']); + }); + + test('requires cloud_provider_token_id parameter', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/locations'); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['cloud_provider_token_id']); + }); + + test('returns 404 for non-existent token', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id=non-existent-uuid'); + + $response->assertStatus(404); + }); +}); + +describe('GET /api/v1/hetzner/server-types', function () { + test('gets Hetzner server types', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ + 'server_types' => [ + ['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'cores' => 1, 'memory' => 2.0, 'disk' => 20], + ['id' => 2, 'name' => 'cx21', 'description' => 'CX21', 'cores' => 2, 'memory' => 4.0, 'disk' => 40], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(200); + $response->assertJsonCount(2); + $response->assertJsonFragment(['name' => 'cx11']); + }); + + test('filters out deprecated server types', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ + 'server_types' => [ + ['id' => 1, 'name' => 'cx11', 'deprecated' => false], + ['id' => 2, 'name' => 'cx21', 'deprecated' => true], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'cx11']); + $response->assertJsonMissing(['name' => 'cx21']); + }); +}); + +describe('GET /api/v1/hetzner/images', function () { + test('gets Hetzner images', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/images*' => Http::response([ + 'images' => [ + ['id' => 1, 'name' => 'ubuntu-20.04', 'type' => 'system', 'deprecated' => false], + ['id' => 2, 'name' => 'ubuntu-22.04', 'type' => 'system', 'deprecated' => false], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(200); + $response->assertJsonCount(2); + $response->assertJsonFragment(['name' => 'ubuntu-20.04']); + }); + + test('filters out deprecated images', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/images*' => Http::response([ + 'images' => [ + ['id' => 1, 'name' => 'ubuntu-20.04', 'type' => 'system', 'deprecated' => false], + ['id' => 2, 'name' => 'ubuntu-16.04', 'type' => 'system', 'deprecated' => true], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'ubuntu-20.04']); + $response->assertJsonMissing(['name' => 'ubuntu-16.04']); + }); + + test('filters out non-system images', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/images*' => Http::response([ + 'images' => [ + ['id' => 1, 'name' => 'ubuntu-20.04', 'type' => 'system', 'deprecated' => false], + ['id' => 2, 'name' => 'my-snapshot', 'type' => 'snapshot', 'deprecated' => false], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(200); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'ubuntu-20.04']); + $response->assertJsonMissing(['name' => 'my-snapshot']); + }); +}); + +describe('GET /api/v1/hetzner/ssh-keys', function () { + test('gets Hetzner SSH keys', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ + 'ssh_keys' => [ + ['id' => 1, 'name' => 'my-key', 'fingerprint' => 'aa:bb:cc:dd'], + ['id' => 2, 'name' => 'another-key', 'fingerprint' => 'ee:ff:11:22'], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/ssh-keys?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertStatus(200); + $response->assertJsonCount(2); + $response->assertJsonFragment(['name' => 'my-key']); + }); +}); + +describe('POST /api/v1/servers/hetzner', function () { + test('creates a Hetzner server', function () { + // Mock Hetzner API calls + Http::fake([ + 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([ + 'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'], + ], 201), + 'https://api.hetzner.cloud/v1/servers' => Http::response([ + 'server' => [ + 'id' => 456, + 'name' => 'test-server', + 'public_net' => [ + 'ipv4' => ['ip' => '1.2.3.4'], + 'ipv6' => ['ip' => '2001:db8::1'], + ], + ], + ], 201), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/hetzner', [ + 'cloud_provider_token_id' => $this->hetznerToken->uuid, + 'location' => 'nbg1', + 'server_type' => 'cx11', + 'image' => 15512617, + 'name' => 'test-server', + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_ipv4' => true, + 'enable_ipv6' => true, + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid', 'hetzner_server_id', 'ip']); + $response->assertJsonFragment(['hetzner_server_id' => 456, 'ip' => '1.2.3.4']); + + // Verify server was created in database + $this->assertDatabaseHas('servers', [ + 'name' => 'test-server', + 'ip' => '1.2.3.4', + 'team_id' => $this->team->id, + 'hetzner_server_id' => 456, + ]); + }); + + test('generates server name if not provided', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([ + 'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'], + ], 201), + 'https://api.hetzner.cloud/v1/servers' => Http::response([ + 'server' => [ + 'id' => 456, + 'public_net' => [ + 'ipv4' => ['ip' => '1.2.3.4'], + ], + ], + ], 201), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/hetzner', [ + 'cloud_provider_token_id' => $this->hetznerToken->uuid, + 'location' => 'nbg1', + 'server_type' => 'cx11', + 'image' => 15512617, + 'private_key_uuid' => $this->privateKey->uuid, + ]); + + $response->assertStatus(201); + + // Verify a server was created with a generated name + $this->assertDatabaseCount('servers', 1); + }); + + test('validates required fields', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/hetzner', []); + + $response->assertStatus(422); + $response->assertJsonValidationErrors([ + 'cloud_provider_token_id', + 'location', + 'server_type', + 'image', + 'private_key_uuid', + ]); + }); + + test('validates cloud_provider_token_id exists', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/hetzner', [ + 'cloud_provider_token_id' => 'non-existent-uuid', + 'location' => 'nbg1', + 'server_type' => 'cx11', + 'image' => 15512617, + 'private_key_uuid' => $this->privateKey->uuid, + ]); + + $response->assertStatus(404); + $response->assertJson(['message' => 'Hetzner cloud provider token not found.']); + }); + + test('validates private_key_uuid exists', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/hetzner', [ + 'cloud_provider_token_id' => $this->hetznerToken->uuid, + 'location' => 'nbg1', + 'server_type' => 'cx11', + 'image' => 15512617, + 'private_key_uuid' => 'non-existent-uuid', + ]); + + $response->assertStatus(404); + $response->assertJson(['message' => 'Private key not found.']); + }); + + test('prefers IPv4 when both IPv4 and IPv6 are enabled', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([ + 'ssh_key' => ['id' => 123], + ], 201), + 'https://api.hetzner.cloud/v1/servers' => Http::response([ + 'server' => [ + 'id' => 456, + 'public_net' => [ + 'ipv4' => ['ip' => '1.2.3.4'], + 'ipv6' => ['ip' => '2001:db8::1'], + ], + ], + ], 201), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/hetzner', [ + 'cloud_provider_token_id' => $this->hetznerToken->uuid, + 'location' => 'nbg1', + 'server_type' => 'cx11', + 'image' => 15512617, + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_ipv4' => true, + 'enable_ipv6' => true, + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['ip' => '1.2.3.4']); + }); + + test('uses IPv6 when only IPv6 is enabled', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([ + 'ssh_key' => ['id' => 123], + ], 201), + 'https://api.hetzner.cloud/v1/servers' => Http::response([ + 'server' => [ + 'id' => 456, + 'public_net' => [ + 'ipv4' => ['ip' => null], + 'ipv6' => ['ip' => '2001:db8::1'], + ], + ], + ], 201), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/hetzner', [ + 'cloud_provider_token_id' => $this->hetznerToken->uuid, + 'location' => 'nbg1', + 'server_type' => 'cx11', + 'image' => 15512617, + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_ipv4' => false, + 'enable_ipv6' => true, + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['ip' => '2001:db8::1']); + }); + + test('rejects extra fields not in allowed list', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/hetzner', [ + 'cloud_provider_token_id' => $this->hetznerToken->uuid, + 'location' => 'nbg1', + 'server_type' => 'cx11', + 'image' => 15512617, + 'private_key_uuid' => $this->privateKey->uuid, + 'invalid_field' => 'invalid_value', + ]); + + $response->assertStatus(422); + }); + + test('rejects request without authentication', function () { + $response = $this->postJson('/api/v1/servers/hetzner', [ + 'cloud_provider_token_id' => $this->hetznerToken->uuid, + 'location' => 'nbg1', + 'server_type' => 'cx11', + 'image' => 15512617, + 'private_key_uuid' => $this->privateKey->uuid, + ]); + + $response->assertStatus(401); + }); +}); diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php index ebf73da06..630b1bf53 100644 --- a/tests/Unit/Actions/Server/CleanupDockerTest.php +++ b/tests/Unit/Actions/Server/CleanupDockerTest.php @@ -251,3 +251,92 @@ expect(preg_match($pattern, $image))->toBe(0, "Image {$image} should be deletable"); } }); + +it('excludes current version of Coolify infrastructure images from any registry', function () { + // Test the regex pattern used to protect the current version of infrastructure images + // regardless of which registry they come from (ghcr.io, docker.io, or no prefix) + $helperVersion = '1.0.12'; + $realtimeVersion = '1.0.10'; + + // Build the exclusion pattern the same way CleanupDocker does + // Pattern: (^|/)coollabsio/coolify-helper:VERSION$|(^|/)coollabsio/coolify-realtime:VERSION$ + $escapedHelperVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $helperVersion); + $escapedRealtimeVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $realtimeVersion); + + // For PHP preg_match, escape forward slashes + $infraPattern = "(^|\\/)coollabsio\\/coolify-helper:{$escapedHelperVersion}$|(^|\\/)coollabsio\\/coolify-realtime:{$escapedRealtimeVersion}$"; + $pattern = "/{$infraPattern}/"; + + // Current versioned infrastructure images from ANY registry should be PROTECTED + $protectedImages = [ + // ghcr.io registry + "ghcr.io/coollabsio/coolify-helper:{$helperVersion}", + "ghcr.io/coollabsio/coolify-realtime:{$realtimeVersion}", + // docker.io registry (explicit) + "docker.io/coollabsio/coolify-helper:{$helperVersion}", + "docker.io/coollabsio/coolify-realtime:{$realtimeVersion}", + // No registry prefix (Docker Hub implicit) + "coollabsio/coolify-helper:{$helperVersion}", + "coollabsio/coolify-realtime:{$realtimeVersion}", + ]; + + // Verify current infrastructure images ARE protected from any registry + foreach ($protectedImages as $image) { + expect(preg_match($pattern, $image))->toBe(1, "Current infrastructure image {$image} should be protected"); + } + + // Verify OLD versions of infrastructure images are NOT protected (can be deleted) + $oldVersionImages = [ + 'ghcr.io/coollabsio/coolify-helper:1.0.11', + 'docker.io/coollabsio/coolify-helper:1.0.10', + 'coollabsio/coolify-helper:1.0.9', + 'ghcr.io/coollabsio/coolify-realtime:1.0.9', + 'ghcr.io/coollabsio/coolify-helper:latest', + 'coollabsio/coolify-realtime:latest', + ]; + + foreach ($oldVersionImages as $image) { + expect(preg_match($pattern, $image))->toBe(0, "Old infrastructure image {$image} should NOT be protected"); + } + + // Verify other images are NOT protected (can be deleted) + $deletableImages = [ + 'nginx:alpine', + 'postgres:15', + 'redis:7', + 'mysql:8.0', + 'node:20', + ]; + + foreach ($deletableImages as $image) { + expect(preg_match($pattern, $image))->toBe(0, "Image {$image} should NOT be protected"); + } +}); + +it('protects current infrastructure images from any registry even when no applications exist', function () { + // When there are no applications, current versioned infrastructure images should still be protected + // regardless of which registry they come from + $helperVersion = '1.0.12'; + $realtimeVersion = '1.0.10'; + + // Build the pattern the same way CleanupDocker does + $escapedHelperVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $helperVersion); + $escapedRealtimeVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $realtimeVersion); + + // For PHP preg_match, escape forward slashes + $infraPattern = "(^|\\/)coollabsio\\/coolify-helper:{$escapedHelperVersion}$|(^|\\/)coollabsio\\/coolify-realtime:{$escapedRealtimeVersion}$"; + $pattern = "/{$infraPattern}/"; + + // Verify current infrastructure images from any registry are protected + expect(preg_match($pattern, "ghcr.io/coollabsio/coolify-helper:{$helperVersion}"))->toBe(1); + expect(preg_match($pattern, "docker.io/coollabsio/coolify-helper:{$helperVersion}"))->toBe(1); + expect(preg_match($pattern, "coollabsio/coolify-helper:{$helperVersion}"))->toBe(1); + expect(preg_match($pattern, "ghcr.io/coollabsio/coolify-realtime:{$realtimeVersion}"))->toBe(1); + + // Old versions should NOT be protected + expect(preg_match($pattern, 'ghcr.io/coollabsio/coolify-helper:1.0.11'))->toBe(0); + expect(preg_match($pattern, 'docker.io/coollabsio/coolify-helper:1.0.11'))->toBe(0); + + // Other images should not be protected + expect(preg_match($pattern, 'nginx:alpine'))->toBe(0); +}); diff --git a/tests/Unit/Api/ApplicationAutogenerateDomainTest.php b/tests/Unit/Api/ApplicationAutogenerateDomainTest.php new file mode 100644 index 000000000..766033618 --- /dev/null +++ b/tests/Unit/Api/ApplicationAutogenerateDomainTest.php @@ -0,0 +1,131 @@ +andReturn(null); + Illuminate\Support\Facades\Log::shouldReceive('info')->andReturn(null); +}); + +it('generateUrl produces correct URL with wildcard domain', function () { + $serverSettings = Mockery::mock(ServerSetting::class); + $serverSettings->wildcard_domain = 'http://example.com'; + + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('getAttribute') + ->with('settings') + ->andReturn($serverSettings); + + // Mock data_get to return the wildcard domain + $wildcard = data_get($server, 'settings.wildcard_domain'); + + expect($wildcard)->toBe('http://example.com'); + + // Test the URL generation logic manually (simulating generateUrl behavior) + $random = 'abc123-def456'; + $url = Spatie\Url\Url::fromString($wildcard); + $host = $url->getHost(); + $scheme = $url->getScheme(); + + $generatedUrl = "$scheme://{$random}.$host"; + + expect($generatedUrl)->toBe('http://abc123-def456.example.com'); +}); + +it('generateUrl falls back to sslip when no wildcard domain', function () { + // Test the sslip fallback logic for IPv4 + $ip = '192.168.1.100'; + $fallbackDomain = "http://{$ip}.sslip.io"; + + $random = 'test-uuid'; + $url = Spatie\Url\Url::fromString($fallbackDomain); + $host = $url->getHost(); + $scheme = $url->getScheme(); + + $generatedUrl = "$scheme://{$random}.$host"; + + expect($generatedUrl)->toBe('http://test-uuid.192.168.1.100.sslip.io'); +}); + +it('autogenerate_domain defaults to true', function () { + // Create a mock request + $request = new Illuminate\Http\Request; + + // When autogenerate_domain is not set, boolean() should return the default (true) + $autogenerateDomain = $request->boolean('autogenerate_domain', true); + + expect($autogenerateDomain)->toBeTrue(); +}); + +it('autogenerate_domain can be set to false', function () { + // Create a request with autogenerate_domain set to false + $request = new Illuminate\Http\Request(['autogenerate_domain' => false]); + + $autogenerateDomain = $request->boolean('autogenerate_domain', true); + + expect($autogenerateDomain)->toBeFalse(); +}); + +it('autogenerate_domain can be set to true explicitly', function () { + // Create a request with autogenerate_domain set to true + $request = new Illuminate\Http\Request(['autogenerate_domain' => true]); + + $autogenerateDomain = $request->boolean('autogenerate_domain', true); + + expect($autogenerateDomain)->toBeTrue(); +}); + +it('domain is not auto-generated when domains field is provided', function () { + // Test the logic: if domains is set, autogenerate should be skipped + $fqdn = 'https://myapp.example.com'; + $autogenerateDomain = true; + + // The condition: $autogenerateDomain && blank($fqdn) + $shouldAutogenerate = $autogenerateDomain && blank($fqdn); + + expect($shouldAutogenerate)->toBeFalse(); +}); + +it('domain is auto-generated when domains field is empty and autogenerate is true', function () { + // Test the logic: if domains is empty and autogenerate is true, should generate + $fqdn = null; + $autogenerateDomain = true; + + // The condition: $autogenerateDomain && blank($fqdn) + $shouldAutogenerate = $autogenerateDomain && blank($fqdn); + + expect($shouldAutogenerate)->toBeTrue(); + + // Also test with empty string + $fqdn = ''; + $shouldAutogenerate = $autogenerateDomain && blank($fqdn); + + expect($shouldAutogenerate)->toBeTrue(); +}); + +it('domain is not auto-generated when autogenerate is false', function () { + // Test the logic: if autogenerate is false, should not generate even if domains is empty + $fqdn = null; + $autogenerateDomain = false; + + // The condition: $autogenerateDomain && blank($fqdn) + $shouldAutogenerate = $autogenerateDomain && blank($fqdn); + + expect($shouldAutogenerate)->toBeFalse(); +}); + +it('removeUnnecessaryFieldsFromRequest removes autogenerate_domain', function () { + $request = new Illuminate\Http\Request([ + 'autogenerate_domain' => true, + 'name' => 'test-app', + 'project_uuid' => 'abc123', + ]); + + // Simulate removeUnnecessaryFieldsFromRequest + $request->offsetUnset('autogenerate_domain'); + + expect($request->has('autogenerate_domain'))->toBeFalse(); + expect($request->has('name'))->toBeTrue(); +}); diff --git a/tests/Unit/ValidProxyConfigFilenameTest.php b/tests/Unit/ValidProxyConfigFilenameTest.php new file mode 100644 index 000000000..c326d69cf --- /dev/null +++ b/tests/Unit/ValidProxyConfigFilenameTest.php @@ -0,0 +1,185 @@ +validate('fileName', $filename, function ($message) use (&$failures, $filename) { + $failures[] = "{$filename}: {$message}"; + }); + } + + expect($failures)->toBeEmpty(); +}); + +test('blocks path traversal with forward slash', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', '../etc/passwd', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('blocks path traversal with backslash', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', '..\\windows\\system32', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('blocks hidden files starting with dot', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', '.hidden.yaml', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('blocks reserved filename coolify.yaml', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', 'coolify.yaml', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('blocks reserved filename coolify.yml', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', 'coolify.yml', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('blocks reserved filename Caddyfile', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', 'Caddyfile', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('blocks filenames with invalid characters', function () { + $invalidFilenames = [ + 'file;rm.yaml', + 'file|test.yaml', + 'config$var.yaml', + 'test`cmd`.yaml', + 'name with spaces.yaml', + 'fileoutput.yaml', + 'config&background.yaml', + "file\nnewline.yaml", + ]; + + $rule = new ValidProxyConfigFilename; + + foreach ($invalidFilenames as $filename) { + $failed = false; + $rule->validate('fileName', $filename, function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue("Expected '{$filename}' to be rejected"); + } +}); + +test('blocks filenames exceeding 255 characters', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $longFilename = str_repeat('a', 256); + $rule->validate('fileName', $longFilename, function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('allows filenames at exactly 255 characters', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $exactFilename = str_repeat('a', 255); + $rule->validate('fileName', $exactFilename, function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeFalse(); +}); + +test('allows empty values without failing', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', '', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeFalse(); +}); + +test('blocks nested path traversal', function () { + $rule = new ValidProxyConfigFilename; + $failed = false; + + $rule->validate('fileName', 'foo/bar/../../etc/passwd', function () use (&$failed) { + $failed = true; + }); + + expect($failed)->toBeTrue(); +}); + +test('allows similar but not reserved filenames', function () { + $validFilenames = [ + 'coolify-custom.yaml', + 'my-coolify.yaml', + 'coolify2.yaml', + 'Caddyfile.backup', + 'my-Caddyfile', + ]; + + $rule = new ValidProxyConfigFilename; + $failures = []; + + foreach ($validFilenames as $filename) { + $rule->validate('fileName', $filename, function ($message) use (&$failures, $filename) { + $failures[] = "{$filename}: {$message}"; + }); + } + + expect($failures)->toBeEmpty(); +}); diff --git a/versions.json b/versions.json index 6d3f90371..1441c7c5e 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.453" + "version": "4.0.0-beta.454" }, "nightly": { - "version": "4.0.0-beta.454" + "version": "4.0.0-beta.455" }, "helper": { "version": "1.0.12"