v4.0.0-beta.454 (#7563)
This commit is contained in:
commit
6b88481ce2
62 changed files with 4600 additions and 200 deletions
|
|
@ -37,9 +37,15 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
|
|||
$applicationCleanupLog = $this->cleanupApplicationImages($server, $applications);
|
||||
$cleanupLog = array_merge($cleanupLog, $applicationCleanupLog);
|
||||
|
||||
// Build image prune command that excludes application images
|
||||
// This ensures we clean up non-Coolify images while preserving rollback images
|
||||
$imagePruneCmd = $this->buildImagePruneCommand($applicationImageRepos);
|
||||
// Build image prune command that excludes application images and current Coolify infrastructure images
|
||||
// This ensures we clean up non-Coolify images while preserving rollback images and current helper/realtime images
|
||||
// Note: Only the current version is protected; old versions will be cleaned up by explicit commands below
|
||||
// We pass the version strings so all registry variants are protected (ghcr.io, docker.io, no prefix)
|
||||
$imagePruneCmd = $this->buildImagePruneCommand(
|
||||
$applicationImageRepos,
|
||||
$helperImageVersion,
|
||||
$realtimeImageVersion
|
||||
);
|
||||
|
||||
$commands = [
|
||||
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
|
||||
|
|
@ -78,33 +84,51 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
|
|||
* Since docker image prune doesn't support excluding by repository name directly,
|
||||
* we use a shell script approach to delete unused images while preserving application images.
|
||||
*/
|
||||
private function buildImagePruneCommand($applicationImageRepos): string
|
||||
{
|
||||
private function buildImagePruneCommand(
|
||||
$applicationImageRepos,
|
||||
string $helperImageVersion,
|
||||
string $realtimeImageVersion
|
||||
): string {
|
||||
// Step 1: Always prune dangling images (untagged)
|
||||
$commands = ['docker image prune -f'];
|
||||
|
||||
if ($applicationImageRepos->isEmpty()) {
|
||||
// No applications, add original prune command for all unused images
|
||||
$commands[] = 'docker image prune -af --filter "label!=coolify.managed=true"';
|
||||
} else {
|
||||
// Build grep pattern to exclude application image repositories
|
||||
$excludePatterns = $applicationImageRepos->map(function ($repo) {
|
||||
// Escape special characters for grep extended regex (ERE)
|
||||
// ERE special chars: . \ + * ? [ ^ ] $ ( ) { } |
|
||||
return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo);
|
||||
})->implode('|');
|
||||
// Build grep pattern to exclude application image repositories (matches repo:tag and repo_service:tag)
|
||||
$appExcludePatterns = $applicationImageRepos->map(function ($repo) {
|
||||
// Escape special characters for grep extended regex (ERE)
|
||||
// ERE special chars: . \ + * ? [ ^ ] $ ( ) { } |
|
||||
return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo);
|
||||
})->implode('|');
|
||||
|
||||
// Delete unused images that:
|
||||
// - Are not application images (don't match app repos)
|
||||
// - Don't have coolify.managed=true label
|
||||
// Images in use by containers will fail silently with docker rmi
|
||||
// Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build)
|
||||
$commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ".
|
||||
"grep -v -E '^({$excludePatterns})[_:].+' | ".
|
||||
"grep -v '<none>' | ".
|
||||
"xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true";
|
||||
// Build grep pattern to exclude Coolify infrastructure images (current version only)
|
||||
// This pattern matches the image name regardless of registry prefix:
|
||||
// - ghcr.io/coollabsio/coolify-helper:1.0.12
|
||||
// - docker.io/coollabsio/coolify-helper:1.0.12
|
||||
// - coollabsio/coolify-helper:1.0.12
|
||||
// Pattern: (^|/)coollabsio/coolify-(helper|realtime):VERSION$
|
||||
$escapedHelperVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $helperImageVersion);
|
||||
$escapedRealtimeVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $realtimeImageVersion);
|
||||
$infraExcludePattern = "(^|/)coollabsio/coolify-helper:{$escapedHelperVersion}$|(^|/)coollabsio/coolify-realtime:{$escapedRealtimeVersion}$";
|
||||
|
||||
// Delete unused images that:
|
||||
// - Are not application images (don't match app repos)
|
||||
// - Are not current Coolify infrastructure images (any registry)
|
||||
// - Don't have coolify.managed=true label
|
||||
// Images in use by containers will fail silently with docker rmi
|
||||
// Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build)
|
||||
$grepCommands = "grep -v '<none>'";
|
||||
|
||||
// Add application repo exclusion if there are applications
|
||||
if ($applicationImageRepos->isNotEmpty()) {
|
||||
$grepCommands .= " | grep -v -E '^({$appExcludePatterns})[_:].+'";
|
||||
}
|
||||
|
||||
// Add infrastructure image exclusion (matches any registry prefix)
|
||||
$grepCommands .= " | grep -v -E '{$infraExcludePattern}'";
|
||||
|
||||
$commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ".
|
||||
$grepCommands.' | '.
|
||||
"xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true";
|
||||
|
||||
return implode(' && ', $commands);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
15
app/Exceptions/RateLimitException.php
Normal file
15
app/Exceptions/RateLimitException.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class RateLimitException extends Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $message = 'Rate limit exceeded.',
|
||||
public readonly ?int $retryAfter = null
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
|
@ -192,6 +192,7 @@ public function applications(Request $request)
|
|||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
|
|
@ -342,6 +343,7 @@ public function create_public_application(Request $request)
|
|||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
|
|
@ -492,6 +494,7 @@ public function create_private_gh_app_application(Request $request)
|
|||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
|
|
@ -626,6 +629,7 @@ public function create_private_deploy_key_application(Request $request)
|
|||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
|
|
@ -757,6 +761,7 @@ public function create_dockerfile_application(Request $request)
|
|||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
|
|
@ -927,7 +932,7 @@ private function create_application(Request $request, $type)
|
|||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override'];
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
|
|
@ -940,6 +945,7 @@ private function create_application(Request $request, $type)
|
|||
'is_http_basic_auth_enabled' => 'boolean',
|
||||
'http_basic_auth_username' => 'string|nullable',
|
||||
'http_basic_auth_password' => 'string|nullable',
|
||||
'autogenerate_domain' => 'boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
|
|
@ -964,6 +970,7 @@ private function create_application(Request $request, $type)
|
|||
}
|
||||
$serverUuid = $request->server_uuid;
|
||||
$fqdn = $request->domains;
|
||||
$autogenerateDomain = $request->boolean('autogenerate_domain', true);
|
||||
$instantDeploy = $request->instant_deploy;
|
||||
$githubAppUuid = $request->github_app_uuid;
|
||||
$useBuildServer = $request->use_build_server;
|
||||
|
|
@ -1087,6 +1094,11 @@ private function create_application(Request $request, $type)
|
|||
$application->settings->save();
|
||||
}
|
||||
$application->refresh();
|
||||
// Auto-generate domain if requested and no custom domain provided
|
||||
if ($autogenerateDomain && blank($fqdn)) {
|
||||
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
|
||||
$application->save();
|
||||
}
|
||||
if ($application->settings->is_container_label_readonly_enabled) {
|
||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
|
|
@ -1115,7 +1127,7 @@ private function create_application(Request $request, $type)
|
|||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => data_get($application, 'uuid'),
|
||||
'domains' => data_get($application, 'domains'),
|
||||
'domains' => data_get($application, 'fqdn'),
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === 'private-gh-app') {
|
||||
$validationRules = [
|
||||
|
|
@ -1238,6 +1250,11 @@ private function create_application(Request $request, $type)
|
|||
|
||||
$application->save();
|
||||
$application->refresh();
|
||||
// Auto-generate domain if requested and no custom domain provided
|
||||
if ($autogenerateDomain && blank($fqdn)) {
|
||||
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
|
||||
$application->save();
|
||||
}
|
||||
if (isset($useBuildServer)) {
|
||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||
$application->settings->save();
|
||||
|
|
@ -1270,7 +1287,7 @@ private function create_application(Request $request, $type)
|
|||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => data_get($application, 'uuid'),
|
||||
'domains' => data_get($application, 'domains'),
|
||||
'domains' => data_get($application, 'fqdn'),
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === 'private-deploy-key') {
|
||||
|
||||
|
|
@ -1367,6 +1384,11 @@ private function create_application(Request $request, $type)
|
|||
$application->environment_id = $environment->id;
|
||||
$application->save();
|
||||
$application->refresh();
|
||||
// Auto-generate domain if requested and no custom domain provided
|
||||
if ($autogenerateDomain && blank($fqdn)) {
|
||||
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
|
||||
$application->save();
|
||||
}
|
||||
if (isset($useBuildServer)) {
|
||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||
$application->settings->save();
|
||||
|
|
@ -1399,7 +1421,7 @@ private function create_application(Request $request, $type)
|
|||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => data_get($application, 'uuid'),
|
||||
'domains' => data_get($application, 'domains'),
|
||||
'domains' => data_get($application, 'fqdn'),
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === 'dockerfile') {
|
||||
$validationRules = [
|
||||
|
|
@ -1461,6 +1483,11 @@ private function create_application(Request $request, $type)
|
|||
$application->git_branch = 'main';
|
||||
$application->save();
|
||||
$application->refresh();
|
||||
// Auto-generate domain if requested and no custom domain provided
|
||||
if ($autogenerateDomain && blank($fqdn)) {
|
||||
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
|
||||
$application->save();
|
||||
}
|
||||
if (isset($useBuildServer)) {
|
||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||
$application->settings->save();
|
||||
|
|
@ -1489,7 +1516,7 @@ private function create_application(Request $request, $type)
|
|||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => data_get($application, 'uuid'),
|
||||
'domains' => data_get($application, 'domains'),
|
||||
'domains' => data_get($application, 'fqdn'),
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === 'dockerimage') {
|
||||
$validationRules = [
|
||||
|
|
@ -1554,6 +1581,11 @@ private function create_application(Request $request, $type)
|
|||
$application->git_branch = 'main';
|
||||
$application->save();
|
||||
$application->refresh();
|
||||
// Auto-generate domain if requested and no custom domain provided
|
||||
if ($autogenerateDomain && blank($fqdn)) {
|
||||
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
|
||||
$application->save();
|
||||
}
|
||||
if (isset($useBuildServer)) {
|
||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||
$application->settings->save();
|
||||
|
|
@ -1582,7 +1614,7 @@ private function create_application(Request $request, $type)
|
|||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => data_get($application, 'uuid'),
|
||||
'domains' => data_get($application, 'domains'),
|
||||
'domains' => data_get($application, 'fqdn'),
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === 'dockercompose') {
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override'];
|
||||
|
|
|
|||
531
app/Http/Controllers/Api/CloudProviderTokensController.php
Normal file
531
app/Http/Controllers/Api/CloudProviderTokensController.php
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class CloudProviderTokensController extends Controller
|
||||
{
|
||||
private function removeSensitiveData($token)
|
||||
{
|
||||
$token->makeHidden([
|
||||
'id',
|
||||
'token',
|
||||
]);
|
||||
|
||||
return serializeApiResponse($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a provider token against the provider's API.
|
||||
*
|
||||
* @return array{valid: bool, error: string|null}
|
||||
*/
|
||||
private function validateProviderToken(string $provider, string $token): array
|
||||
{
|
||||
try {
|
||||
$response = match ($provider) {
|
||||
'hetzner' => Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'),
|
||||
'digitalocean' => Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.digitalocean.com/v2/account'),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($response === null) {
|
||||
return ['valid' => false, 'error' => 'Unsupported provider.'];
|
||||
}
|
||||
|
||||
if ($response->successful()) {
|
||||
return ['valid' => true, 'error' => null];
|
||||
}
|
||||
|
||||
return ['valid' => false, 'error' => "Invalid {$provider} token. Please check your API token."];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to validate cloud provider token', [
|
||||
'provider' => $provider,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['valid' => false, 'error' => 'Failed to validate token with provider API.'];
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Cloud Provider Tokens',
|
||||
description: 'List all cloud provider tokens for the authenticated team.',
|
||||
path: '/cloud-tokens',
|
||||
operationId: 'list-cloud-tokens',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Cloud Tokens'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get all cloud provider tokens.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean']],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
'servers_count' => ['type' => 'integer'],
|
||||
'created_at' => ['type' => 'string'],
|
||||
'updated_at' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function index(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$tokens = CloudProviderToken::whereTeamId($teamId)
|
||||
->withCount('servers')
|
||||
->get()
|
||||
->map(function ($token) {
|
||||
return $this->removeSensitiveData($token);
|
||||
});
|
||||
|
||||
return response()->json($tokens);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Cloud Provider Token',
|
||||
description: 'Get cloud provider token by UUID.',
|
||||
path: '/cloud-tokens/{uuid}',
|
||||
operationId: 'get-cloud-token-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Cloud Tokens'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get cloud provider token by UUID',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'provider' => ['type' => 'string'],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
'servers_count' => ['type' => 'integer'],
|
||||
'created_at' => ['type' => 'string'],
|
||||
'updated_at' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function show(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($request->uuid)
|
||||
->withCount('servers')
|
||||
->first();
|
||||
|
||||
if (is_null($token)) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
return response()->json($this->removeSensitiveData($token));
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Cloud Provider Token',
|
||||
description: 'Create a new cloud provider token. The token will be validated before being stored.',
|
||||
path: '/cloud-tokens',
|
||||
operationId: 'create-cloud-token',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Cloud Tokens'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
description: 'Cloud provider token details',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['provider', 'token', 'name'],
|
||||
properties: [
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
|
||||
'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'],
|
||||
'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Cloud provider token created.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the token.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function store(Request $request)
|
||||
{
|
||||
$allowedFields = ['provider', 'token', 'name'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
// Use request body only (excludes any route parameters)
|
||||
$body = $request->json()->all();
|
||||
|
||||
$validator = customApiValidator($body, [
|
||||
'provider' => 'required|string|in:hetzner,digitalocean',
|
||||
'token' => 'required|string',
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($body), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Validate token with the provider's API
|
||||
$validation = $this->validateProviderToken($body['provider'], $body['token']);
|
||||
|
||||
if (! $validation['valid']) {
|
||||
return response()->json(['message' => $validation['error']], 400);
|
||||
}
|
||||
|
||||
$cloudProviderToken = CloudProviderToken::create([
|
||||
'team_id' => $teamId,
|
||||
'provider' => $body['provider'],
|
||||
'token' => $body['token'],
|
||||
'name' => $body['name'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $cloudProviderToken->uuid,
|
||||
])->setStatusCode(201);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Cloud Provider Token',
|
||||
description: 'Update cloud provider token name.',
|
||||
path: '/cloud-tokens/{uuid}',
|
||||
operationId: 'update-cloud-token-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Cloud Tokens'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
description: 'Cloud provider token updated.',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The friendly name for the token.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Cloud provider token updated.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update(Request $request)
|
||||
{
|
||||
$allowedFields = ['name'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
// Use request body only (excludes route parameters like uuid)
|
||||
$body = $request->json()->all();
|
||||
|
||||
$validator = customApiValidator($body, [
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($body), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Use route parameter for UUID lookup
|
||||
$token = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first();
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
$token->update(array_intersect_key($body, array_flip($allowedFields)));
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $token->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Cloud Provider Token',
|
||||
description: 'Delete cloud provider token by UUID. Cannot delete if token is used by any servers.',
|
||||
path: '/cloud-tokens/{uuid}',
|
||||
operationId: 'delete-cloud-token-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Cloud Tokens'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the cloud provider token.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Cloud provider token deleted.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Cloud provider token deleted.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $request->uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 422);
|
||||
}
|
||||
|
||||
$token = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
if ($token->hasServers()) {
|
||||
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
|
||||
}
|
||||
|
||||
$token->delete();
|
||||
|
||||
return response()->json(['message' => 'Cloud provider token deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Validate Cloud Provider Token',
|
||||
description: 'Validate a cloud provider token against the provider API.',
|
||||
path: '/cloud-tokens/{uuid}/validate',
|
||||
operationId: 'validate-cloud-token-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Cloud Tokens'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Token validation result.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'valid' => ['type' => 'boolean', 'example' => true],
|
||||
'message' => ['type' => 'string', 'example' => 'Token is valid.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function validateToken(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$cloudToken = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||
|
||||
if (! $cloudToken) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
$validation = $this->validateProviderToken($cloudToken->provider, $cloudToken->token);
|
||||
|
||||
return response()->json([
|
||||
'valid' => $validation['valid'],
|
||||
'message' => $validation['valid'] ? 'Token is valid.' : $validation['error'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.";
|
||||
|
|
|
|||
738
app/Http/Controllers/Api/HetznerController.php
Normal file
738
app/Http/Controllers/Api/HetznerController.php
Normal file
|
|
@ -0,0 +1,738 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidCloudInitYaml;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\HetznerService;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class HetznerController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get cloud provider token UUID from request.
|
||||
* Prefers cloud_provider_token_uuid over deprecated cloud_provider_token_id.
|
||||
*/
|
||||
private function getCloudProviderTokenUuid(Request $request): ?string
|
||||
{
|
||||
return $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Locations',
|
||||
description: 'Get all available Hetzner datacenter locations.',
|
||||
path: '/hetzner/locations',
|
||||
operationId: 'get-hetzner-locations',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner locations.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'description' => ['type' => 'string'],
|
||||
'country' => ['type' => 'string'],
|
||||
'city' => ['type' => 'string'],
|
||||
'latitude' => ['type' => 'number'],
|
||||
'longitude' => ['type' => 'number'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function locations(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
$locations = $hetznerService->getLocations();
|
||||
|
||||
return response()->json($locations);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Server Types',
|
||||
description: 'Get all available Hetzner server types (instance sizes).',
|
||||
path: '/hetzner/server-types',
|
||||
operationId: 'get-hetzner-server-types',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner server types.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'description' => ['type' => 'string'],
|
||||
'cores' => ['type' => 'integer'],
|
||||
'memory' => ['type' => 'number'],
|
||||
'disk' => ['type' => 'integer'],
|
||||
'prices' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'location' => ['type' => 'string', 'description' => 'Datacenter location name'],
|
||||
'price_hourly' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'net' => ['type' => 'string'],
|
||||
'gross' => ['type' => 'string'],
|
||||
],
|
||||
],
|
||||
'price_monthly' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'net' => ['type' => 'string'],
|
||||
'gross' => ['type' => 'string'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function serverTypes(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
$serverTypes = $hetznerService->getServerTypes();
|
||||
|
||||
return response()->json($serverTypes);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Images',
|
||||
description: 'Get all available Hetzner system images (operating systems).',
|
||||
path: '/hetzner/images',
|
||||
operationId: 'get-hetzner-images',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner images.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'description' => ['type' => 'string'],
|
||||
'type' => ['type' => 'string'],
|
||||
'os_flavor' => ['type' => 'string'],
|
||||
'os_version' => ['type' => 'string'],
|
||||
'architecture' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function images(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
$images = $hetznerService->getImages();
|
||||
|
||||
// Filter out deprecated images (same as UI)
|
||||
$filtered = array_filter($images, function ($image) {
|
||||
if (isset($image['type']) && $image['type'] !== 'system') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($image['deprecated']) && $image['deprecated'] === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return response()->json(array_values($filtered));
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner SSH Keys',
|
||||
description: 'Get all SSH keys stored in the Hetzner account.',
|
||||
path: '/hetzner/ssh-keys',
|
||||
operationId: 'get-hetzner-ssh-keys',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner SSH keys.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'fingerprint' => ['type' => 'string'],
|
||||
'public_key' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function sshKeys(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
$sshKeys = $hetznerService->getSshKeys();
|
||||
|
||||
return response()->json($sshKeys);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Hetzner Server',
|
||||
description: 'Create a new server on Hetzner and register it in Coolify.',
|
||||
path: '/servers/hetzner',
|
||||
operationId: 'create-hetzner-server',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
description: 'Hetzner server creation parameters',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['location', 'server_type', 'image', 'private_key_uuid'],
|
||||
properties: [
|
||||
'cloud_provider_token_uuid' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'],
|
||||
'cloud_provider_token_id' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', 'deprecated' => true],
|
||||
'location' => ['type' => 'string', 'example' => 'nbg1', 'description' => 'Hetzner location name'],
|
||||
'server_type' => ['type' => 'string', 'example' => 'cx11', 'description' => 'Hetzner server type name'],
|
||||
'image' => ['type' => 'integer', 'example' => 15512617, 'description' => 'Hetzner image ID'],
|
||||
'name' => ['type' => 'string', 'example' => 'my-server', 'description' => 'Server name (auto-generated if not provided)'],
|
||||
'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'],
|
||||
'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'],
|
||||
'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'],
|
||||
'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'],
|
||||
'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'],
|
||||
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Hetzner server created.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the server.'],
|
||||
'hetzner_server_id' => ['type' => 'integer', 'description' => 'The Hetzner server ID.'],
|
||||
'ip' => ['type' => 'string', 'description' => 'The server IP address.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 429,
|
||||
ref: '#/components/responses/429',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function createServer(Request $request)
|
||||
{
|
||||
$allowedFields = [
|
||||
'cloud_provider_token_uuid',
|
||||
'cloud_provider_token_id',
|
||||
'location',
|
||||
'server_type',
|
||||
'image',
|
||||
'name',
|
||||
'private_key_uuid',
|
||||
'enable_ipv4',
|
||||
'enable_ipv6',
|
||||
'hetzner_ssh_key_ids',
|
||||
'cloud_init_script',
|
||||
'instant_validate',
|
||||
];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
'location' => 'required|string',
|
||||
'server_type' => 'required|string',
|
||||
'image' => 'required|integer',
|
||||
'name' => ['nullable', 'string', 'max:253', new ValidHostname],
|
||||
'private_key_uuid' => 'required|string',
|
||||
'enable_ipv4' => 'nullable|boolean',
|
||||
'enable_ipv6' => 'nullable|boolean',
|
||||
'hetzner_ssh_key_ids' => 'nullable|array',
|
||||
'hetzner_ssh_key_ids.*' => 'integer',
|
||||
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
|
||||
'instant_validate' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Check server limit
|
||||
if (Team::serverLimitReached()) {
|
||||
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if (! $request->name) {
|
||||
$request->offsetSet('name', generate_random_name());
|
||||
}
|
||||
if (is_null($request->enable_ipv4)) {
|
||||
$request->offsetSet('enable_ipv4', true);
|
||||
}
|
||||
if (is_null($request->enable_ipv6)) {
|
||||
$request->offsetSet('enable_ipv6', true);
|
||||
}
|
||||
if (is_null($request->hetzner_ssh_key_ids)) {
|
||||
$request->offsetSet('hetzner_ssh_key_ids', []);
|
||||
}
|
||||
if (is_null($request->instant_validate)) {
|
||||
$request->offsetSet('instant_validate', false);
|
||||
}
|
||||
|
||||
// Validate cloud provider token
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
// Validate private key
|
||||
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||
if (! $privateKey) {
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
|
||||
// Get public key and MD5 fingerprint
|
||||
$publicKey = $privateKey->getPublicKey();
|
||||
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
|
||||
|
||||
// Check if SSH key already exists on Hetzner
|
||||
$existingSshKeys = $hetznerService->getSshKeys();
|
||||
$existingKey = null;
|
||||
|
||||
foreach ($existingSshKeys as $key) {
|
||||
if ($key['fingerprint'] === $md5Fingerprint) {
|
||||
$existingKey = $key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload SSH key if it doesn't exist
|
||||
if ($existingKey) {
|
||||
$sshKeyId = $existingKey['id'];
|
||||
} else {
|
||||
$sshKeyName = $privateKey->name;
|
||||
$uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey);
|
||||
$sshKeyId = $uploadedKey['id'];
|
||||
}
|
||||
|
||||
// Normalize server name to lowercase for RFC 1123 compliance
|
||||
$normalizedServerName = strtolower(trim($request->name));
|
||||
|
||||
// Prepare SSH keys array: Coolify key + user-selected Hetzner keys
|
||||
$sshKeys = array_merge(
|
||||
[$sshKeyId],
|
||||
$request->hetzner_ssh_key_ids
|
||||
);
|
||||
|
||||
// Remove duplicates
|
||||
$sshKeys = array_unique($sshKeys);
|
||||
$sshKeys = array_values($sshKeys);
|
||||
|
||||
// Prepare server creation parameters
|
||||
$params = [
|
||||
'name' => $normalizedServerName,
|
||||
'server_type' => $request->server_type,
|
||||
'image' => $request->image,
|
||||
'location' => $request->location,
|
||||
'start_after_create' => true,
|
||||
'ssh_keys' => $sshKeys,
|
||||
'public_net' => [
|
||||
'enable_ipv4' => $request->enable_ipv4,
|
||||
'enable_ipv6' => $request->enable_ipv6,
|
||||
],
|
||||
];
|
||||
|
||||
// Add cloud-init script if provided
|
||||
if (! empty($request->cloud_init_script)) {
|
||||
$params['user_data'] = $request->cloud_init_script;
|
||||
}
|
||||
|
||||
// Create server on Hetzner
|
||||
$hetznerServer = $hetznerService->createServer($params);
|
||||
|
||||
// Determine IP address to use (prefer IPv4, fallback to IPv6)
|
||||
$ipAddress = null;
|
||||
if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
|
||||
$ipAddress = $hetznerServer['public_net']['ipv4']['ip'];
|
||||
} elseif ($request->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) {
|
||||
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
|
||||
}
|
||||
|
||||
if (! $ipAddress) {
|
||||
throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.');
|
||||
}
|
||||
|
||||
// Create server in Coolify database
|
||||
$server = Server::create([
|
||||
'name' => $normalizedServerName,
|
||||
'ip' => $ipAddress,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => $teamId,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'hetzner_server_id' => $hetznerServer['id'],
|
||||
]);
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
// Validate server if requested
|
||||
if ($request->instant_validate) {
|
||||
\App\Actions\Server\ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
'hetzner_server_id' => $hetznerServer['id'],
|
||||
'ip' => $ipAddress,
|
||||
])->setStatusCode(201);
|
||||
} catch (RateLimitException $e) {
|
||||
$response = response()->json(['message' => $e->getMessage()], 429);
|
||||
if ($e->retryAfter !== null) {
|
||||
$response->header('Retry-After', $e->retryAfter);
|
||||
}
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CloudProviderToken extends Model
|
||||
class CloudProviderToken extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
73
app/Rules/ValidProxyConfigFilename.php
Normal file
73
app/Rules/ValidProxyConfigFilename.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class ValidProxyConfigFilename implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Reserved filenames that cannot be used.
|
||||
*/
|
||||
private const RESERVED_FILENAMES = [
|
||||
'coolify.yaml',
|
||||
'coolify.yml',
|
||||
'Caddyfile',
|
||||
];
|
||||
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* Validates proxy configuration filename:
|
||||
* - Must be 1-255 characters
|
||||
* - No path separators (/, \) to prevent path traversal
|
||||
* - Cannot start with a dot (hidden files)
|
||||
* - Only alphanumeric characters, dashes, underscores, and dots allowed
|
||||
* - Must have a basename before any extension
|
||||
* - Cannot use reserved filenames
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (empty($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$filename = trim($value);
|
||||
|
||||
// Check length (filesystem limit is typically 255 bytes)
|
||||
if (strlen($filename) > 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('server_settings', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cloud_provider_tokens', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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"]
|
||||
',
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
922
openapi.json
922
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"
|
||||
|
|
|
|||
514
openapi.yaml
514
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
<div wire:poll.3000ms x-data="{
|
||||
expanded: @entangle('expanded')
|
||||
expanded: @entangle('expanded'),
|
||||
reduceOpacity: @js($this->shouldReduceOpacity)
|
||||
}" class="fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4">
|
||||
@if ($this->deploymentCount > 0)
|
||||
<div class="relative">
|
||||
<div class="relative transition-opacity duration-200"
|
||||
:class="{ 'opacity-100': expanded || !reduceOpacity, 'opacity-60 hover:opacity-100': reduceOpacity && !expanded }">
|
||||
<!-- Indicator Button -->
|
||||
<button @click="expanded = !expanded"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg shadow-lg transition-all duration-200 dark:bg-coolgray-100 bg-white dark:border dark:border-coolgray-200 hover:shadow-xl">
|
||||
<!-- Animated spinner -->
|
||||
<svg class="w-4 h-4 text-coollabs dark:text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||
stroke-width="4"></circle>
|
||||
<svg class="w-4 h-4 text-coollabs dark:text-warning animate-spin" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||
</path>
|
||||
|
|
@ -23,8 +24,8 @@ class="flex items-center gap-2 px-4 py-2 rounded-lg shadow-lg transition-all dur
|
|||
|
||||
<!-- Expand/collapse icon -->
|
||||
<svg class="w-4 h-4 transition-transform duration-200 dark:text-neutral-400 text-gray-600"
|
||||
:class="{ 'rotate-180': expanded }" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
:class="{ 'rotate-180': expanded }" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -32,9 +33,8 @@ class="flex items-center gap-2 px-4 py-2 rounded-lg shadow-lg transition-all dur
|
|||
<!-- Expanded deployment list -->
|
||||
<div x-show="expanded" x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0" x-transition:leave-end="opacity-0 translate-y-2"
|
||||
x-cloak
|
||||
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 translate-y-2" x-cloak
|
||||
class="absolute bottom-full mb-2 w-80 max-h-96 overflow-y-auto rounded-lg shadow-xl dark:bg-coolgray-100 bg-white dark:border dark:border-coolgray-200">
|
||||
|
||||
<div class="p-4 space-y-3">
|
||||
|
|
@ -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')
|
||||
<svg class="w-4 h-4 text-coollabs dark:text-warning animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10"
|
||||
stroke="currentColor" stroke-width="4"></circle>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
|
||||
</circle>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||
</path>
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-4 h-4 dark:text-neutral-400 text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<svg class="w-4 h-4 dark:text-neutral-400 text-gray-500" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
|
|
@ -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 }}
|
||||
</div>
|
||||
<p class="text-xs dark:text-neutral-400 text-gray-600 mt-1">
|
||||
{{ $deployment->application?->environment?->project?->name }} / {{ $deployment->application?->environment?->name }}
|
||||
{{ $deployment->application?->environment?->project?->name }} /
|
||||
{{ $deployment->application?->environment?->name }}
|
||||
</p>
|
||||
<p class="text-xs dark:text-neutral-400 text-gray-600">
|
||||
{{ $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 }}
|
||||
</p>
|
||||
@endif
|
||||
<p class="text-xs mt-1 capitalize"
|
||||
:class="{
|
||||
'text-coollabs dark:text-warning': '{{ $deployment->status }}' === 'in_progress',
|
||||
'dark:text-neutral-400 text-gray-500': '{{ $deployment->status }}' === 'queued'
|
||||
}">
|
||||
<p class="text-xs mt-1 capitalize" :class="{
|
||||
'text-coollabs dark:text-warning': '{{ $deployment->status }}' === 'in_progress',
|
||||
'dark:text-neutral-400 text-gray-500': '{{ $deployment->status }}' === 'queued'
|
||||
}">
|
||||
{{ str_replace('_', ' ', $deployment->status) }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -92,4 +91,4 @@ class="flex items-start gap-3 p-3 rounded-lg dark:bg-coolgray-200 bg-gray-50 tra
|
|||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -13,12 +13,10 @@
|
|||
helper="Allow to automatically deploy Preview Deployments for all opened PR's.<br><br>Closing a PR will delete Preview Deployments."
|
||||
instantSave id="isPreviewDeploymentsEnabled" label="Preview Deployments" canGate="update"
|
||||
:canResource="$application" />
|
||||
@if ($isPreviewDeploymentsEnabled)
|
||||
<x-forms.checkbox
|
||||
helper="When enabled, anyone can trigger PR deployments. When disabled, only repository members, collaborators, and contributors can trigger PR deployments."
|
||||
instantSave id="isPrDeploymentsPublicEnabled" label="Allow Public PR Deployments" canGate="update"
|
||||
:canResource="$application" />
|
||||
@endif
|
||||
<x-forms.checkbox
|
||||
helper="When enabled, anyone can trigger PR deployments. When disabled, only repository members, collaborators, and contributors can trigger PR deployments."
|
||||
instantSave id="isPrDeploymentsPublicEnabled" label="Allow Public PR Deployments" canGate="update"
|
||||
:canResource="$application" :disabled="!$isPreviewDeploymentsEnabled" />
|
||||
@endif
|
||||
<x-forms.checkbox helper="Disable Docker build cache on every deployment." instantSave
|
||||
id="disableBuildCache" label="Disable Build Cache" canGate="update" :canResource="$application" />
|
||||
|
|
|
|||
|
|
@ -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-
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="logsContainer" @scroll="handleScroll"
|
||||
<div id="logsContainer"
|
||||
class="flex flex-col overflow-y-auto p-2 px-4 min-h-4 scrollbar"
|
||||
:class="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
|
||||
:class="fullscreen ? 'flex-1' : 'max-h-[30rem]'">
|
||||
<div id="logs" class="flex flex-col font-mono">
|
||||
<div x-show="searchQuery.trim() && getMatchCount() === 0"
|
||||
class="text-gray-500 dark:text-gray-400 py-2">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
<div class="flex gap-2 items-center p-4 cursor-pointer select-none hover:bg-gray-50 dark:hover:bg-coolgray-200"
|
||||
x-on:click="expanded = !expanded; if (expanded && !logsLoaded) { $wire.getLogs(); logsLoaded = true; }">
|
||||
x-on:click="expanded = !expanded; if (expanded && !logsLoaded) { $wire.getLogs(true); logsLoaded = true; }">
|
||||
<svg class="w-4 h-4 transition-transform" :class="expanded ? 'rotate-90' : ''" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
|
||||
|
|
@ -191,9 +192,10 @@
|
|||
</div>
|
||||
@endif
|
||||
<div x-show="expanded" {{ $collapsible ? 'x-collapse' : '' }}
|
||||
:class="fullscreen ? 'fullscreen flex flex-col' : 'relative w-full {{ $collapsible ? 'py-4' : '' }} mx-auto'">
|
||||
<div class="flex flex-col bg-white dark:text-white dark:bg-coolgray-100 dark:border-coolgray-300 border-neutral-200"
|
||||
:class="fullscreen ? 'h-full' : 'border border-solid rounded-sm'">
|
||||
: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;' : ''">
|
||||
<div class="flex flex-col dark:text-white dark:border-coolgray-300 border-neutral-200"
|
||||
:class="fullscreen ? 'h-full w-full bg-white dark:bg-coolgray-100' : 'bg-white dark:bg-coolgray-100 border border-solid rounded-sm'">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-4 py-2 border-b dark:border-coolgray-300 border-neutral-200 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -215,7 +217,7 @@ class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
|
|||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
<input type="text" x-model="searchQuery" placeholder="Find in logs"
|
||||
<input type="text" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
|
||||
class="input input-sm w-48 pl-8 pr-8 dark:bg-coolgray-300" />
|
||||
<button x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
|
|
@ -307,18 +309,26 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
|
|||
class="flex overflow-y-auto overflow-x-hidden flex-col px-4 py-2 w-full min-w-0 scrollbar"
|
||||
:class="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
|
||||
@if ($outputs)
|
||||
@php
|
||||
// Limit rendered lines to prevent memory exhaustion
|
||||
$maxDisplayLines = 2000;
|
||||
$allLines = collect(explode("\n", $outputs))->filter(fn($line) => trim($line) !== '');
|
||||
$totalLines = $allLines->count();
|
||||
$hasMoreLines = $totalLines > $maxDisplayLines;
|
||||
$displayLines = $hasMoreLines ? $allLines->slice(-$maxDisplayLines)->values() : $allLines;
|
||||
@endphp
|
||||
<div id="logs" class="font-mono max-w-full cursor-default">
|
||||
@if ($hasMoreLines)
|
||||
<div class="text-center py-2 text-gray-500 dark:text-gray-400 text-sm border-b dark:border-coolgray-300 mb-2">
|
||||
Showing last {{ number_format($maxDisplayLines) }} of {{ number_format($totalLines) }} lines
|
||||
</div>
|
||||
@endif
|
||||
<div x-show="searchQuery.trim() && getMatchCount() === 0"
|
||||
class="text-gray-500 dark:text-gray-400 py-2">
|
||||
No matches found.
|
||||
</div>
|
||||
@foreach (explode("\n", $outputs) as $line)
|
||||
@foreach ($displayLines as $line)
|
||||
@php
|
||||
// Skip empty lines
|
||||
if (trim($line) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse timestamp from log line (ISO 8601 format: 2025-12-04T11:48:39.136764033Z)
|
||||
$timestamp = '';
|
||||
$logContent = $line;
|
||||
|
|
@ -352,14 +362,14 @@ class="flex gap-2">
|
|||
<span class="shrink-0 text-gray-500">{{ $timestamp }}</span>
|
||||
@endif
|
||||
<span data-line-text="{{ $logContent }}"
|
||||
x-effect="renderTrigger; searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"
|
||||
x-effect="searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"
|
||||
class="whitespace-pre-wrap break-all"></span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<pre id="logs"
|
||||
class="font-mono whitespace-pre-wrap break-all max-w-full">Refresh to get the logs...</pre>
|
||||
class="font-mono whitespace-pre-wrap break-all max-w-full text-neutral-400">No logs yet.</pre>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@
|
|||
<x-forms.input canGate="update" :canResource="$server" id="dynamicTimeout"
|
||||
label="Deployment timeout (seconds)" required
|
||||
helper="You can define the maximum duration for a deployment to run before timing it out." />
|
||||
<x-forms.input canGate="update" :canResource="$server" id="deploymentQueueLimit"
|
||||
label="Deployment queue limit" required
|
||||
helper="Maximum number of queued deployments allowed. New deployments will be rejected with a 429 status when the limit is reached." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\ApplicationsController;
|
||||
use App\Http\Controllers\Api\CloudProviderTokensController;
|
||||
use App\Http\Controllers\Api\DatabasesController;
|
||||
use App\Http\Controllers\Api\DeployController;
|
||||
use App\Http\Controllers\Api\GithubController;
|
||||
use App\Http\Controllers\Api\HetznerController;
|
||||
use App\Http\Controllers\Api\OtherController;
|
||||
use App\Http\Controllers\Api\ProjectController;
|
||||
use App\Http\Controllers\Api\ResourcesController;
|
||||
|
|
@ -63,6 +65,13 @@
|
|||
Route::patch('/security/keys/{uuid}', [SecurityController::class, 'update_key'])->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']);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
413
tests/Feature/CloudProviderTokenApiTest.php
Normal file
413
tests/Feature/CloudProviderTokenApiTest.php
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
<?php
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Create a team with owner
|
||||
$this->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.']);
|
||||
});
|
||||
});
|
||||
448
tests/Feature/HetznerApiTest.php
Normal file
448
tests/Feature/HetznerApiTest.php
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
<?php
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Create a team with owner
|
||||
$this->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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
131
tests/Unit/Api/ApplicationAutogenerateDomainTest.php
Normal file
131
tests/Unit/Api/ApplicationAutogenerateDomainTest.php
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
|
||||
beforeEach(function () {
|
||||
// Mock Log to prevent actual logging
|
||||
Illuminate\Support\Facades\Log::shouldReceive('error')->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();
|
||||
});
|
||||
185
tests/Unit/ValidProxyConfigFilenameTest.php
Normal file
185
tests/Unit/ValidProxyConfigFilenameTest.php
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<?php
|
||||
|
||||
use App\Rules\ValidProxyConfigFilename;
|
||||
|
||||
test('allows valid proxy config filenames', function () {
|
||||
$validFilenames = [
|
||||
'my-config',
|
||||
'service_name.yaml',
|
||||
'router-1.yml',
|
||||
'traefik-config',
|
||||
'my.service.yaml',
|
||||
'config_v2.caddy',
|
||||
'API-Gateway.yaml',
|
||||
'load-balancer_prod.yml',
|
||||
];
|
||||
|
||||
$rule = new ValidProxyConfigFilename;
|
||||
$failures = [];
|
||||
|
||||
foreach ($validFilenames as $filename) {
|
||||
$rule->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',
|
||||
'file<redirect.yaml',
|
||||
'file>output.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();
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue