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);
|
$applicationCleanupLog = $this->cleanupApplicationImages($server, $applications);
|
||||||
$cleanupLog = array_merge($cleanupLog, $applicationCleanupLog);
|
$cleanupLog = array_merge($cleanupLog, $applicationCleanupLog);
|
||||||
|
|
||||||
// Build image prune command that excludes application images
|
// 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
|
// This ensures we clean up non-Coolify images while preserving rollback images and current helper/realtime images
|
||||||
$imagePruneCmd = $this->buildImagePruneCommand($applicationImageRepos);
|
// 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 = [
|
$commands = [
|
||||||
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
|
'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,
|
* 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.
|
* 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)
|
// Step 1: Always prune dangling images (untagged)
|
||||||
$commands = ['docker image prune -f'];
|
$commands = ['docker image prune -f'];
|
||||||
|
|
||||||
if ($applicationImageRepos->isEmpty()) {
|
// Build grep pattern to exclude application image repositories (matches repo:tag and repo_service:tag)
|
||||||
// No applications, add original prune command for all unused images
|
$appExcludePatterns = $applicationImageRepos->map(function ($repo) {
|
||||||
$commands[] = 'docker image prune -af --filter "label!=coolify.managed=true"';
|
// Escape special characters for grep extended regex (ERE)
|
||||||
} else {
|
// ERE special chars: . \ + * ? [ ^ ] $ ( ) { } |
|
||||||
// Build grep pattern to exclude application image repositories
|
return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo);
|
||||||
$excludePatterns = $applicationImageRepos->map(function ($repo) {
|
})->implode('|');
|
||||||
// Escape special characters for grep extended regex (ERE)
|
|
||||||
// ERE special chars: . \ + * ? [ ^ ] $ ( ) { } |
|
|
||||||
return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo);
|
|
||||||
})->implode('|');
|
|
||||||
|
|
||||||
// Delete unused images that:
|
// Build grep pattern to exclude Coolify infrastructure images (current version only)
|
||||||
// - Are not application images (don't match app repos)
|
// This pattern matches the image name regardless of registry prefix:
|
||||||
// - Don't have coolify.managed=true label
|
// - ghcr.io/coollabsio/coolify-helper:1.0.12
|
||||||
// Images in use by containers will fail silently with docker rmi
|
// - docker.io/coollabsio/coolify-helper:1.0.12
|
||||||
// Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build)
|
// - coollabsio/coolify-helper:1.0.12
|
||||||
$commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ".
|
// Pattern: (^|/)coollabsio/coolify-(helper|realtime):VERSION$
|
||||||
"grep -v -E '^({$excludePatterns})[_:].+' | ".
|
$escapedHelperVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $helperImageVersion);
|
||||||
"grep -v '<none>' | ".
|
$escapedRealtimeVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $realtimeImageVersion);
|
||||||
"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";
|
$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);
|
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
|
private function getArchDockerInstallCommand(): string
|
||||||
{
|
{
|
||||||
// Use -Syu to perform full system upgrade before installing Docker
|
// Use -Syu to perform full system upgrade before installing Docker
|
||||||
|
|
@ -171,4 +158,9 @@ private function getArchDockerInstallCommand(): string
|
||||||
'systemctl enable docker.service && '.
|
'systemctl enable docker.service && '.
|
||||||
'systemctl start 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\ServerManagerJob;
|
||||||
use App\Jobs\UpdateCoolifyJob;
|
use App\Jobs\UpdateCoolifyJob;
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
use App\Models\Server;
|
|
||||||
use App\Models\Team;
|
|
||||||
use Illuminate\Console\Scheduling\Schedule;
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||||
|
|
||||||
class Kernel extends ConsoleKernel
|
class Kernel extends ConsoleKernel
|
||||||
{
|
{
|
||||||
private $allServers;
|
|
||||||
|
|
||||||
private Schedule $scheduleInstance;
|
private Schedule $scheduleInstance;
|
||||||
|
|
||||||
private InstanceSettings $settings;
|
private InstanceSettings $settings;
|
||||||
|
|
@ -34,8 +30,6 @@ class Kernel extends ConsoleKernel
|
||||||
protected function schedule(Schedule $schedule): void
|
protected function schedule(Schedule $schedule): void
|
||||||
{
|
{
|
||||||
$this->scheduleInstance = $schedule;
|
$this->scheduleInstance = $schedule;
|
||||||
$this->allServers = Server::where('ip', '!=', '1.2.3.4');
|
|
||||||
|
|
||||||
$this->settings = instanceSettings();
|
$this->settings = instanceSettings();
|
||||||
$this->updateCheckFrequency = $this->settings->update_check_frequency ?: '0 * * * *';
|
$this->updateCheckFrequency = $this->settings->update_check_frequency ?: '0 * * * *';
|
||||||
|
|
||||||
|
|
@ -95,14 +89,6 @@ protected function schedule(Schedule $schedule): void
|
||||||
|
|
||||||
private function pullImages(): 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)
|
$this->scheduleInstance->job(new CheckHelperImageJob)
|
||||||
->cron($this->updateCheckFrequency)
|
->cron($this->updateCheckFrequency)
|
||||||
->timezone($this->instanceTimezone)
|
->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'],
|
'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.'],
|
'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.'],
|
'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'],
|
'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.'],
|
'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.'],
|
'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'],
|
'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.'],
|
'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.'],
|
'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'],
|
'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.'],
|
'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.'],
|
'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'],
|
'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.'],
|
'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.'],
|
'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) {
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
return $return;
|
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(), [
|
$validator = customApiValidator($request->all(), [
|
||||||
'name' => 'string|max:255',
|
'name' => 'string|max:255',
|
||||||
|
|
@ -940,6 +945,7 @@ private function create_application(Request $request, $type)
|
||||||
'is_http_basic_auth_enabled' => 'boolean',
|
'is_http_basic_auth_enabled' => 'boolean',
|
||||||
'http_basic_auth_username' => 'string|nullable',
|
'http_basic_auth_username' => 'string|nullable',
|
||||||
'http_basic_auth_password' => 'string|nullable',
|
'http_basic_auth_password' => 'string|nullable',
|
||||||
|
'autogenerate_domain' => 'boolean',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||||
|
|
@ -964,6 +970,7 @@ private function create_application(Request $request, $type)
|
||||||
}
|
}
|
||||||
$serverUuid = $request->server_uuid;
|
$serverUuid = $request->server_uuid;
|
||||||
$fqdn = $request->domains;
|
$fqdn = $request->domains;
|
||||||
|
$autogenerateDomain = $request->boolean('autogenerate_domain', true);
|
||||||
$instantDeploy = $request->instant_deploy;
|
$instantDeploy = $request->instant_deploy;
|
||||||
$githubAppUuid = $request->github_app_uuid;
|
$githubAppUuid = $request->github_app_uuid;
|
||||||
$useBuildServer = $request->use_build_server;
|
$useBuildServer = $request->use_build_server;
|
||||||
|
|
@ -1087,6 +1094,11 @@ private function create_application(Request $request, $type)
|
||||||
$application->settings->save();
|
$application->settings->save();
|
||||||
}
|
}
|
||||||
$application->refresh();
|
$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) {
|
if ($application->settings->is_container_label_readonly_enabled) {
|
||||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||||
$application->save();
|
$application->save();
|
||||||
|
|
@ -1115,7 +1127,7 @@ private function create_application(Request $request, $type)
|
||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
'uuid' => data_get($application, 'uuid'),
|
'uuid' => data_get($application, 'uuid'),
|
||||||
'domains' => data_get($application, 'domains'),
|
'domains' => data_get($application, 'fqdn'),
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'private-gh-app') {
|
} elseif ($type === 'private-gh-app') {
|
||||||
$validationRules = [
|
$validationRules = [
|
||||||
|
|
@ -1238,6 +1250,11 @@ private function create_application(Request $request, $type)
|
||||||
|
|
||||||
$application->save();
|
$application->save();
|
||||||
$application->refresh();
|
$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)) {
|
if (isset($useBuildServer)) {
|
||||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||||
$application->settings->save();
|
$application->settings->save();
|
||||||
|
|
@ -1270,7 +1287,7 @@ private function create_application(Request $request, $type)
|
||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
'uuid' => data_get($application, 'uuid'),
|
'uuid' => data_get($application, 'uuid'),
|
||||||
'domains' => data_get($application, 'domains'),
|
'domains' => data_get($application, 'fqdn'),
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'private-deploy-key') {
|
} elseif ($type === 'private-deploy-key') {
|
||||||
|
|
||||||
|
|
@ -1367,6 +1384,11 @@ private function create_application(Request $request, $type)
|
||||||
$application->environment_id = $environment->id;
|
$application->environment_id = $environment->id;
|
||||||
$application->save();
|
$application->save();
|
||||||
$application->refresh();
|
$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)) {
|
if (isset($useBuildServer)) {
|
||||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||||
$application->settings->save();
|
$application->settings->save();
|
||||||
|
|
@ -1399,7 +1421,7 @@ private function create_application(Request $request, $type)
|
||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
'uuid' => data_get($application, 'uuid'),
|
'uuid' => data_get($application, 'uuid'),
|
||||||
'domains' => data_get($application, 'domains'),
|
'domains' => data_get($application, 'fqdn'),
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'dockerfile') {
|
} elseif ($type === 'dockerfile') {
|
||||||
$validationRules = [
|
$validationRules = [
|
||||||
|
|
@ -1461,6 +1483,11 @@ private function create_application(Request $request, $type)
|
||||||
$application->git_branch = 'main';
|
$application->git_branch = 'main';
|
||||||
$application->save();
|
$application->save();
|
||||||
$application->refresh();
|
$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)) {
|
if (isset($useBuildServer)) {
|
||||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||||
$application->settings->save();
|
$application->settings->save();
|
||||||
|
|
@ -1489,7 +1516,7 @@ private function create_application(Request $request, $type)
|
||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
'uuid' => data_get($application, 'uuid'),
|
'uuid' => data_get($application, 'uuid'),
|
||||||
'domains' => data_get($application, 'domains'),
|
'domains' => data_get($application, 'fqdn'),
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'dockerimage') {
|
} elseif ($type === 'dockerimage') {
|
||||||
$validationRules = [
|
$validationRules = [
|
||||||
|
|
@ -1554,6 +1581,11 @@ private function create_application(Request $request, $type)
|
||||||
$application->git_branch = 'main';
|
$application->git_branch = 'main';
|
||||||
$application->save();
|
$application->save();
|
||||||
$application->refresh();
|
$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)) {
|
if (isset($useBuildServer)) {
|
||||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||||
$application->settings->save();
|
$application->settings->save();
|
||||||
|
|
@ -1582,7 +1614,7 @@ private function create_application(Request $request, $type)
|
||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
'uuid' => data_get($application, 'uuid'),
|
'uuid' => data_get($application, 'uuid'),
|
||||||
'domains' => data_get($application, 'domains'),
|
'domains' => data_get($application, 'fqdn'),
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'dockercompose') {
|
} 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'];
|
$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;
|
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) {
|
if ($deployment_uuid) {
|
||||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -430,7 +434,11 @@ public function by_tags(string $tags, int $team_id, bool $force = false)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
foreach ($applications as $resource) {
|
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) {
|
if ($deployment_uuid) {
|
||||||
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
$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,
|
deployment_uuid: $deployment_uuid,
|
||||||
force_rebuild: $force,
|
force_rebuild: $force,
|
||||||
pull_request_id: $pr,
|
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'];
|
$message = $result['message'];
|
||||||
} else {
|
} else {
|
||||||
$message = "Application {$resource->name} deployment queued.";
|
$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
|
class OpenApi
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,9 @@ public function manual(Request $request)
|
||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
is_webhook: true
|
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([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
@ -144,7 +146,9 @@ public function manual(Request $request)
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'bitbucket'
|
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([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,9 @@ public function manual(Request $request)
|
||||||
commit: data_get($payload, 'after', 'HEAD'),
|
commit: data_get($payload, 'after', 'HEAD'),
|
||||||
is_webhook: true,
|
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([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
@ -169,7 +171,9 @@ public function manual(Request $request)
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'gitea'
|
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([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,9 @@ public function manual(Request $request)
|
||||||
commit: data_get($payload, 'after', 'HEAD'),
|
commit: data_get($payload, 'after', 'HEAD'),
|
||||||
is_webhook: true,
|
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([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
@ -197,7 +199,9 @@ public function manual(Request $request)
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'github'
|
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([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
@ -347,12 +351,15 @@ public function normal(Request $request)
|
||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
}
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'status' => $result['status'],
|
'status' => $result['status'],
|
||||||
'message' => $result['message'],
|
'message' => $result['message'],
|
||||||
'application_uuid' => $application->uuid,
|
'application_uuid' => $application->uuid,
|
||||||
'application_name' => $application->name,
|
'application_name' => $application->name,
|
||||||
'deployment_uuid' => $result['deployment_uuid'],
|
'deployment_uuid' => $result['deployment_uuid'] ?? null,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$paths = str($application->watch_paths)->explode("\n");
|
$paths = str($application->watch_paths)->explode("\n");
|
||||||
|
|
@ -411,7 +418,9 @@ public function normal(Request $request)
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'github'
|
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([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,9 @@ public function manual(Request $request)
|
||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
is_webhook: true,
|
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([
|
$return_payloads->push([
|
||||||
'status' => $result['status'],
|
'status' => $result['status'],
|
||||||
'message' => $result['message'],
|
'message' => $result['message'],
|
||||||
|
|
@ -202,7 +204,9 @@ public function manual(Request $request)
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'gitlab'
|
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([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
|
|
|
||||||
|
|
@ -486,15 +486,38 @@ private function decide_what_to_do()
|
||||||
|
|
||||||
private function post_deployment()
|
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();
|
$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->pull_request_id !== 0) {
|
||||||
if ($this->application->is_github_based()) {
|
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()
|
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.
|
* Terminal states cannot be changed.
|
||||||
*/
|
*/
|
||||||
private function isInTerminalState(): bool
|
private function isInTerminalState(): bool
|
||||||
{
|
{
|
||||||
$this->application_deployment_queue->refresh();
|
$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) {
|
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -3980,6 +4007,15 @@ private function handleStatusTransition(ApplicationDeploymentStatus $status): vo
|
||||||
*/
|
*/
|
||||||
private function handleSuccessfulDeployment(): void
|
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));
|
event(new ApplicationConfigurationChanged($this->application->team()->id));
|
||||||
|
|
||||||
if (! $this->only_this_server) {
|
if (! $this->only_this_server) {
|
||||||
|
|
|
||||||
|
|
@ -160,13 +160,10 @@ private function processServerTasks(Server $server): void
|
||||||
ServerPatchCheckJob::dispatch($server);
|
ServerPatchCheckJob::dispatch($server);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for sentinel updates hourly (independent of user-configurable update_check_frequency)
|
// Sentinel update checks (hourly) - check for updates to Sentinel version
|
||||||
if ($server->isSentinelEnabled()) {
|
// No timezone needed for hourly - runs at top of every hour
|
||||||
$shouldCheckSentinel = $this->shouldRunNow('0 * * * *', $serverTimezone);
|
if ($isSentinelEnabled && $this->shouldRunNow('0 * * * *')) {
|
||||||
|
CheckAndStartSentinelJob::dispatch($server);
|
||||||
if ($shouldCheckSentinel) {
|
|
||||||
CheckAndStartSentinelJob::dispatch($server);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@ public function deploymentCount()
|
||||||
return $this->deployments->count();
|
return $this->deployments->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function shouldReduceOpacity(): bool
|
||||||
|
{
|
||||||
|
return request()->routeIs('project.application.deployment.*');
|
||||||
|
}
|
||||||
|
|
||||||
public function toggleExpanded()
|
public function toggleExpanded()
|
||||||
{
|
{
|
||||||
$this->expanded = ! $this->expanded;
|
$this->expanded = ! $this->expanded;
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,11 @@ class Show extends Component
|
||||||
|
|
||||||
public bool $is_debug_enabled = false;
|
public bool $is_debug_enabled = false;
|
||||||
|
|
||||||
|
private bool $deploymentFinishedDispatched = false;
|
||||||
|
|
||||||
public function getListeners()
|
public function getListeners()
|
||||||
{
|
{
|
||||||
$teamId = auth()->user()->currentTeam()->id;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
|
|
||||||
'refreshQueue',
|
'refreshQueue',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -91,10 +90,15 @@ private function isKeepAliveOn()
|
||||||
|
|
||||||
public function polling()
|
public function polling()
|
||||||
{
|
{
|
||||||
$this->dispatch('deploymentFinished');
|
|
||||||
$this->application_deployment_queue->refresh();
|
$this->application_deployment_queue->refresh();
|
||||||
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
|
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
|
||||||
$this->isKeepAliveOn();
|
$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()
|
public function getLogLinesProperty()
|
||||||
|
|
|
||||||
|
|
@ -100,19 +100,17 @@ public function deploy(bool $force_rebuild = false)
|
||||||
deployment_uuid: $this->deploymentUuid,
|
deployment_uuid: $this->deploymentUuid,
|
||||||
force_rebuild: $force_rebuild,
|
force_rebuild: $force_rebuild,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'skipped') {
|
||||||
$this->dispatch('error', 'Deployment skipped', $result['message']);
|
$this->dispatch('error', 'Deployment skipped', $result['message']);
|
||||||
|
|
||||||
return;
|
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', [
|
return $this->redirectRoute('project.application.deployment.show', [
|
||||||
'project_uuid' => $this->parameters['project_uuid'],
|
'project_uuid' => $this->parameters['project_uuid'],
|
||||||
'application_uuid' => $this->parameters['application_uuid'],
|
'application_uuid' => $this->parameters['application_uuid'],
|
||||||
|
|
@ -151,19 +149,17 @@ public function restart()
|
||||||
deployment_uuid: $this->deploymentUuid,
|
deployment_uuid: $this->deploymentUuid,
|
||||||
restart_only: true,
|
restart_only: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'skipped') {
|
||||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||||
|
|
||||||
return;
|
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', [
|
return $this->redirectRoute('project.application.deployment.show', [
|
||||||
'project_uuid' => $this->parameters['project_uuid'],
|
'project_uuid' => $this->parameters['project_uuid'],
|
||||||
'application_uuid' => $this->parameters['application_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,
|
pull_request_id: $pull_request_id,
|
||||||
git_type: $found->git_type ?? null,
|
git_type: $found->git_type ?? null,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'skipped') {
|
||||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ public function rollbackImage($commit)
|
||||||
|
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
|
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $this->application,
|
application: $this->application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
commit: $commit,
|
commit: $commit,
|
||||||
|
|
@ -60,6 +60,12 @@ public function rollbackImage($commit)
|
||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->route('project.application.deployment.show', [
|
return redirect()->route('project.application.deployment.show', [
|
||||||
'project_uuid' => $this->parameters['project_uuid'],
|
'project_uuid' => $this->parameters['project_uuid'],
|
||||||
'application_uuid' => $this->parameters['application_uuid'],
|
'application_uuid' => $this->parameters['application_uuid'],
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,11 @@ public function redeploy(int $network_id, int $server_id)
|
||||||
only_this_server: true,
|
only_this_server: true,
|
||||||
no_questions_asked: true,
|
no_questions_asked: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'skipped') {
|
||||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ class Advanced extends Component
|
||||||
#[Validate(['integer', 'min:1'])]
|
#[Validate(['integer', 'min:1'])]
|
||||||
public int $dynamicTimeout = 1;
|
public int $dynamicTimeout = 1;
|
||||||
|
|
||||||
|
#[Validate(['integer', 'min:1'])]
|
||||||
|
public int $deploymentQueueLimit = 25;
|
||||||
|
|
||||||
public function mount(string $server_uuid)
|
public function mount(string $server_uuid)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
@ -43,12 +46,14 @@ public function syncData(bool $toModel = false)
|
||||||
$this->validate();
|
$this->validate();
|
||||||
$this->server->settings->concurrent_builds = $this->concurrentBuilds;
|
$this->server->settings->concurrent_builds = $this->concurrentBuilds;
|
||||||
$this->server->settings->dynamic_timeout = $this->dynamicTimeout;
|
$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_notification_threshold = $this->serverDiskUsageNotificationThreshold;
|
||||||
$this->server->settings->server_disk_usage_check_frequency = $this->serverDiskUsageCheckFrequency;
|
$this->server->settings->server_disk_usage_check_frequency = $this->serverDiskUsageCheckFrequency;
|
||||||
$this->server->settings->save();
|
$this->server->settings->save();
|
||||||
} else {
|
} else {
|
||||||
$this->concurrentBuilds = $this->server->settings->concurrent_builds;
|
$this->concurrentBuilds = $this->server->settings->concurrent_builds;
|
||||||
$this->dynamicTimeout = $this->server->settings->dynamic_timeout;
|
$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->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold;
|
||||||
$this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency;
|
$this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use App\Enums\ProxyTypes;
|
use App\Enums\ProxyTypes;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Rules\ValidProxyConfigFilename;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
@ -38,11 +39,11 @@ public function addDynamicConfiguration()
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->server);
|
$this->authorize('update', $this->server);
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'fileName' => 'required',
|
'fileName' => ['required', new ValidProxyConfigFilename],
|
||||||
'value' => 'required',
|
'value' => 'required',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Validate filename to prevent command injection
|
// Additional security validation to prevent command injection
|
||||||
validateShellSafePath($this->fileName, 'proxy configuration filename');
|
validateShellSafePath($this->fileName, 'proxy configuration filename');
|
||||||
|
|
||||||
if (data_get($this->parameters, 'server_uuid')) {
|
if (data_get($this->parameters, 'server_uuid')) {
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
class CloudProviderToken extends BaseModel
|
||||||
|
|
||||||
class CloudProviderToken extends Model
|
|
||||||
{
|
{
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
properties: [
|
properties: [
|
||||||
'id' => ['type' => 'integer'],
|
'id' => ['type' => 'integer'],
|
||||||
'concurrent_builds' => ['type' => 'integer'],
|
'concurrent_builds' => ['type' => 'integer'],
|
||||||
|
'deployment_queue_limit' => ['type' => 'integer'],
|
||||||
'dynamic_timeout' => ['type' => 'integer'],
|
'dynamic_timeout' => ['type' => 'integer'],
|
||||||
'force_disabled' => ['type' => 'boolean'],
|
'force_disabled' => ['type' => 'boolean'],
|
||||||
'force_server_cleanup' => ['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;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Exceptions\RateLimitException;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
class HetznerService
|
class HetznerService
|
||||||
|
|
@ -46,6 +47,19 @@ private function request(string $method, string $endpoint, array $data = [])
|
||||||
->{$method}($this->baseUrl.$endpoint, $data);
|
->{$method}($this->baseUrl.$endpoint, $data);
|
||||||
|
|
||||||
if (! $response->successful()) {
|
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'));
|
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 we exhausted all retries and still failed
|
||||||
if (! $commandExecuted && $lastError) {
|
if (! $commandExecuted && $lastError) {
|
||||||
// Now we can set the status to FAILED since all retries have been exhausted
|
// 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)) {
|
if (isset($this->application_deployment_queue)) {
|
||||||
$this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value;
|
// Avoid clobbering a deployment that may have just been marked FINISHED
|
||||||
$this->application_deployment_queue->save();
|
$this->application_deployment_queue->newQuery()
|
||||||
|
->where('id', $this->application_deployment_queue->id)
|
||||||
|
->where('status', '!=', ApplicationDeploymentStatus::FINISHED->value)
|
||||||
|
->update([
|
||||||
|
'status' => ApplicationDeploymentStatus::FAILED->value,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
throw $lastError;
|
throw $lastError;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -178,4 +178,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
|
||||||
$request->offsetUnset('use_build_server');
|
$request->offsetUnset('use_build_server');
|
||||||
$request->offsetUnset('is_static');
|
$request->offsetUnset('is_static');
|
||||||
$request->offsetUnset('force_domain_override');
|
$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;
|
$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
|
// Check if there's already a deployment in progress or queued for this application and commit
|
||||||
$existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id)
|
$existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id)
|
||||||
->where('commit', $commit)
|
->where('commit', $commit)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'coolify' => [
|
'coolify' => [
|
||||||
'version' => '4.0.0-beta.453',
|
'version' => '4.0.0-beta.454',
|
||||||
'helper_version' => '1.0.12',
|
'helper_version' => '1.0.12',
|
||||||
'realtime_version' => '1.0.10',
|
'realtime_version' => '1.0.10',
|
||||||
'self_hosted' => env('SELF_HOSTED', true),
|
'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
|
public function run(): void
|
||||||
{
|
{
|
||||||
Application::create([
|
Application::create([
|
||||||
|
'uuid' => 'docker-compose',
|
||||||
'name' => 'Docker Compose Example',
|
'name' => 'Docker Compose Example',
|
||||||
'repository_project_id' => 603035348,
|
'repository_project_id' => 603035348,
|
||||||
'git_repository' => 'coollabsio/coolify-examples',
|
'git_repository' => 'coollabsio/coolify-examples',
|
||||||
|
|
@ -30,6 +31,7 @@ public function run(): void
|
||||||
'source_type' => GithubApp::class,
|
'source_type' => GithubApp::class,
|
||||||
]);
|
]);
|
||||||
Application::create([
|
Application::create([
|
||||||
|
'uuid' => 'nodejs',
|
||||||
'name' => 'NodeJS Fastify Example',
|
'name' => 'NodeJS Fastify Example',
|
||||||
'fqdn' => 'http://nodejs.127.0.0.1.sslip.io',
|
'fqdn' => 'http://nodejs.127.0.0.1.sslip.io',
|
||||||
'repository_project_id' => 603035348,
|
'repository_project_id' => 603035348,
|
||||||
|
|
@ -45,6 +47,7 @@ public function run(): void
|
||||||
'source_type' => GithubApp::class,
|
'source_type' => GithubApp::class,
|
||||||
]);
|
]);
|
||||||
Application::create([
|
Application::create([
|
||||||
|
'uuid' => 'dockerfile',
|
||||||
'name' => 'Dockerfile Example',
|
'name' => 'Dockerfile Example',
|
||||||
'fqdn' => 'http://dockerfile.127.0.0.1.sslip.io',
|
'fqdn' => 'http://dockerfile.127.0.0.1.sslip.io',
|
||||||
'repository_project_id' => 603035348,
|
'repository_project_id' => 603035348,
|
||||||
|
|
@ -60,6 +63,7 @@ public function run(): void
|
||||||
'source_type' => GithubApp::class,
|
'source_type' => GithubApp::class,
|
||||||
]);
|
]);
|
||||||
Application::create([
|
Application::create([
|
||||||
|
'uuid' => 'dockerfile-pure',
|
||||||
'name' => 'Pure Dockerfile Example',
|
'name' => 'Pure Dockerfile Example',
|
||||||
'fqdn' => 'http://pure-dockerfile.127.0.0.1.sslip.io',
|
'fqdn' => 'http://pure-dockerfile.127.0.0.1.sslip.io',
|
||||||
'git_repository' => 'coollabsio/coolify',
|
'git_repository' => 'coollabsio/coolify',
|
||||||
|
|
@ -75,6 +79,23 @@ public function run(): void
|
||||||
'dockerfile' => 'FROM nginx
|
'dockerfile' => 'FROM nginx
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
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([
|
GithubApp::create([
|
||||||
'id' => 0,
|
'id' => 0,
|
||||||
|
'uuid' => 'github-public',
|
||||||
'name' => 'Public GitHub',
|
'name' => 'Public GitHub',
|
||||||
'api_url' => 'https://api.github.com',
|
'api_url' => 'https://api.github.com',
|
||||||
'html_url' => 'https://github.com',
|
'html_url' => 'https://github.com',
|
||||||
|
|
@ -22,7 +23,7 @@ public function run(): void
|
||||||
]);
|
]);
|
||||||
GithubApp::create([
|
GithubApp::create([
|
||||||
'name' => 'coolify-laravel-dev-public',
|
'name' => 'coolify-laravel-dev-public',
|
||||||
'uuid' => '69420',
|
'uuid' => 'github-app',
|
||||||
'organization' => 'coollabsio',
|
'organization' => 'coollabsio',
|
||||||
'api_url' => 'https://api.github.com',
|
'api_url' => 'https://api.github.com',
|
||||||
'html_url' => 'https://github.com',
|
'html_url' => 'https://github.com',
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ public function run(): void
|
||||||
{
|
{
|
||||||
GitlabApp::create([
|
GitlabApp::create([
|
||||||
'id' => 1,
|
'id' => 1,
|
||||||
|
'uuid' => 'gitlab-public',
|
||||||
'name' => 'Public GitLab',
|
'name' => 'Public GitLab',
|
||||||
'api_url' => 'https://gitlab.com/api/v4',
|
'api_url' => 'https://gitlab.com/api/v4',
|
||||||
'html_url' => 'https://gitlab.com',
|
'html_url' => 'https://gitlab.com',
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ class PrivateKeySeeder extends Seeder
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
PrivateKey::create([
|
PrivateKey::create([
|
||||||
|
'uuid' => 'ssh',
|
||||||
'team_id' => 0,
|
'team_id' => 0,
|
||||||
'name' => 'Testing Host Key',
|
'name' => 'Testing Host Key',
|
||||||
'description' => 'This is a test docker container',
|
'description' => 'This is a test docker container',
|
||||||
|
|
@ -27,6 +28,7 @@ public function run(): void
|
||||||
]);
|
]);
|
||||||
|
|
||||||
PrivateKey::create([
|
PrivateKey::create([
|
||||||
|
'uuid' => 'github-key',
|
||||||
'team_id' => 0,
|
'team_id' => 0,
|
||||||
'name' => 'development-github-app',
|
'name' => 'development-github-app',
|
||||||
'description' => 'This is the key for using the 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
|
public function run(): void
|
||||||
{
|
{
|
||||||
Project::create([
|
$project = Project::create([
|
||||||
|
'uuid' => 'project',
|
||||||
'name' => 'My first project',
|
'name' => 'My first project',
|
||||||
'description' => 'This is a test project in development',
|
'description' => 'This is a test project in development',
|
||||||
'team_id' => 0,
|
'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
|
public function run(): void
|
||||||
{
|
{
|
||||||
S3Storage::create([
|
S3Storage::create([
|
||||||
|
'uuid' => 'minio',
|
||||||
'name' => 'Local MinIO',
|
'name' => 'Local MinIO',
|
||||||
'description' => 'Local MinIO S3 Storage',
|
'description' => 'Local MinIO S3 Storage',
|
||||||
'key' => 'minioadmin',
|
'key' => 'minioadmin',
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ public function run(): void
|
||||||
{
|
{
|
||||||
Server::create([
|
Server::create([
|
||||||
'id' => 0,
|
'id' => 0,
|
||||||
|
'uuid' => 'localhost',
|
||||||
'name' => 'localhost',
|
'name' => 'localhost',
|
||||||
'description' => 'This is a test docker container in development mode',
|
'description' => 'This is a test docker container in development mode',
|
||||||
'ip' => 'coolify-testing-host',
|
'ip' => 'coolify-testing-host',
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ public function run(): void
|
||||||
if (StandaloneDocker::find(0) == null) {
|
if (StandaloneDocker::find(0) == null) {
|
||||||
StandaloneDocker::create([
|
StandaloneDocker::create([
|
||||||
'id' => 0,
|
'id' => 0,
|
||||||
|
'uuid' => 'docker',
|
||||||
'name' => 'Standalone Docker 1',
|
'name' => 'Standalone Docker 1',
|
||||||
'network' => 'coolify',
|
'network' => 'coolify',
|
||||||
'server_id' => 0,
|
'server_id' => 0,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ class StandalonePostgresqlSeeder extends Seeder
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
StandalonePostgresql::create([
|
StandalonePostgresql::create([
|
||||||
|
'uuid' => 'postgresql',
|
||||||
'name' => 'Local PostgreSQL',
|
'name' => 'Local PostgreSQL',
|
||||||
'description' => 'Local PostgreSQL for testing',
|
'description' => 'Local PostgreSQL for testing',
|
||||||
'postgres_password' => 'postgres',
|
'postgres_password' => 'postgres',
|
||||||
|
|
|
||||||
922
openapi.json
922
openapi.json
|
|
@ -361,6 +361,11 @@
|
||||||
"force_domain_override": {
|
"force_domain_override": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Force domain usage even if conflicts are detected. Default is false."
|
"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"
|
"type": "object"
|
||||||
|
|
@ -771,6 +776,11 @@
|
||||||
"force_domain_override": {
|
"force_domain_override": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Force domain usage even if conflicts are detected. Default is false."
|
"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"
|
"type": "object"
|
||||||
|
|
@ -1181,6 +1191,11 @@
|
||||||
"force_domain_override": {
|
"force_domain_override": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Force domain usage even if conflicts are detected. Default is false."
|
"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"
|
"type": "object"
|
||||||
|
|
@ -1520,6 +1535,11 @@
|
||||||
"force_domain_override": {
|
"force_domain_override": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Force domain usage even if conflicts are detected. Default is false."
|
"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"
|
"type": "object"
|
||||||
|
|
@ -1842,6 +1862,11 @@
|
||||||
"force_domain_override": {
|
"force_domain_override": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Force domain usage even if conflicts are detected. Default is false."
|
"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"
|
"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": {
|
"\/databases": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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": {
|
"\/version": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Version",
|
"summary": "Version",
|
||||||
|
|
@ -9816,6 +10702,9 @@
|
||||||
"concurrent_builds": {
|
"concurrent_builds": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"deployment_queue_limit": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"dynamic_timeout": {
|
"dynamic_timeout": {
|
||||||
"type": "integer"
|
"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": {
|
"securitySchemes": {
|
||||||
|
|
@ -10189,6 +11103,10 @@
|
||||||
"name": "Applications",
|
"name": "Applications",
|
||||||
"description": "Applications"
|
"description": "Applications"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Cloud Tokens",
|
||||||
|
"description": "Cloud Tokens"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Databases",
|
"name": "Databases",
|
||||||
"description": "Databases"
|
"description": "Databases"
|
||||||
|
|
@ -10201,6 +11119,10 @@
|
||||||
"name": "GitHub Apps",
|
"name": "GitHub Apps",
|
||||||
"description": "GitHub Apps"
|
"description": "GitHub Apps"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Hetzner",
|
||||||
|
"description": "Hetzner"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Projects",
|
"name": "Projects",
|
||||||
"description": "Projects"
|
"description": "Projects"
|
||||||
|
|
|
||||||
514
openapi.yaml
514
openapi.yaml
|
|
@ -265,6 +265,10 @@ paths:
|
||||||
force_domain_override:
|
force_domain_override:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: 'Force domain usage even if conflicts are detected. Default is false.'
|
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
|
type: object
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
|
|
@ -531,6 +535,10 @@ paths:
|
||||||
force_domain_override:
|
force_domain_override:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: 'Force domain usage even if conflicts are detected. Default is false.'
|
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
|
type: object
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
|
|
@ -797,6 +805,10 @@ paths:
|
||||||
force_domain_override:
|
force_domain_override:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: 'Force domain usage even if conflicts are detected. Default is false.'
|
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
|
type: object
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
|
|
@ -1010,6 +1022,10 @@ paths:
|
||||||
force_domain_override:
|
force_domain_override:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: 'Force domain usage even if conflicts are detected. Default is false.'
|
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
|
type: object
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
|
|
@ -1214,6 +1230,10 @@ paths:
|
||||||
force_domain_override:
|
force_domain_override:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: 'Force domain usage even if conflicts are detected. Default is false.'
|
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
|
type: object
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
|
|
@ -2075,6 +2095,224 @@ paths:
|
||||||
security:
|
security:
|
||||||
-
|
-
|
||||||
bearerAuth: []
|
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:
|
/databases:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -4084,6 +4322,258 @@ paths:
|
||||||
security:
|
security:
|
||||||
-
|
-
|
||||||
bearerAuth: []
|
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:
|
/version:
|
||||||
get:
|
get:
|
||||||
summary: Version
|
summary: Version
|
||||||
|
|
@ -6312,6 +6802,8 @@ components:
|
||||||
type: integer
|
type: integer
|
||||||
concurrent_builds:
|
concurrent_builds:
|
||||||
type: integer
|
type: integer
|
||||||
|
deployment_queue_limit:
|
||||||
|
type: integer
|
||||||
dynamic_timeout:
|
dynamic_timeout:
|
||||||
type: integer
|
type: integer
|
||||||
force_disabled:
|
force_disabled:
|
||||||
|
|
@ -6556,6 +7048,22 @@ components:
|
||||||
type: array
|
type: array
|
||||||
items: { type: string }
|
items: { type: string }
|
||||||
type: object
|
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:
|
securitySchemes:
|
||||||
bearerAuth:
|
bearerAuth:
|
||||||
type: http
|
type: http
|
||||||
|
|
@ -6565,6 +7073,9 @@ tags:
|
||||||
-
|
-
|
||||||
name: Applications
|
name: Applications
|
||||||
description: Applications
|
description: Applications
|
||||||
|
-
|
||||||
|
name: 'Cloud Tokens'
|
||||||
|
description: 'Cloud Tokens'
|
||||||
-
|
-
|
||||||
name: Databases
|
name: Databases
|
||||||
description: Databases
|
description: Databases
|
||||||
|
|
@ -6574,6 +7085,9 @@ tags:
|
||||||
-
|
-
|
||||||
name: 'GitHub Apps'
|
name: 'GitHub Apps'
|
||||||
description: 'GitHub Apps'
|
description: 'GitHub Apps'
|
||||||
|
-
|
||||||
|
name: Hetzner
|
||||||
|
description: Hetzner
|
||||||
-
|
-
|
||||||
name: Projects
|
name: Projects
|
||||||
description: Projects
|
description: Projects
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ fi
|
||||||
|
|
||||||
if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
|
if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
|
||||||
echo "docker-compose.custom.yml detected." >>"$LOGFILE"
|
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
|
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
|
fi
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"coolify": {
|
"coolify": {
|
||||||
"v4": {
|
"v4": {
|
||||||
"version": "4.0.0-beta.453"
|
"version": "4.0.0-beta.454"
|
||||||
},
|
},
|
||||||
"nightly": {
|
"nightly": {
|
||||||
"version": "4.0.0-beta.454"
|
"version": "4.0.0-beta.455"
|
||||||
},
|
},
|
||||||
"helper": {
|
"helper": {
|
||||||
"version": "1.0.12"
|
"version": "1.0.12"
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
<div wire:poll.3000ms x-data="{
|
<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">
|
}" class="fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4">
|
||||||
@if ($this->deploymentCount > 0)
|
@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 -->
|
<!-- Indicator Button -->
|
||||||
<button @click="expanded = !expanded"
|
<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">
|
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 -->
|
<!-- Animated spinner -->
|
||||||
<svg class="w-4 h-4 text-coollabs dark:text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none"
|
<svg class="w-4 h-4 text-coollabs dark:text-warning animate-spin" xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24">
|
fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor"
|
<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">
|
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>
|
</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 -->
|
<!-- Expand/collapse icon -->
|
||||||
<svg class="w-4 h-4 transition-transform duration-200 dark:text-neutral-400 text-gray-600"
|
<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"
|
:class="{ 'rotate-180': expanded }" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||||
viewBox="0 0 24 24" stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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 -->
|
<!-- Expanded deployment list -->
|
||||||
<div x-show="expanded" x-transition:enter="transition ease-out duration-200"
|
<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: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="transition ease-in duration-150" x-transition:leave-start="opacity-100 translate-y-0"
|
||||||
x-transition:leave-start="opacity-100 translate-y-0" x-transition:leave-end="opacity-0 translate-y-2"
|
x-transition:leave-end="opacity-0 translate-y-2" x-cloak
|
||||||
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">
|
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">
|
<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')
|
@if ($deployment->status === 'in_progress')
|
||||||
<svg class="w-4 h-4 text-coollabs dark:text-warning animate-spin"
|
<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">
|
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10"
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
|
||||||
stroke="currentColor" stroke-width="4"></circle>
|
</circle>
|
||||||
<path class="opacity-75" fill="currentColor"
|
<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">
|
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>
|
</path>
|
||||||
</svg>
|
</svg>
|
||||||
@else
|
@else
|
||||||
<svg class="w-4 h-4 dark:text-neutral-400 text-gray-500"
|
<svg class="w-4 h-4 dark:text-neutral-400 text-gray-500" xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<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" />
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</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 }}
|
{{ $deployment->application_name }}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs dark:text-neutral-400 text-gray-600 mt-1">
|
<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>
|
||||||
<p class="text-xs dark:text-neutral-400 text-gray-600">
|
<p class="text-xs dark:text-neutral-400 text-gray-600">
|
||||||
{{ $deployment->server_name }}
|
{{ $deployment->server_name }}
|
||||||
|
|
@ -78,11 +78,10 @@ class="flex items-start gap-3 p-3 rounded-lg dark:bg-coolgray-200 bg-gray-50 tra
|
||||||
PR #{{ $deployment->pull_request_id }}
|
PR #{{ $deployment->pull_request_id }}
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
<p class="text-xs mt-1 capitalize"
|
<p class="text-xs mt-1 capitalize" :class="{
|
||||||
:class="{
|
'text-coollabs dark:text-warning': '{{ $deployment->status }}' === 'in_progress',
|
||||||
'text-coollabs dark:text-warning': '{{ $deployment->status }}' === 'in_progress',
|
'dark:text-neutral-400 text-gray-500': '{{ $deployment->status }}' === 'queued'
|
||||||
'dark:text-neutral-400 text-gray-500': '{{ $deployment->status }}' === 'queued'
|
}">
|
||||||
}">
|
|
||||||
{{ str_replace('_', ' ', $deployment->status) }}
|
{{ str_replace('_', ' ', $deployment->status) }}
|
||||||
</p>
|
</p>
|
||||||
</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."
|
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"
|
instantSave id="isPreviewDeploymentsEnabled" label="Preview Deployments" canGate="update"
|
||||||
:canResource="$application" />
|
:canResource="$application" />
|
||||||
@if ($isPreviewDeploymentsEnabled)
|
<x-forms.checkbox
|
||||||
<x-forms.checkbox
|
helper="When enabled, anyone can trigger PR deployments. When disabled, only repository members, collaborators, and contributors can trigger PR deployments."
|
||||||
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"
|
||||||
instantSave id="isPrDeploymentsPublicEnabled" label="Allow Public PR Deployments" canGate="update"
|
:canResource="$application" :disabled="!$isPreviewDeploymentsEnabled" />
|
||||||
:canResource="$application" />
|
|
||||||
@endif
|
|
||||||
@endif
|
@endif
|
||||||
<x-forms.checkbox helper="Disable Docker build cache on every deployment." instantSave
|
<x-forms.checkbox helper="Disable Docker build cache on every deployment." instantSave
|
||||||
id="disableBuildCache" label="Disable Build Cache" canGate="update" :canResource="$application" />
|
id="disableBuildCache" label="Disable Build Cache" canGate="update" :canResource="$application" />
|
||||||
|
|
|
||||||
|
|
@ -15,21 +15,14 @@
|
||||||
deploymentId: '{{ $application_deployment_queue->deployment_uuid ?? 'deployment' }}',
|
deploymentId: '{{ $application_deployment_queue->deployment_uuid ?? 'deployment' }}',
|
||||||
makeFullscreen() {
|
makeFullscreen() {
|
||||||
this.fullscreen = !this.fullscreen;
|
this.fullscreen = !this.fullscreen;
|
||||||
if (this.fullscreen === false) {
|
|
||||||
this.alwaysScroll = false;
|
|
||||||
clearInterval(this.intervalId);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
isScrolling: false,
|
|
||||||
toggleScroll() {
|
toggleScroll() {
|
||||||
this.alwaysScroll = !this.alwaysScroll;
|
this.alwaysScroll = !this.alwaysScroll;
|
||||||
if (this.alwaysScroll) {
|
if (this.alwaysScroll) {
|
||||||
this.intervalId = setInterval(() => {
|
this.intervalId = setInterval(() => {
|
||||||
const logsContainer = document.getElementById('logsContainer');
|
const logsContainer = document.getElementById('logsContainer');
|
||||||
if (logsContainer) {
|
if (logsContainer) {
|
||||||
this.isScrolling = true;
|
|
||||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||||
setTimeout(() => { this.isScrolling = false; }, 50);
|
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -37,17 +30,6 @@
|
||||||
this.intervalId = null;
|
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) {
|
matchesSearch(text) {
|
||||||
if (!this.searchQuery.trim()) return true;
|
if (!this.searchQuery.trim()) return true;
|
||||||
return text.toLowerCase().includes(this.searchQuery.toLowerCase());
|
return text.toLowerCase().includes(this.searchQuery.toLowerCase());
|
||||||
|
|
@ -134,6 +116,18 @@
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
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() {
|
init() {
|
||||||
// Re-render logs after Livewire updates
|
// Re-render logs after Livewire updates
|
||||||
document.addEventListener('livewire:navigated', () => {
|
document.addEventListener('livewire:navigated', () => {
|
||||||
|
|
@ -144,14 +138,19 @@
|
||||||
this.$nextTick(() => { this.renderTrigger++; });
|
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
|
// Start auto-scroll if deployment is in progress
|
||||||
if (this.alwaysScroll) {
|
if (this.alwaysScroll) {
|
||||||
this.intervalId = setInterval(() => {
|
this.intervalId = setInterval(() => {
|
||||||
const logsContainer = document.getElementById('logsContainer');
|
const logsContainer = document.getElementById('logsContainer');
|
||||||
if (logsContainer) {
|
if (logsContainer) {
|
||||||
this.isScrolling = true;
|
|
||||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||||
setTimeout(() => { this.isScrolling = false; }, 50);
|
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
@ -254,9 +253,9 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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="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 id="logs" class="flex flex-col font-mono">
|
||||||
<div x-show="searchQuery.trim() && getMatchCount() === 0"
|
<div x-show="searchQuery.trim() && getMatchCount() === 0"
|
||||||
class="text-gray-500 dark:text-gray-400 py-2">
|
class="text-gray-500 dark:text-gray-400 py-2">
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
alwaysScroll: false,
|
alwaysScroll: false,
|
||||||
intervalId: null,
|
intervalId: null,
|
||||||
|
scrollDebounce: null,
|
||||||
colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
|
colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
renderTrigger: 0,
|
|
||||||
containerName: '{{ $container ?? "logs" }}',
|
containerName: '{{ $container ?? "logs" }}',
|
||||||
makeFullscreen() {
|
makeFullscreen() {
|
||||||
this.fullscreen = !this.fullscreen;
|
this.fullscreen = !this.fullscreen;
|
||||||
|
|
@ -35,15 +35,22 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleScroll(event) {
|
handleScroll(event) {
|
||||||
|
// Skip if follow logs is disabled or this is a programmatic scroll
|
||||||
if (!this.alwaysScroll || this.isScrolling) return;
|
if (!this.alwaysScroll || this.isScrolling) return;
|
||||||
const el = event.target;
|
|
||||||
// Check if user scrolled away from the bottom
|
// Debounce scroll handling to avoid false positives from DOM mutations
|
||||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
// when Livewire re-renders and adds new log lines
|
||||||
if (distanceFromBottom > 50) {
|
clearTimeout(this.scrollDebounce);
|
||||||
this.alwaysScroll = false;
|
this.scrollDebounce = setTimeout(() => {
|
||||||
clearInterval(this.intervalId);
|
const el = event.target;
|
||||||
this.intervalId = null;
|
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() {
|
toggleColorLogs() {
|
||||||
this.colorLogs = !this.colorLogs;
|
this.colorLogs = !this.colorLogs;
|
||||||
|
|
@ -157,20 +164,14 @@
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
if (this.expanded) {
|
if (this.expanded) {
|
||||||
this.$wire.getLogs();
|
this.$wire.getLogs(true);
|
||||||
this.logsLoaded = true;
|
this.logsLoaded = true;
|
||||||
}
|
}
|
||||||
// Re-render logs after Livewire updates
|
|
||||||
Livewire.hook('commit', ({ succeed }) => {
|
|
||||||
succeed(() => {
|
|
||||||
this.$nextTick(() => { this.renderTrigger++; });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}">
|
}">
|
||||||
@if ($collapsible)
|
@if ($collapsible)
|
||||||
<div class="flex gap-2 items-center p-4 cursor-pointer select-none hover:bg-gray-50 dark:hover:bg-coolgray-200"
|
<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"
|
<svg class="w-4 h-4 transition-transform" :class="expanded ? 'rotate-90' : ''" viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
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" />
|
<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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<div x-show="expanded" {{ $collapsible ? 'x-collapse' : '' }}
|
<div x-show="expanded" {{ $collapsible ? 'x-collapse' : '' }}
|
||||||
:class="fullscreen ? 'fullscreen flex flex-col' : 'relative w-full {{ $collapsible ? 'py-4' : '' }} mx-auto'">
|
:class="fullscreen ? 'fullscreen flex flex-col !overflow-visible' : '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"
|
:style="fullscreen ? 'max-height: none !important; height: 100% !important;' : ''">
|
||||||
:class="fullscreen ? 'h-full' : 'border border-solid rounded-sm'">
|
<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
|
<div
|
||||||
class="flex items-center justify-between gap-2 px-4 py-2 border-b dark:border-coolgray-300 border-neutral-200 shrink-0">
|
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">
|
<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"
|
<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" />
|
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>
|
</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" />
|
class="input input-sm w-48 pl-8 pr-8 dark:bg-coolgray-300" />
|
||||||
<button x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
|
<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">
|
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="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]'">
|
:class="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
|
||||||
@if ($outputs)
|
@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">
|
<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"
|
<div x-show="searchQuery.trim() && getMatchCount() === 0"
|
||||||
class="text-gray-500 dark:text-gray-400 py-2">
|
class="text-gray-500 dark:text-gray-400 py-2">
|
||||||
No matches found.
|
No matches found.
|
||||||
</div>
|
</div>
|
||||||
@foreach (explode("\n", $outputs) as $line)
|
@foreach ($displayLines as $line)
|
||||||
@php
|
@php
|
||||||
// Skip empty lines
|
|
||||||
if (trim($line) === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse timestamp from log line (ISO 8601 format: 2025-12-04T11:48:39.136764033Z)
|
// Parse timestamp from log line (ISO 8601 format: 2025-12-04T11:48:39.136764033Z)
|
||||||
$timestamp = '';
|
$timestamp = '';
|
||||||
$logContent = $line;
|
$logContent = $line;
|
||||||
|
|
@ -352,14 +362,14 @@ class="flex gap-2">
|
||||||
<span class="shrink-0 text-gray-500">{{ $timestamp }}</span>
|
<span class="shrink-0 text-gray-500">{{ $timestamp }}</span>
|
||||||
@endif
|
@endif
|
||||||
<span data-line-text="{{ $logContent }}"
|
<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>
|
class="whitespace-pre-wrap break-all"></span>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<pre id="logs"
|
<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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@
|
||||||
<x-forms.input canGate="update" :canResource="$server" id="dynamicTimeout"
|
<x-forms.input canGate="update" :canResource="$server" id="dynamicTimeout"
|
||||||
label="Deployment timeout (seconds)" required
|
label="Deployment timeout (seconds)" required
|
||||||
helper="You can define the maximum duration for a deployment to run before timing it out." />
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Api\ApplicationsController;
|
use App\Http\Controllers\Api\ApplicationsController;
|
||||||
|
use App\Http\Controllers\Api\CloudProviderTokensController;
|
||||||
use App\Http\Controllers\Api\DatabasesController;
|
use App\Http\Controllers\Api\DatabasesController;
|
||||||
use App\Http\Controllers\Api\DeployController;
|
use App\Http\Controllers\Api\DeployController;
|
||||||
use App\Http\Controllers\Api\GithubController;
|
use App\Http\Controllers\Api\GithubController;
|
||||||
|
use App\Http\Controllers\Api\HetznerController;
|
||||||
use App\Http\Controllers\Api\OtherController;
|
use App\Http\Controllers\Api\OtherController;
|
||||||
use App\Http\Controllers\Api\ProjectController;
|
use App\Http\Controllers\Api\ProjectController;
|
||||||
use App\Http\Controllers\Api\ResourcesController;
|
use App\Http\Controllers\Api\ResourcesController;
|
||||||
|
|
@ -63,6 +65,13 @@
|
||||||
Route::patch('/security/keys/{uuid}', [SecurityController::class, 'update_key'])->middleware(['api.ability:write']);
|
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::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::match(['get', 'post'], '/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']);
|
||||||
Route::get('/deployments', [DeployController::class, 'deployments'])->middleware(['api.ability:read']);
|
Route::get('/deployments', [DeployController::class, 'deployments'])->middleware(['api.ability:read']);
|
||||||
Route::get('/deployments/{uuid}', [DeployController::class, 'deployment_by_uuid'])->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::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']);
|
||||||
Route::delete('/servers/{uuid}', [ServersController::class, 'delete_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('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']);
|
||||||
|
|
||||||
Route::get('/applications', [ApplicationsController::class, 'applications'])->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
|
if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
|
||||||
echo "docker-compose.custom.yml detected." >>"$LOGFILE"
|
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
|
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
|
fi
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
# ignore: true
|
|
||||||
# documentation: https://garagehq.deuxfleurs.fr/documentation/
|
# documentation: https://garagehq.deuxfleurs.fr/documentation/
|
||||||
# slogan: Garage is an S3-compatible distributed object storage service designed for self-hosting.
|
# slogan: Garage is an S3-compatible distributed object storage service designed for self-hosting.
|
||||||
# category: storage
|
# category: storage
|
||||||
|
|
|
||||||
|
|
@ -1399,6 +1399,23 @@
|
||||||
"minversion": "0.0.0",
|
"minversion": "0.0.0",
|
||||||
"port": "80"
|
"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": {
|
"getoutline": {
|
||||||
"documentation": "https://docs.getoutline.com/s/hosting/doc/hosting-outline-nipGaCRBDu?utm_source=coolify.io",
|
"documentation": "https://docs.getoutline.com/s/hosting/doc/hosting-outline-nipGaCRBDu?utm_source=coolify.io",
|
||||||
"slogan": "Your team\u2019s knowledge base",
|
"slogan": "Your team\u2019s knowledge base",
|
||||||
|
|
|
||||||
|
|
@ -1399,6 +1399,23 @@
|
||||||
"minversion": "0.0.0",
|
"minversion": "0.0.0",
|
||||||
"port": "80"
|
"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": {
|
"getoutline": {
|
||||||
"documentation": "https://docs.getoutline.com/s/hosting/doc/hosting-outline-nipGaCRBDu?utm_source=coolify.io",
|
"documentation": "https://docs.getoutline.com/s/hosting/doc/hosting-outline-nipGaCRBDu?utm_source=coolify.io",
|
||||||
"slogan": "Your team\u2019s knowledge base",
|
"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");
|
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": {
|
"coolify": {
|
||||||
"v4": {
|
"v4": {
|
||||||
"version": "4.0.0-beta.453"
|
"version": "4.0.0-beta.454"
|
||||||
},
|
},
|
||||||
"nightly": {
|
"nightly": {
|
||||||
"version": "4.0.0-beta.454"
|
"version": "4.0.0-beta.455"
|
||||||
},
|
},
|
||||||
"helper": {
|
"helper": {
|
||||||
"version": "1.0.12"
|
"version": "1.0.12"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue