v4.0.0-beta.454 (#7563)

This commit is contained in:
Andras Bacsai 2025-12-11 21:36:31 +01:00 committed by GitHub
commit 6b88481ce2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 4600 additions and 200 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -111,7 +111,9 @@ public function manual(Request $request)
commit: data_get($payload, 'after', 'HEAD'),
is_webhook: true,
);
if ($result['status'] === 'skipped') {
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
@ -197,7 +199,9 @@ public function manual(Request $request)
is_webhook: true,
git_type: 'github'
);
if ($result['status'] === 'skipped') {
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
@ -347,12 +351,15 @@ public function normal(Request $request)
force_rebuild: false,
is_webhook: true,
);
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
}
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
'deployment_uuid' => $result['deployment_uuid'] ?? null,
]);
} else {
$paths = str($application->watch_paths)->explode("\n");
@ -411,7 +418,9 @@ public function normal(Request $request)
is_webhook: true,
git_type: 'github'
);
if ($result['status'] === 'skipped') {
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',

View file

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

View file

@ -486,15 +486,38 @@ private function decide_what_to_do()
private function post_deployment()
{
GetContainersStatus::dispatch($this->server);
// Mark deployment as complete FIRST, before any other operations
// This ensures the deployment status is FINISHED even if subsequent operations fail
$this->completeDeployment();
// Then handle side effects - these should not fail the deployment
try {
GetContainersStatus::dispatch($this->server);
} catch (\Exception $e) {
\Log::warning('Failed to dispatch GetContainersStatus for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
if ($this->pull_request_id !== 0) {
if ($this->application->is_github_based()) {
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
try {
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
} catch (\Exception $e) {
\Log::warning('Failed to dispatch PR update for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
}
}
$this->run_post_deployment_command();
$this->application->isConfigurationChanged(true);
try {
$this->run_post_deployment_command();
} catch (\Exception $e) {
\Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage());
}
try {
$this->application->isConfigurationChanged(true);
} catch (\Exception $e) {
\Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
}
private function deploy_simple_dockerfile()
@ -3934,13 +3957,17 @@ private function transitionToStatus(ApplicationDeploymentStatus $status): void
}
/**
* Check if deployment is in a terminal state (FAILED or CANCELLED).
* Check if deployment is in a terminal state (FINISHED, FAILED or CANCELLED).
* Terminal states cannot be changed.
*/
private function isInTerminalState(): bool
{
$this->application_deployment_queue->refresh();
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FINISHED->value) {
return true;
}
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
return true;
}
@ -3980,6 +4007,15 @@ private function handleStatusTransition(ApplicationDeploymentStatus $status): vo
*/
private function handleSuccessfulDeployment(): void
{
// Reset restart count after successful deployment
// This is done here (not in Livewire) to avoid race conditions
// with GetContainersStatus reading old container restart counts
$this->application->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
event(new ApplicationConfigurationChanged($this->application->team()->id));
if (! $this->only_this_server) {

View file

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

View file

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

View file

@ -20,12 +20,11 @@ class Show extends Component
public bool $is_debug_enabled = false;
private bool $deploymentFinishedDispatched = false;
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
'refreshQueue',
];
}
@ -91,10 +90,15 @@ private function isKeepAliveOn()
public function polling()
{
$this->dispatch('deploymentFinished');
$this->application_deployment_queue->refresh();
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
$this->isKeepAliveOn();
// Dispatch event when deployment finishes to stop auto-scroll (only once)
if (! $this->isKeepAliveOn && ! $this->deploymentFinishedDispatched) {
$this->deploymentFinishedDispatched = true;
$this->dispatch('deploymentFinished');
}
}
public function getLogLinesProperty()

View file

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

View file

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

View file

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

View file

@ -89,6 +89,11 @@ public function redeploy(int $network_id, int $server_id)
only_this_server: true,
no_questions_asked: true,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);

View file

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

View file

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

View file

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

View file

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

View file

@ -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;
}
}
}

View file

@ -2,6 +2,7 @@
namespace App\Services;
use App\Exceptions\RateLimitException;
use Illuminate\Support\Facades\Http;
class HetznerService
@ -46,6 +47,19 @@ private function request(string $method, string $endpoint, array $data = [])
->{$method}($this->baseUrl.$endpoint, $data);
if (! $response->successful()) {
if ($response->status() === 429) {
$retryAfter = $response->header('Retry-After');
if ($retryAfter === null) {
$resetTime = $response->header('RateLimit-Reset');
$retryAfter = $resetTime ? max(0, (int) $resetTime - time()) : null;
}
throw new RateLimitException(
'Rate limit exceeded. Please try again later.',
$retryAfter !== null ? (int) $retryAfter : null
);
}
throw new \Exception('Hetzner API error: '.$response->json('error.message', 'Unknown error'));
}

View file

@ -140,9 +140,15 @@ public function execute_remote_command(...$commands)
// If we exhausted all retries and still failed
if (! $commandExecuted && $lastError) {
// Now we can set the status to FAILED since all retries have been exhausted
// But only if the deployment hasn't already been marked as FINISHED
if (isset($this->application_deployment_queue)) {
$this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value;
$this->application_deployment_queue->save();
// Avoid clobbering a deployment that may have just been marked FINISHED
$this->application_deployment_queue->newQuery()
->where('id', $this->application_deployment_queue->id)
->where('status', '!=', ApplicationDeploymentStatus::FINISHED->value)
->update([
'status' => ApplicationDeploymentStatus::FAILED->value,
]);
}
throw $lastError;
}

View file

@ -178,4 +178,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('use_build_server');
$request->offsetUnset('is_static');
$request->offsetUnset('force_domain_override');
$request->offsetUnset('autogenerate_domain');
}

View file

@ -28,6 +28,20 @@ function queue_application_deployment(Application $application, string $deployme
$destination_id = $destination->id;
}
// Check if the deployment queue is full for this server
$serverForQueueCheck = $server ?? Server::find($server_id);
$queue_limit = $serverForQueueCheck->settings->deployment_queue_limit ?? 25;
$queued_count = ApplicationDeploymentQueue::where('server_id', $server_id)
->where('status', ApplicationDeploymentStatus::QUEUED->value)
->count();
if ($queued_count >= $queue_limit) {
return [
'status' => 'queue_full',
'message' => 'Deployment queue is full. Please wait for existing deployments to complete.',
];
}
// Check if there's already a deployment in progress or queued for this application and commit
$existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id)
->where('commit', $commit)

View file

@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => '4.0.0-beta.453',
'version' => '4.0.0-beta.454',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),

View file

@ -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');
});
}
};

View file

@ -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');
});
}
};

View file

@ -15,6 +15,7 @@ class ApplicationSeeder extends Seeder
public function run(): void
{
Application::create([
'uuid' => 'docker-compose',
'name' => 'Docker Compose Example',
'repository_project_id' => 603035348,
'git_repository' => 'coollabsio/coolify-examples',
@ -30,6 +31,7 @@ public function run(): void
'source_type' => GithubApp::class,
]);
Application::create([
'uuid' => 'nodejs',
'name' => 'NodeJS Fastify Example',
'fqdn' => 'http://nodejs.127.0.0.1.sslip.io',
'repository_project_id' => 603035348,
@ -45,6 +47,7 @@ public function run(): void
'source_type' => GithubApp::class,
]);
Application::create([
'uuid' => 'dockerfile',
'name' => 'Dockerfile Example',
'fqdn' => 'http://dockerfile.127.0.0.1.sslip.io',
'repository_project_id' => 603035348,
@ -60,6 +63,7 @@ public function run(): void
'source_type' => GithubApp::class,
]);
Application::create([
'uuid' => 'dockerfile-pure',
'name' => 'Pure Dockerfile Example',
'fqdn' => 'http://pure-dockerfile.127.0.0.1.sslip.io',
'git_repository' => 'coollabsio/coolify',
@ -75,6 +79,23 @@ public function run(): void
'dockerfile' => 'FROM nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
',
]);
Application::create([
'uuid' => 'crashloop',
'name' => 'Crash Loop Example',
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'v4.x',
'git_commit_sha' => 'HEAD',
'build_pack' => 'dockerfile',
'ports_exposes' => '80',
'environment_id' => 1,
'destination_id' => 0,
'destination_type' => StandaloneDocker::class,
'source_id' => 0,
'source_type' => GithubApp::class,
'dockerfile' => 'FROM alpine
CMD ["sh", "-c", "echo Crashing in 5 seconds... && sleep 5 && exit 1"]
',
]);
}

View file

@ -14,6 +14,7 @@ public function run(): void
{
GithubApp::create([
'id' => 0,
'uuid' => 'github-public',
'name' => 'Public GitHub',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
@ -22,7 +23,7 @@ public function run(): void
]);
GithubApp::create([
'name' => 'coolify-laravel-dev-public',
'uuid' => '69420',
'uuid' => 'github-app',
'organization' => 'coollabsio',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',

View file

@ -14,6 +14,7 @@ public function run(): void
{
GitlabApp::create([
'id' => 1,
'uuid' => 'gitlab-public',
'name' => 'Public GitLab',
'api_url' => 'https://gitlab.com/api/v4',
'html_url' => 'https://gitlab.com',

View file

@ -13,6 +13,7 @@ class PrivateKeySeeder extends Seeder
public function run(): void
{
PrivateKey::create([
'uuid' => 'ssh',
'team_id' => 0,
'name' => 'Testing Host Key',
'description' => 'This is a test docker container',
@ -27,6 +28,7 @@ public function run(): void
]);
PrivateKey::create([
'uuid' => 'github-key',
'team_id' => 0,
'name' => 'development-github-app',
'description' => 'This is the key for using the development GitHub app',

View file

@ -9,10 +9,14 @@ class ProjectSeeder extends Seeder
{
public function run(): void
{
Project::create([
$project = Project::create([
'uuid' => 'project',
'name' => 'My first project',
'description' => 'This is a test project in development',
'team_id' => 0,
]);
// Update the auto-created environment with a deterministic UUID
$project->environments()->first()->update(['uuid' => 'production']);
}
}

View file

@ -13,6 +13,7 @@ class S3StorageSeeder extends Seeder
public function run(): void
{
S3Storage::create([
'uuid' => 'minio',
'name' => 'Local MinIO',
'description' => 'Local MinIO S3 Storage',
'key' => 'minioadmin',

View file

@ -13,6 +13,7 @@ public function run(): void
{
Server::create([
'id' => 0,
'uuid' => 'localhost',
'name' => 'localhost',
'description' => 'This is a test docker container in development mode',
'ip' => 'coolify-testing-host',

View file

@ -15,6 +15,7 @@ public function run(): void
if (StandaloneDocker::find(0) == null) {
StandaloneDocker::create([
'id' => 0,
'uuid' => 'docker',
'name' => 'Standalone Docker 1',
'network' => 'coolify',
'server_id' => 0,

View file

@ -11,6 +11,7 @@ class StandalonePostgresqlSeeder extends Seeder
public function run(): void
{
StandalonePostgresql::create([
'uuid' => 'postgresql',
'name' => 'Local PostgreSQL',
'description' => 'Local PostgreSQL for testing',
'postgres_password' => 'postgres',

View file

@ -361,6 +361,11 @@
"force_domain_override": {
"type": "boolean",
"description": "Force domain usage even if conflicts are detected. Default is false."
},
"autogenerate_domain": {
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
}
},
"type": "object"
@ -771,6 +776,11 @@
"force_domain_override": {
"type": "boolean",
"description": "Force domain usage even if conflicts are detected. Default is false."
},
"autogenerate_domain": {
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
}
},
"type": "object"
@ -1181,6 +1191,11 @@
"force_domain_override": {
"type": "boolean",
"description": "Force domain usage even if conflicts are detected. Default is false."
},
"autogenerate_domain": {
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
}
},
"type": "object"
@ -1520,6 +1535,11 @@
"force_domain_override": {
"type": "boolean",
"description": "Force domain usage even if conflicts are detected. Default is false."
},
"autogenerate_domain": {
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
}
},
"type": "object"
@ -1842,6 +1862,11 @@
"force_domain_override": {
"type": "boolean",
"description": "Force domain usage even if conflicts are detected. Default is false."
},
"autogenerate_domain": {
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
}
},
"type": "object"
@ -3275,6 +3300,387 @@
]
}
},
"\/cloud-tokens": {
"get": {
"tags": [
"Cloud Tokens"
],
"summary": "List Cloud Provider Tokens",
"description": "List all cloud provider tokens for the authenticated team.",
"operationId": "list-cloud-tokens",
"responses": {
"200": {
"description": "Get all cloud provider tokens.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"properties": {
"uuid": {
"type": "string"
},
"name": {
"type": "string"
},
"provider": {
"type": "string",
"enum": [
"hetzner",
"digitalocean"
]
},
"team_id": {
"type": "integer"
},
"servers_count": {
"type": "integer"
},
"created_at": {
"type": "string"
},
"updated_at": {
"type": "string"
}
},
"type": "object"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Cloud Tokens"
],
"summary": "Create Cloud Provider Token",
"description": "Create a new cloud provider token. The token will be validated before being stored.",
"operationId": "create-cloud-token",
"requestBody": {
"description": "Cloud provider token details",
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"provider",
"token",
"name"
],
"properties": {
"provider": {
"type": "string",
"enum": [
"hetzner",
"digitalocean"
],
"example": "hetzner",
"description": "The cloud provider."
},
"token": {
"type": "string",
"example": "your-api-token-here",
"description": "The API token for the cloud provider."
},
"name": {
"type": "string",
"example": "My Hetzner Token",
"description": "A friendly name for the token."
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "Cloud provider token created.",
"content": {
"application\/json": {
"schema": {
"properties": {
"uuid": {
"type": "string",
"example": "og888os",
"description": "The UUID of the token."
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/cloud-tokens\/{uuid}": {
"get": {
"tags": [
"Cloud Tokens"
],
"summary": "Get Cloud Provider Token",
"description": "Get cloud provider token by UUID.",
"operationId": "get-cloud-token-by-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "Token UUID",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Get cloud provider token by UUID",
"content": {
"application\/json": {
"schema": {
"properties": {
"uuid": {
"type": "string"
},
"name": {
"type": "string"
},
"provider": {
"type": "string"
},
"team_id": {
"type": "integer"
},
"servers_count": {
"type": "integer"
},
"created_at": {
"type": "string"
},
"updated_at": {
"type": "string"
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"delete": {
"tags": [
"Cloud Tokens"
],
"summary": "Delete Cloud Provider Token",
"description": "Delete cloud provider token by UUID. Cannot delete if token is used by any servers.",
"operationId": "delete-cloud-token-by-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the cloud provider token.",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Cloud provider token deleted.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Cloud provider token deleted."
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"patch": {
"tags": [
"Cloud Tokens"
],
"summary": "Update Cloud Provider Token",
"description": "Update cloud provider token name.",
"operationId": "update-cloud-token-by-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "Token UUID",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"description": "Cloud provider token updated.",
"required": true,
"content": {
"application\/json": {
"schema": {
"properties": {
"name": {
"type": "string",
"description": "The friendly name for the token."
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Cloud provider token updated.",
"content": {
"application\/json": {
"schema": {
"properties": {
"uuid": {
"type": "string"
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/cloud-tokens\/{uuid}\/validate": {
"post": {
"tags": [
"Cloud Tokens"
],
"summary": "Validate Cloud Provider Token",
"description": "Validate a cloud provider token against the provider API.",
"operationId": "validate-cloud-token-by-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "Token UUID",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Token validation result.",
"content": {
"application\/json": {
"schema": {
"properties": {
"valid": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Token is valid."
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/databases": {
"get": {
"tags": [
@ -6314,6 +6720,486 @@
]
}
},
"\/hetzner\/locations": {
"get": {
"tags": [
"Hetzner"
],
"summary": "Get Hetzner Locations",
"description": "Get all available Hetzner datacenter locations.",
"operationId": "get-hetzner-locations",
"parameters": [
{
"name": "cloud_provider_token_uuid",
"in": "query",
"description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "cloud_provider_token_id",
"in": "query",
"description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.",
"required": false,
"deprecated": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "List of Hetzner locations.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"country": {
"type": "string"
},
"city": {
"type": "string"
},
"latitude": {
"type": "number"
},
"longitude": {
"type": "number"
}
},
"type": "object"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/hetzner\/server-types": {
"get": {
"tags": [
"Hetzner"
],
"summary": "Get Hetzner Server Types",
"description": "Get all available Hetzner server types (instance sizes).",
"operationId": "get-hetzner-server-types",
"parameters": [
{
"name": "cloud_provider_token_uuid",
"in": "query",
"description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "cloud_provider_token_id",
"in": "query",
"description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.",
"required": false,
"deprecated": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "List of Hetzner server types.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"cores": {
"type": "integer"
},
"memory": {
"type": "number"
},
"disk": {
"type": "integer"
},
"prices": {
"type": "array",
"items": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "Datacenter location name"
},
"price_hourly": {
"type": "object",
"properties": {
"net": {
"type": "string"
},
"gross": {
"type": "string"
}
}
},
"price_monthly": {
"type": "object",
"properties": {
"net": {
"type": "string"
},
"gross": {
"type": "string"
}
}
}
}
}
}
},
"type": "object"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/hetzner\/images": {
"get": {
"tags": [
"Hetzner"
],
"summary": "Get Hetzner Images",
"description": "Get all available Hetzner system images (operating systems).",
"operationId": "get-hetzner-images",
"parameters": [
{
"name": "cloud_provider_token_uuid",
"in": "query",
"description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "cloud_provider_token_id",
"in": "query",
"description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.",
"required": false,
"deprecated": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "List of Hetzner images.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"type": {
"type": "string"
},
"os_flavor": {
"type": "string"
},
"os_version": {
"type": "string"
},
"architecture": {
"type": "string"
}
},
"type": "object"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/hetzner\/ssh-keys": {
"get": {
"tags": [
"Hetzner"
],
"summary": "Get Hetzner SSH Keys",
"description": "Get all SSH keys stored in the Hetzner account.",
"operationId": "get-hetzner-ssh-keys",
"parameters": [
{
"name": "cloud_provider_token_uuid",
"in": "query",
"description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "cloud_provider_token_id",
"in": "query",
"description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.",
"required": false,
"deprecated": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "List of Hetzner SSH keys.",
"content": {
"application\/json": {
"schema": {
"type": "array",
"items": {
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"fingerprint": {
"type": "string"
},
"public_key": {
"type": "string"
}
},
"type": "object"
}
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/servers\/hetzner": {
"post": {
"tags": [
"Hetzner"
],
"summary": "Create Hetzner Server",
"description": "Create a new server on Hetzner and register it in Coolify.",
"operationId": "create-hetzner-server",
"requestBody": {
"description": "Hetzner server creation parameters",
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"location",
"server_type",
"image",
"private_key_uuid"
],
"properties": {
"cloud_provider_token_uuid": {
"type": "string",
"example": "abc123",
"description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided."
},
"cloud_provider_token_id": {
"type": "string",
"example": "abc123",
"description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.",
"deprecated": true
},
"location": {
"type": "string",
"example": "nbg1",
"description": "Hetzner location name"
},
"server_type": {
"type": "string",
"example": "cx11",
"description": "Hetzner server type name"
},
"image": {
"type": "integer",
"example": 15512617,
"description": "Hetzner image ID"
},
"name": {
"type": "string",
"example": "my-server",
"description": "Server name (auto-generated if not provided)"
},
"private_key_uuid": {
"type": "string",
"example": "xyz789",
"description": "Private key UUID"
},
"enable_ipv4": {
"type": "boolean",
"example": true,
"description": "Enable IPv4 (default: true)"
},
"enable_ipv6": {
"type": "boolean",
"example": true,
"description": "Enable IPv6 (default: true)"
},
"hetzner_ssh_key_ids": {
"type": "array",
"items": {
"type": "integer"
},
"description": "Additional Hetzner SSH key IDs"
},
"cloud_init_script": {
"type": "string",
"description": "Cloud-init YAML script (optional)"
},
"instant_validate": {
"type": "boolean",
"example": false,
"description": "Validate server immediately after creation"
}
},
"type": "object"
}
}
}
},
"responses": {
"201": {
"description": "Hetzner server created.",
"content": {
"application\/json": {
"schema": {
"properties": {
"uuid": {
"type": "string",
"example": "og888os",
"description": "The UUID of the server."
},
"hetzner_server_id": {
"type": "integer",
"description": "The Hetzner server ID."
},
"ip": {
"type": "string",
"description": "The server IP address."
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
},
"429": {
"$ref": "#\/components\/responses\/429"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/version": {
"get": {
"summary": "Version",
@ -9816,6 +10702,9 @@
"concurrent_builds": {
"type": "integer"
},
"deployment_queue_limit": {
"type": "integer"
},
"dynamic_timeout": {
"type": "integer"
},
@ -10174,6 +11063,31 @@
}
}
}
},
"429": {
"description": "Rate limit exceeded.",
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying.",
"schema": {
"type": "integer",
"example": 60
}
}
},
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Rate limit exceeded. Please try again later."
}
},
"type": "object"
}
}
}
}
},
"securitySchemes": {
@ -10189,6 +11103,10 @@
"name": "Applications",
"description": "Applications"
},
{
"name": "Cloud Tokens",
"description": "Cloud Tokens"
},
{
"name": "Databases",
"description": "Databases"
@ -10201,6 +11119,10 @@
"name": "GitHub Apps",
"description": "GitHub Apps"
},
{
"name": "Hetzner",
"description": "Hetzner"
},
{
"name": "Projects",
"description": "Projects"

View file

@ -265,6 +265,10 @@ paths:
force_domain_override:
type: boolean
description: 'Force domain usage even if conflicts are detected. Default is false.'
autogenerate_domain:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
type: object
responses:
'201':
@ -531,6 +535,10 @@ paths:
force_domain_override:
type: boolean
description: 'Force domain usage even if conflicts are detected. Default is false.'
autogenerate_domain:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
type: object
responses:
'201':
@ -797,6 +805,10 @@ paths:
force_domain_override:
type: boolean
description: 'Force domain usage even if conflicts are detected. Default is false.'
autogenerate_domain:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
type: object
responses:
'201':
@ -1010,6 +1022,10 @@ paths:
force_domain_override:
type: boolean
description: 'Force domain usage even if conflicts are detected. Default is false.'
autogenerate_domain:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
type: object
responses:
'201':
@ -1214,6 +1230,10 @@ paths:
force_domain_override:
type: boolean
description: 'Force domain usage even if conflicts are detected. Default is false.'
autogenerate_domain:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
type: object
responses:
'201':
@ -2075,6 +2095,224 @@ paths:
security:
-
bearerAuth: []
/cloud-tokens:
get:
tags:
- 'Cloud Tokens'
summary: 'List Cloud Provider Tokens'
description: 'List all cloud provider tokens for the authenticated team.'
operationId: list-cloud-tokens
responses:
'200':
description: 'Get all cloud provider tokens.'
content:
application/json:
schema:
type: array
items:
properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
security:
-
bearerAuth: []
post:
tags:
- 'Cloud Tokens'
summary: 'Create Cloud Provider Token'
description: 'Create a new cloud provider token. The token will be validated before being stored.'
operationId: create-cloud-token
requestBody:
description: 'Cloud provider token details'
required: true
content:
application/json:
schema:
required:
- provider
- token
- name
properties:
provider:
type: string
enum: [hetzner, digitalocean]
example: hetzner
description: 'The cloud provider.'
token:
type: string
example: your-api-token-here
description: 'The API token for the cloud provider.'
name:
type: string
example: 'My Hetzner Token'
description: 'A friendly name for the token.'
type: object
responses:
'201':
description: 'Cloud provider token created.'
content:
application/json:
schema:
properties:
uuid: { type: string, example: og888os, description: 'The UUID of the token.' }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/cloud-tokens/{uuid}':
get:
tags:
- 'Cloud Tokens'
summary: 'Get Cloud Provider Token'
description: 'Get cloud provider token by UUID.'
operationId: get-cloud-token-by-uuid
parameters:
-
name: uuid
in: path
description: 'Token UUID'
required: true
schema:
type: string
responses:
'200':
description: 'Get cloud provider token by UUID'
content:
application/json:
schema:
properties:
uuid: { type: string }
name: { type: string }
provider: { type: string }
team_id: { type: integer }
servers_count: { type: integer }
created_at: { type: string }
updated_at: { type: string }
type: object
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
delete:
tags:
- 'Cloud Tokens'
summary: 'Delete Cloud Provider Token'
description: 'Delete cloud provider token by UUID. Cannot delete if token is used by any servers.'
operationId: delete-cloud-token-by-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the cloud provider token.'
required: true
schema:
type: string
format: uuid
responses:
'200':
description: 'Cloud provider token deleted.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'Cloud provider token deleted.' }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
patch:
tags:
- 'Cloud Tokens'
summary: 'Update Cloud Provider Token'
description: 'Update cloud provider token name.'
operationId: update-cloud-token-by-uuid
parameters:
-
name: uuid
in: path
description: 'Token UUID'
required: true
schema:
type: string
requestBody:
description: 'Cloud provider token updated.'
required: true
content:
application/json:
schema:
properties:
name:
type: string
description: 'The friendly name for the token.'
type: object
responses:
'200':
description: 'Cloud provider token updated.'
content:
application/json:
schema:
properties:
uuid: { type: string }
type: object
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/cloud-tokens/{uuid}/validate':
post:
tags:
- 'Cloud Tokens'
summary: 'Validate Cloud Provider Token'
description: 'Validate a cloud provider token against the provider API.'
operationId: validate-cloud-token-by-uuid
parameters:
-
name: uuid
in: path
description: 'Token UUID'
required: true
schema:
type: string
responses:
'200':
description: 'Token validation result.'
content:
application/json:
schema:
properties:
valid: { type: boolean, example: true }
message: { type: string, example: 'Token is valid.' }
type: object
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/databases:
get:
tags:
@ -4084,6 +4322,258 @@ paths:
security:
-
bearerAuth: []
/hetzner/locations:
get:
tags:
- Hetzner
summary: 'Get Hetzner Locations'
description: 'Get all available Hetzner datacenter locations.'
operationId: get-hetzner-locations
parameters:
-
name: cloud_provider_token_uuid
in: query
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'
required: false
schema:
type: string
-
name: cloud_provider_token_id
in: query
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.'
required: false
deprecated: true
schema:
type: string
responses:
'200':
description: 'List of Hetzner locations.'
content:
application/json:
schema:
type: array
items:
properties: { id: { type: integer }, name: { type: string }, description: { type: string }, country: { type: string }, city: { type: string }, latitude: { type: number }, longitude: { type: number } }
type: object
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/hetzner/server-types:
get:
tags:
- Hetzner
summary: 'Get Hetzner Server Types'
description: 'Get all available Hetzner server types (instance sizes).'
operationId: get-hetzner-server-types
parameters:
-
name: cloud_provider_token_uuid
in: query
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'
required: false
schema:
type: string
-
name: cloud_provider_token_id
in: query
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.'
required: false
deprecated: true
schema:
type: string
responses:
'200':
description: 'List of Hetzner server types.'
content:
application/json:
schema:
type: array
items:
properties: { id: { type: integer }, name: { type: string }, description: { type: string }, cores: { type: integer }, memory: { type: number }, disk: { type: integer }, prices: { type: array, items: { type: object, properties: { location: { type: string, description: 'Datacenter location name' }, price_hourly: { type: object, properties: { net: { type: string }, gross: { type: string } } }, price_monthly: { type: object, properties: { net: { type: string }, gross: { type: string } } } } } } }
type: object
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/hetzner/images:
get:
tags:
- Hetzner
summary: 'Get Hetzner Images'
description: 'Get all available Hetzner system images (operating systems).'
operationId: get-hetzner-images
parameters:
-
name: cloud_provider_token_uuid
in: query
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'
required: false
schema:
type: string
-
name: cloud_provider_token_id
in: query
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.'
required: false
deprecated: true
schema:
type: string
responses:
'200':
description: 'List of Hetzner images.'
content:
application/json:
schema:
type: array
items:
properties: { id: { type: integer }, name: { type: string }, description: { type: string }, type: { type: string }, os_flavor: { type: string }, os_version: { type: string }, architecture: { type: string } }
type: object
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/hetzner/ssh-keys:
get:
tags:
- Hetzner
summary: 'Get Hetzner SSH Keys'
description: 'Get all SSH keys stored in the Hetzner account.'
operationId: get-hetzner-ssh-keys
parameters:
-
name: cloud_provider_token_uuid
in: query
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'
required: false
schema:
type: string
-
name: cloud_provider_token_id
in: query
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.'
required: false
deprecated: true
schema:
type: string
responses:
'200':
description: 'List of Hetzner SSH keys.'
content:
application/json:
schema:
type: array
items:
properties: { id: { type: integer }, name: { type: string }, fingerprint: { type: string }, public_key: { type: string } }
type: object
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/servers/hetzner:
post:
tags:
- Hetzner
summary: 'Create Hetzner Server'
description: 'Create a new server on Hetzner and register it in Coolify.'
operationId: create-hetzner-server
requestBody:
description: 'Hetzner server creation parameters'
required: true
content:
application/json:
schema:
required:
- location
- server_type
- image
- private_key_uuid
properties:
cloud_provider_token_uuid:
type: string
example: abc123
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'
cloud_provider_token_id:
type: string
example: abc123
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.'
deprecated: true
location:
type: string
example: nbg1
description: 'Hetzner location name'
server_type:
type: string
example: cx11
description: 'Hetzner server type name'
image:
type: integer
example: 15512617
description: 'Hetzner image ID'
name:
type: string
example: my-server
description: 'Server name (auto-generated if not provided)'
private_key_uuid:
type: string
example: xyz789
description: 'Private key UUID'
enable_ipv4:
type: boolean
example: true
description: 'Enable IPv4 (default: true)'
enable_ipv6:
type: boolean
example: true
description: 'Enable IPv6 (default: true)'
hetzner_ssh_key_ids:
type: array
items: { type: integer }
description: 'Additional Hetzner SSH key IDs'
cloud_init_script:
type: string
description: 'Cloud-init YAML script (optional)'
instant_validate:
type: boolean
example: false
description: 'Validate server immediately after creation'
type: object
responses:
'201':
description: 'Hetzner server created.'
content:
application/json:
schema:
properties:
uuid: { type: string, example: og888os, description: 'The UUID of the server.' }
hetzner_server_id: { type: integer, description: 'The Hetzner server ID.' }
ip: { type: string, description: 'The server IP address.' }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
'429':
$ref: '#/components/responses/429'
security:
-
bearerAuth: []
/version:
get:
summary: Version
@ -6312,6 +6802,8 @@ components:
type: integer
concurrent_builds:
type: integer
deployment_queue_limit:
type: integer
dynamic_timeout:
type: integer
force_disabled:
@ -6556,6 +7048,22 @@ components:
type: array
items: { type: string }
type: object
'429':
description: 'Rate limit exceeded.'
headers:
Retry-After:
description: 'Number of seconds to wait before retrying.'
schema:
type: integer
example: 60
content:
application/json:
schema:
properties:
message:
type: string
example: 'Rate limit exceeded. Please try again later.'
type: object
securitySchemes:
bearerAuth:
type: http
@ -6565,6 +7073,9 @@ tags:
-
name: Applications
description: Applications
-
name: 'Cloud Tokens'
description: 'Cloud Tokens'
-
name: Databases
description: Databases
@ -6574,6 +7085,9 @@ tags:
-
name: 'GitHub Apps'
description: 'GitHub Apps'
-
name: Hetzner
description: Hetzner
-
name: Projects
description: Projects

View file

@ -66,7 +66,7 @@ fi
if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
echo "docker-compose.custom.yml detected." >>"$LOGFILE"
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60" >>"$LOGFILE" 2>&1
else
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60" >>"$LOGFILE" 2>&1
fi

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.453"
"version": "4.0.0-beta.454"
},
"nightly": {
"version": "4.0.0-beta.454"
"version": "4.0.0-beta.455"
},
"helper": {
"version": "1.0.12"

View file

@ -1,16 +1,17 @@
<div wire:poll.3000ms x-data="{
expanded: @entangle('expanded')
expanded: @entangle('expanded'),
reduceOpacity: @js($this->shouldReduceOpacity)
}" class="fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4">
@if ($this->deploymentCount > 0)
<div class="relative">
<div class="relative transition-opacity duration-200"
:class="{ 'opacity-100': expanded || !reduceOpacity, 'opacity-60 hover:opacity-100': reduceOpacity && !expanded }">
<!-- Indicator Button -->
<button @click="expanded = !expanded"
class="flex items-center gap-2 px-4 py-2 rounded-lg shadow-lg transition-all duration-200 dark:bg-coolgray-100 bg-white dark:border dark:border-coolgray-200 hover:shadow-xl">
<!-- Animated spinner -->
<svg class="w-4 h-4 text-coollabs dark:text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<svg class="w-4 h-4 text-coollabs dark:text-warning animate-spin" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
@ -23,8 +24,8 @@ class="flex items-center gap-2 px-4 py-2 rounded-lg shadow-lg transition-all dur
<!-- Expand/collapse icon -->
<svg class="w-4 h-4 transition-transform duration-200 dark:text-neutral-400 text-gray-600"
:class="{ 'rotate-180': expanded }" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
:class="{ 'rotate-180': expanded }" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
@ -32,9 +33,8 @@ class="flex items-center gap-2 px-4 py-2 rounded-lg shadow-lg transition-all dur
<!-- Expanded deployment list -->
<div x-show="expanded" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0" x-transition:leave-end="opacity-0 translate-y-2"
x-cloak
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-2" x-cloak
class="absolute bottom-full mb-2 w-80 max-h-96 overflow-y-auto rounded-lg shadow-xl dark:bg-coolgray-100 bg-white dark:border dark:border-coolgray-200">
<div class="p-4 space-y-3">
@ -46,16 +46,15 @@ class="flex items-start gap-3 p-3 rounded-lg dark:bg-coolgray-200 bg-gray-50 tra
@if ($deployment->status === 'in_progress')
<svg class="w-4 h-4 text-coollabs dark:text-warning animate-spin"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
</circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
@else
<svg class="w-4 h-4 dark:text-neutral-400 text-gray-500"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<svg class="w-4 h-4 dark:text-neutral-400 text-gray-500" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@ -68,7 +67,8 @@ class="flex items-start gap-3 p-3 rounded-lg dark:bg-coolgray-200 bg-gray-50 tra
{{ $deployment->application_name }}
</div>
<p class="text-xs dark:text-neutral-400 text-gray-600 mt-1">
{{ $deployment->application?->environment?->project?->name }} / {{ $deployment->application?->environment?->name }}
{{ $deployment->application?->environment?->project?->name }} /
{{ $deployment->application?->environment?->name }}
</p>
<p class="text-xs dark:text-neutral-400 text-gray-600">
{{ $deployment->server_name }}
@ -78,11 +78,10 @@ class="flex items-start gap-3 p-3 rounded-lg dark:bg-coolgray-200 bg-gray-50 tra
PR #{{ $deployment->pull_request_id }}
</p>
@endif
<p class="text-xs mt-1 capitalize"
:class="{
'text-coollabs dark:text-warning': '{{ $deployment->status }}' === 'in_progress',
'dark:text-neutral-400 text-gray-500': '{{ $deployment->status }}' === 'queued'
}">
<p class="text-xs mt-1 capitalize" :class="{
'text-coollabs dark:text-warning': '{{ $deployment->status }}' === 'in_progress',
'dark:text-neutral-400 text-gray-500': '{{ $deployment->status }}' === 'queued'
}">
{{ str_replace('_', ' ', $deployment->status) }}
</p>
</div>
@ -92,4 +91,4 @@ class="flex items-start gap-3 p-3 rounded-lg dark:bg-coolgray-200 bg-gray-50 tra
</div>
</div>
@endif
</div>
</div>

View file

@ -13,12 +13,10 @@
helper="Allow to automatically deploy Preview Deployments for all opened PR's.<br><br>Closing a PR will delete Preview Deployments."
instantSave id="isPreviewDeploymentsEnabled" label="Preview Deployments" canGate="update"
:canResource="$application" />
@if ($isPreviewDeploymentsEnabled)
<x-forms.checkbox
helper="When enabled, anyone can trigger PR deployments. When disabled, only repository members, collaborators, and contributors can trigger PR deployments."
instantSave id="isPrDeploymentsPublicEnabled" label="Allow Public PR Deployments" canGate="update"
:canResource="$application" />
@endif
<x-forms.checkbox
helper="When enabled, anyone can trigger PR deployments. When disabled, only repository members, collaborators, and contributors can trigger PR deployments."
instantSave id="isPrDeploymentsPublicEnabled" label="Allow Public PR Deployments" canGate="update"
:canResource="$application" :disabled="!$isPreviewDeploymentsEnabled" />
@endif
<x-forms.checkbox helper="Disable Docker build cache on every deployment." instantSave
id="disableBuildCache" label="Disable Build Cache" canGate="update" :canResource="$application" />

View file

@ -15,21 +15,14 @@
deploymentId: '{{ $application_deployment_queue->deployment_uuid ?? 'deployment' }}',
makeFullscreen() {
this.fullscreen = !this.fullscreen;
if (this.fullscreen === false) {
this.alwaysScroll = false;
clearInterval(this.intervalId);
}
},
isScrolling: false,
toggleScroll() {
this.alwaysScroll = !this.alwaysScroll;
if (this.alwaysScroll) {
this.intervalId = setInterval(() => {
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
this.isScrolling = true;
logsContainer.scrollTop = logsContainer.scrollHeight;
setTimeout(() => { this.isScrolling = false; }, 50);
}
}, 100);
} else {
@ -37,17 +30,6 @@
this.intervalId = null;
}
},
handleScroll(event) {
if (!this.alwaysScroll || this.isScrolling) return;
const el = event.target;
// Check if user scrolled away from the bottom
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distanceFromBottom > 50) {
this.alwaysScroll = false;
clearInterval(this.intervalId);
this.intervalId = null;
}
},
matchesSearch(text) {
if (!this.searchQuery.trim()) return true;
return text.toLowerCase().includes(this.searchQuery.toLowerCase());
@ -134,6 +116,18 @@
a.click();
URL.revokeObjectURL(url);
},
stopScroll() {
// Scroll to the end one final time before disabling
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
logsContainer.scrollTop = logsContainer.scrollHeight;
}
this.alwaysScroll = false;
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
},
init() {
// Re-render logs after Livewire updates
document.addEventListener('livewire:navigated', () => {
@ -144,14 +138,19 @@
this.$nextTick(() => { this.renderTrigger++; });
});
});
// Stop auto-scroll when deployment finishes
Livewire.on('deploymentFinished', () => {
// Wait for DOM to update with final logs before scrolling to end
setTimeout(() => {
this.stopScroll();
}, 500);
});
// Start auto-scroll if deployment is in progress
if (this.alwaysScroll) {
this.intervalId = setInterval(() => {
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
this.isScrolling = true;
logsContainer.scrollTop = logsContainer.scrollHeight;
setTimeout(() => { this.isScrolling = false; }, 50);
}
}, 100);
}
@ -254,9 +253,9 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
</button>
</div>
</div>
<div id="logsContainer" @scroll="handleScroll"
<div id="logsContainer"
class="flex flex-col overflow-y-auto p-2 px-4 min-h-4 scrollbar"
:class="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
:class="fullscreen ? 'flex-1' : 'max-h-[30rem]'">
<div id="logs" class="flex flex-col font-mono">
<div x-show="searchQuery.trim() && getMatchCount() === 0"
class="text-gray-500 dark:text-gray-400 py-2">

View file

@ -6,9 +6,9 @@
fullscreen: false,
alwaysScroll: false,
intervalId: null,
scrollDebounce: null,
colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
searchQuery: '',
renderTrigger: 0,
containerName: '{{ $container ?? "logs" }}',
makeFullscreen() {
this.fullscreen = !this.fullscreen;
@ -35,15 +35,22 @@
}
},
handleScroll(event) {
// Skip if follow logs is disabled or this is a programmatic scroll
if (!this.alwaysScroll || this.isScrolling) return;
const el = event.target;
// Check if user scrolled away from the bottom
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distanceFromBottom > 50) {
this.alwaysScroll = false;
clearInterval(this.intervalId);
this.intervalId = null;
}
// Debounce scroll handling to avoid false positives from DOM mutations
// when Livewire re-renders and adds new log lines
clearTimeout(this.scrollDebounce);
this.scrollDebounce = setTimeout(() => {
const el = event.target;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
// Use larger threshold (100px) to avoid accidental disables
if (distanceFromBottom > 100) {
this.alwaysScroll = false;
clearInterval(this.intervalId);
this.intervalId = null;
}
}, 150);
},
toggleColorLogs() {
this.colorLogs = !this.colorLogs;
@ -157,20 +164,14 @@
},
init() {
if (this.expanded) {
this.$wire.getLogs();
this.$wire.getLogs(true);
this.logsLoaded = true;
}
// Re-render logs after Livewire updates
Livewire.hook('commit', ({ succeed }) => {
succeed(() => {
this.$nextTick(() => { this.renderTrigger++; });
});
});
}
}">
@if ($collapsible)
<div class="flex gap-2 items-center p-4 cursor-pointer select-none hover:bg-gray-50 dark:hover:bg-coolgray-200"
x-on:click="expanded = !expanded; if (expanded && !logsLoaded) { $wire.getLogs(); logsLoaded = true; }">
x-on:click="expanded = !expanded; if (expanded && !logsLoaded) { $wire.getLogs(true); logsLoaded = true; }">
<svg class="w-4 h-4 transition-transform" :class="expanded ? 'rotate-90' : ''" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
@ -191,9 +192,10 @@
</div>
@endif
<div x-show="expanded" {{ $collapsible ? 'x-collapse' : '' }}
:class="fullscreen ? 'fullscreen flex flex-col' : 'relative w-full {{ $collapsible ? 'py-4' : '' }} mx-auto'">
<div class="flex flex-col bg-white dark:text-white dark:bg-coolgray-100 dark:border-coolgray-300 border-neutral-200"
:class="fullscreen ? 'h-full' : 'border border-solid rounded-sm'">
:class="fullscreen ? 'fullscreen flex flex-col !overflow-visible' : 'relative w-full {{ $collapsible ? 'py-4' : '' }} mx-auto'"
:style="fullscreen ? 'max-height: none !important; height: 100% !important;' : ''">
<div class="flex flex-col dark:text-white dark:border-coolgray-300 border-neutral-200"
:class="fullscreen ? 'h-full w-full bg-white dark:bg-coolgray-100' : 'bg-white dark:bg-coolgray-100 border border-solid rounded-sm'">
<div
class="flex items-center justify-between gap-2 px-4 py-2 border-b dark:border-coolgray-300 border-neutral-200 shrink-0">
<div class="flex items-center gap-2">
@ -215,7 +217,7 @@ class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
<path stroke-linecap="round" stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<input type="text" x-model="searchQuery" placeholder="Find in logs"
<input type="text" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
class="input input-sm w-48 pl-8 pr-8 dark:bg-coolgray-300" />
<button x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
@ -307,18 +309,26 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
class="flex overflow-y-auto overflow-x-hidden flex-col px-4 py-2 w-full min-w-0 scrollbar"
:class="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
@if ($outputs)
@php
// Limit rendered lines to prevent memory exhaustion
$maxDisplayLines = 2000;
$allLines = collect(explode("\n", $outputs))->filter(fn($line) => trim($line) !== '');
$totalLines = $allLines->count();
$hasMoreLines = $totalLines > $maxDisplayLines;
$displayLines = $hasMoreLines ? $allLines->slice(-$maxDisplayLines)->values() : $allLines;
@endphp
<div id="logs" class="font-mono max-w-full cursor-default">
@if ($hasMoreLines)
<div class="text-center py-2 text-gray-500 dark:text-gray-400 text-sm border-b dark:border-coolgray-300 mb-2">
Showing last {{ number_format($maxDisplayLines) }} of {{ number_format($totalLines) }} lines
</div>
@endif
<div x-show="searchQuery.trim() && getMatchCount() === 0"
class="text-gray-500 dark:text-gray-400 py-2">
No matches found.
</div>
@foreach (explode("\n", $outputs) as $line)
@foreach ($displayLines as $line)
@php
// Skip empty lines
if (trim($line) === '') {
continue;
}
// Parse timestamp from log line (ISO 8601 format: 2025-12-04T11:48:39.136764033Z)
$timestamp = '';
$logContent = $line;
@ -352,14 +362,14 @@ class="flex gap-2">
<span class="shrink-0 text-gray-500">{{ $timestamp }}</span>
@endif
<span data-line-text="{{ $logContent }}"
x-effect="renderTrigger; searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"
x-effect="searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"
class="whitespace-pre-wrap break-all"></span>
</div>
@endforeach
</div>
@else
<pre id="logs"
class="font-mono whitespace-pre-wrap break-all max-w-full">Refresh to get the logs...</pre>
class="font-mono whitespace-pre-wrap break-all max-w-full text-neutral-400">No logs yet.</pre>
@endif
</div>
</div>

View file

@ -36,6 +36,9 @@
<x-forms.input canGate="update" :canResource="$server" id="dynamicTimeout"
label="Deployment timeout (seconds)" required
helper="You can define the maximum duration for a deployment to run before timing it out." />
<x-forms.input canGate="update" :canResource="$server" id="deploymentQueueLimit"
label="Deployment queue limit" required
helper="Maximum number of queued deployments allowed. New deployments will be rejected with a 429 status when the limit is reached." />
</div>
</div>
</div>

View file

@ -1,9 +1,11 @@
<?php
use App\Http\Controllers\Api\ApplicationsController;
use App\Http\Controllers\Api\CloudProviderTokensController;
use App\Http\Controllers\Api\DatabasesController;
use App\Http\Controllers\Api\DeployController;
use App\Http\Controllers\Api\GithubController;
use App\Http\Controllers\Api\HetznerController;
use App\Http\Controllers\Api\OtherController;
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\ResourcesController;
@ -63,6 +65,13 @@
Route::patch('/security/keys/{uuid}', [SecurityController::class, 'update_key'])->middleware(['api.ability:write']);
Route::delete('/security/keys/{uuid}', [SecurityController::class, 'delete_key'])->middleware(['api.ability:write']);
Route::get('/cloud-tokens', [CloudProviderTokensController::class, 'index'])->middleware(['api.ability:read']);
Route::post('/cloud-tokens', [CloudProviderTokensController::class, 'store'])->middleware(['api.ability:write']);
Route::get('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'show'])->middleware(['api.ability:read']);
Route::patch('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'update'])->middleware(['api.ability:write']);
Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:write']);
Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:read']);
Route::match(['get', 'post'], '/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']);
Route::get('/deployments', [DeployController::class, 'deployments'])->middleware(['api.ability:read']);
Route::get('/deployments/{uuid}', [DeployController::class, 'deployment_by_uuid'])->middleware(['api.ability:read']);
@ -80,6 +89,12 @@
Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']);
Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']);
Route::get('/hetzner/locations', [HetznerController::class, 'locations'])->middleware(['api.ability:read']);
Route::get('/hetzner/server-types', [HetznerController::class, 'serverTypes'])->middleware(['api.ability:read']);
Route::get('/hetzner/images', [HetznerController::class, 'images'])->middleware(['api.ability:read']);
Route::get('/hetzner/ssh-keys', [HetznerController::class, 'sshKeys'])->middleware(['api.ability:read']);
Route::post('/servers/hetzner', [HetznerController::class, 'createServer'])->middleware(['api.ability:write']);
Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']);
Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']);

View file

@ -66,7 +66,7 @@ fi
if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
echo "docker-compose.custom.yml detected." >>"$LOGFILE"
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60" >>"$LOGFILE" 2>&1
else
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60" >>"$LOGFILE" 2>&1
fi

View file

@ -1,4 +1,3 @@
# ignore: true
# documentation: https://garagehq.deuxfleurs.fr/documentation/
# slogan: Garage is an S3-compatible distributed object storage service designed for self-hosting.
# category: storage

View file

@ -1399,6 +1399,23 @@
"minversion": "0.0.0",
"port": "80"
},
"garage": {
"documentation": "https://garagehq.deuxfleurs.fr/documentation/?utm_source=coolify.io",
"slogan": "Garage is an S3-compatible distributed object storage service designed for self-hosting.",
"compose": "c2VydmljZXM6CiAgZ2FyYWdlOgogICAgaW1hZ2U6ICdkeGZscnMvZ2FyYWdlOnYyLjEuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIEdBUkFHRV9TM19BUElfVVJMPSRHQVJBR0VfUzNfQVBJX1VSTAogICAgICAtIEdBUkFHRV9XRUJfVVJMPSRHQVJBR0VfV0VCX1VSTAogICAgICAtIEdBUkFHRV9BRE1JTl9VUkw9JEdBUkFHRV9BRE1JTl9VUkwKICAgICAgLSAnR0FSQUdFX1JQQ19TRUNSRVQ9JHtTRVJWSUNFX0hFWF8zMl9SUENTRUNSRVR9JwogICAgICAtIEdBUkFHRV9BRE1JTl9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0UKICAgICAgLSBHQVJBR0VfTUVUUklDU19UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0VNRVRSSUNTCiAgICAgIC0gR0FSQUdFX0FMTE9XX1dPUkxEX1JFQURBQkxFX1NFQ1JFVFM9dHJ1ZQogICAgICAtICdSVVNUX0xPRz0ke1JVU1RfTE9HOi1nYXJhZ2U9aW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdnYXJhZ2UtbWV0YTovdmFyL2xpYi9nYXJhZ2UvbWV0YScKICAgICAgLSAnZ2FyYWdlLWRhdGE6L3Zhci9saWIvZ2FyYWdlL2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2dhcmFnZS50b21sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2dhcmFnZS50b21sCiAgICAgICAgY29udGVudDogIm1ldGFkYXRhX2RpciA9IFwiL3Zhci9saWIvZ2FyYWdlL21ldGFcIlxuZGF0YV9kaXIgPSBcIi92YXIvbGliL2dhcmFnZS9kYXRhXCJcbmRiX2VuZ2luZSA9IFwibG1kYlwiXG5cbnJlcGxpY2F0aW9uX2ZhY3RvciA9IDFcbmNvbnNpc3RlbmN5X21vZGUgPSBcImNvbnNpc3RlbnRcIlxuXG5jb21wcmVzc2lvbl9sZXZlbCA9IDFcbmJsb2NrX3NpemUgPSBcIjFNXCJcblxucnBjX2JpbmRfYWRkciA9IFwiWzo6XTozOTAxXCJcbnJwY19zZWNyZXRfZmlsZSA9IFwiZW52OkdBUkFHRV9SUENfU0VDUkVUXCJcbmJvb3RzdHJhcF9wZWVycyA9IFtdXG5cbltzM19hcGldXG5zM19yZWdpb24gPSBcImdhcmFnZVwiXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDBcIlxucm9vdF9kb21haW4gPSBcIi5zMy5nYXJhZ2UubG9jYWxob3N0XCJcblxuW3MzX3dlYl1cbmJpbmRfYWRkciA9IFwiWzo6XTozOTAyXCJcbnJvb3RfZG9tYWluID0gXCIud2ViLmdhcmFnZS5sb2NhbGhvc3RcIlxuXG5bYWRtaW5dXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDNcIlxuYWRtaW5fdG9rZW5fZmlsZSA9IFwiZW52OkdBUkFHRV9BRE1JTl9UT0tFTlwiXG5tZXRyaWNzX3Rva2VuX2ZpbGUgPSBcImVudjpHQVJBR0VfTUVUUklDU19UT0tFTlwiIgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9nYXJhZ2UKICAgICAgICAtIHN0YXRzCiAgICAgICAgLSAnLWEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=",
"tags": [
"object",
"storage",
"server",
"s3",
"api",
"distributed"
],
"category": "storage",
"logo": "svgs/garage.svg",
"minversion": "0.0.0",
"port": "3900"
},
"getoutline": {
"documentation": "https://docs.getoutline.com/s/hosting/doc/hosting-outline-nipGaCRBDu?utm_source=coolify.io",
"slogan": "Your team\u2019s knowledge base",

View file

@ -1399,6 +1399,23 @@
"minversion": "0.0.0",
"port": "80"
},
"garage": {
"documentation": "https://garagehq.deuxfleurs.fr/documentation/?utm_source=coolify.io",
"slogan": "Garage is an S3-compatible distributed object storage service designed for self-hosting.",
"compose": "c2VydmljZXM6CiAgZ2FyYWdlOgogICAgaW1hZ2U6ICdkeGZscnMvZ2FyYWdlOnYyLjEuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIEdBUkFHRV9TM19BUElfVVJMPSRHQVJBR0VfUzNfQVBJX1VSTAogICAgICAtIEdBUkFHRV9XRUJfVVJMPSRHQVJBR0VfV0VCX1VSTAogICAgICAtIEdBUkFHRV9BRE1JTl9VUkw9JEdBUkFHRV9BRE1JTl9VUkwKICAgICAgLSAnR0FSQUdFX1JQQ19TRUNSRVQ9JHtTRVJWSUNFX0hFWF8zMl9SUENTRUNSRVR9JwogICAgICAtIEdBUkFHRV9BRE1JTl9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0UKICAgICAgLSBHQVJBR0VfTUVUUklDU19UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0VNRVRSSUNTCiAgICAgIC0gR0FSQUdFX0FMTE9XX1dPUkxEX1JFQURBQkxFX1NFQ1JFVFM9dHJ1ZQogICAgICAtICdSVVNUX0xPRz0ke1JVU1RfTE9HOi1nYXJhZ2U9aW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdnYXJhZ2UtbWV0YTovdmFyL2xpYi9nYXJhZ2UvbWV0YScKICAgICAgLSAnZ2FyYWdlLWRhdGE6L3Zhci9saWIvZ2FyYWdlL2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2dhcmFnZS50b21sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2dhcmFnZS50b21sCiAgICAgICAgY29udGVudDogIm1ldGFkYXRhX2RpciA9IFwiL3Zhci9saWIvZ2FyYWdlL21ldGFcIlxuZGF0YV9kaXIgPSBcIi92YXIvbGliL2dhcmFnZS9kYXRhXCJcbmRiX2VuZ2luZSA9IFwibG1kYlwiXG5cbnJlcGxpY2F0aW9uX2ZhY3RvciA9IDFcbmNvbnNpc3RlbmN5X21vZGUgPSBcImNvbnNpc3RlbnRcIlxuXG5jb21wcmVzc2lvbl9sZXZlbCA9IDFcbmJsb2NrX3NpemUgPSBcIjFNXCJcblxucnBjX2JpbmRfYWRkciA9IFwiWzo6XTozOTAxXCJcbnJwY19zZWNyZXRfZmlsZSA9IFwiZW52OkdBUkFHRV9SUENfU0VDUkVUXCJcbmJvb3RzdHJhcF9wZWVycyA9IFtdXG5cbltzM19hcGldXG5zM19yZWdpb24gPSBcImdhcmFnZVwiXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDBcIlxucm9vdF9kb21haW4gPSBcIi5zMy5nYXJhZ2UubG9jYWxob3N0XCJcblxuW3MzX3dlYl1cbmJpbmRfYWRkciA9IFwiWzo6XTozOTAyXCJcbnJvb3RfZG9tYWluID0gXCIud2ViLmdhcmFnZS5sb2NhbGhvc3RcIlxuXG5bYWRtaW5dXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDNcIlxuYWRtaW5fdG9rZW5fZmlsZSA9IFwiZW52OkdBUkFHRV9BRE1JTl9UT0tFTlwiXG5tZXRyaWNzX3Rva2VuX2ZpbGUgPSBcImVudjpHQVJBR0VfTUVUUklDU19UT0tFTlwiIgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9nYXJhZ2UKICAgICAgICAtIHN0YXRzCiAgICAgICAgLSAnLWEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=",
"tags": [
"object",
"storage",
"server",
"s3",
"api",
"distributed"
],
"category": "storage",
"logo": "svgs/garage.svg",
"minversion": "0.0.0",
"port": "3900"
},
"getoutline": {
"documentation": "https://docs.getoutline.com/s/hosting/doc/hosting-outline-nipGaCRBDu?utm_source=coolify.io",
"slogan": "Your team\u2019s knowledge base",

View 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.']);
});
});

View 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);
});
});

View file

@ -251,3 +251,92 @@
expect(preg_match($pattern, $image))->toBe(0, "Image {$image} should be deletable");
}
});
it('excludes current version of Coolify infrastructure images from any registry', function () {
// Test the regex pattern used to protect the current version of infrastructure images
// regardless of which registry they come from (ghcr.io, docker.io, or no prefix)
$helperVersion = '1.0.12';
$realtimeVersion = '1.0.10';
// Build the exclusion pattern the same way CleanupDocker does
// Pattern: (^|/)coollabsio/coolify-helper:VERSION$|(^|/)coollabsio/coolify-realtime:VERSION$
$escapedHelperVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $helperVersion);
$escapedRealtimeVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $realtimeVersion);
// For PHP preg_match, escape forward slashes
$infraPattern = "(^|\\/)coollabsio\\/coolify-helper:{$escapedHelperVersion}$|(^|\\/)coollabsio\\/coolify-realtime:{$escapedRealtimeVersion}$";
$pattern = "/{$infraPattern}/";
// Current versioned infrastructure images from ANY registry should be PROTECTED
$protectedImages = [
// ghcr.io registry
"ghcr.io/coollabsio/coolify-helper:{$helperVersion}",
"ghcr.io/coollabsio/coolify-realtime:{$realtimeVersion}",
// docker.io registry (explicit)
"docker.io/coollabsio/coolify-helper:{$helperVersion}",
"docker.io/coollabsio/coolify-realtime:{$realtimeVersion}",
// No registry prefix (Docker Hub implicit)
"coollabsio/coolify-helper:{$helperVersion}",
"coollabsio/coolify-realtime:{$realtimeVersion}",
];
// Verify current infrastructure images ARE protected from any registry
foreach ($protectedImages as $image) {
expect(preg_match($pattern, $image))->toBe(1, "Current infrastructure image {$image} should be protected");
}
// Verify OLD versions of infrastructure images are NOT protected (can be deleted)
$oldVersionImages = [
'ghcr.io/coollabsio/coolify-helper:1.0.11',
'docker.io/coollabsio/coolify-helper:1.0.10',
'coollabsio/coolify-helper:1.0.9',
'ghcr.io/coollabsio/coolify-realtime:1.0.9',
'ghcr.io/coollabsio/coolify-helper:latest',
'coollabsio/coolify-realtime:latest',
];
foreach ($oldVersionImages as $image) {
expect(preg_match($pattern, $image))->toBe(0, "Old infrastructure image {$image} should NOT be protected");
}
// Verify other images are NOT protected (can be deleted)
$deletableImages = [
'nginx:alpine',
'postgres:15',
'redis:7',
'mysql:8.0',
'node:20',
];
foreach ($deletableImages as $image) {
expect(preg_match($pattern, $image))->toBe(0, "Image {$image} should NOT be protected");
}
});
it('protects current infrastructure images from any registry even when no applications exist', function () {
// When there are no applications, current versioned infrastructure images should still be protected
// regardless of which registry they come from
$helperVersion = '1.0.12';
$realtimeVersion = '1.0.10';
// Build the pattern the same way CleanupDocker does
$escapedHelperVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $helperVersion);
$escapedRealtimeVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $realtimeVersion);
// For PHP preg_match, escape forward slashes
$infraPattern = "(^|\\/)coollabsio\\/coolify-helper:{$escapedHelperVersion}$|(^|\\/)coollabsio\\/coolify-realtime:{$escapedRealtimeVersion}$";
$pattern = "/{$infraPattern}/";
// Verify current infrastructure images from any registry are protected
expect(preg_match($pattern, "ghcr.io/coollabsio/coolify-helper:{$helperVersion}"))->toBe(1);
expect(preg_match($pattern, "docker.io/coollabsio/coolify-helper:{$helperVersion}"))->toBe(1);
expect(preg_match($pattern, "coollabsio/coolify-helper:{$helperVersion}"))->toBe(1);
expect(preg_match($pattern, "ghcr.io/coollabsio/coolify-realtime:{$realtimeVersion}"))->toBe(1);
// Old versions should NOT be protected
expect(preg_match($pattern, 'ghcr.io/coollabsio/coolify-helper:1.0.11'))->toBe(0);
expect(preg_match($pattern, 'docker.io/coollabsio/coolify-helper:1.0.11'))->toBe(0);
// Other images should not be protected
expect(preg_match($pattern, 'nginx:alpine'))->toBe(0);
});

View 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();
});

View 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();
});

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.453"
"version": "4.0.0-beta.454"
},
"nightly": {
"version": "4.0.0-beta.454"
"version": "4.0.0-beta.455"
},
"helper": {
"version": "1.0.12"