From 5d381478992a324098810a254e75ff9373f6789c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:12:43 +0100 Subject: [PATCH] feat(api): Improve OpenAPI spec and add rate limit handling for Hetzner - Add 429 response with Retry-After header for Hetzner server creation - Create RateLimitException for proper rate limit error handling - Rename cloud_provider_token_id to cloud_provider_token_uuid with deprecation - Fix prices array schema in server-types endpoint with proper items definition - Add explicit default: true to autogenerate_domain properties - Add timeout and retry options to Docker install curl commands - Fix race condition in deployment status update using atomic query --- app/Actions/Server/InstallDocker.php | 2 +- app/Exceptions/RateLimitException.php | 15 ++ .../Api/ApplicationsController.php | 10 +- .../Controllers/Api/HetznerController.php | 131 +++++++++++++++--- app/Http/Controllers/Api/OpenApi.php | 16 +++ app/Services/HetznerService.php | 14 ++ app/Traits/ExecuteRemoteCommand.php | 12 +- openapi.json | 42 +++++- openapi.yaml | 37 ++++- tests/Feature/CloudProviderTokenApiTest.php | 5 +- 10 files changed, 238 insertions(+), 46 deletions(-) create mode 100644 app/Exceptions/RateLimitException.php diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index c8713a22b..eb53b32ee 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -161,6 +161,6 @@ private function getArchDockerInstallCommand(): string 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}"; + return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion}"; } } diff --git a/app/Exceptions/RateLimitException.php b/app/Exceptions/RateLimitException.php new file mode 100644 index 000000000..fde0235dd --- /dev/null +++ b/app/Exceptions/RateLimitException.php @@ -0,0 +1,15 @@ + ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], - 'autogenerate_domain' => ['type' => 'boolean', 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], + '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.'], ], ) ), @@ -343,7 +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', 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], + '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.'], ], ) ), @@ -494,7 +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', 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], + '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.'], ], ) ), @@ -629,7 +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', 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], + '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.'], ], ) ), @@ -761,7 +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', 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], + '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.'], ], ) ), diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 2d0ee7bb3..2645c2df1 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -3,6 +3,7 @@ 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; @@ -16,6 +17,15 @@ 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.', @@ -26,11 +36,19 @@ class HetznerController extends Controller ], 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: true, - description: 'Cloud provider token UUID', + required: false, + deprecated: true, + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', schema: new OA\Schema(type: 'string') ), ], @@ -76,7 +94,8 @@ public function locations(Request $request) } $validator = customApiValidator($request->all(), [ - 'cloud_provider_token_id' => 'required|string', + '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()) { @@ -86,8 +105,9 @@ public function locations(Request $request) ], 422); } + $tokenUuid = $this->getCloudProviderTokenUuid($request); $token = CloudProviderToken::whereTeamId($teamId) - ->whereUuid($request->cloud_provider_token_id) + ->whereUuid($tokenUuid) ->where('provider', 'hetzner') ->first(); @@ -115,11 +135,19 @@ public function locations(Request $request) ], 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: true, - description: 'Cloud provider token UUID', + required: false, + deprecated: true, + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', schema: new OA\Schema(type: 'string') ), ], @@ -141,7 +169,29 @@ public function locations(Request $request) 'cores' => ['type' => 'integer'], 'memory' => ['type' => 'number'], 'disk' => ['type' => 'integer'], - 'prices' => ['type' => 'array'], + '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'], + ], + ], + ], + ], + ], ] ) ) @@ -165,7 +215,8 @@ public function serverTypes(Request $request) } $validator = customApiValidator($request->all(), [ - 'cloud_provider_token_id' => 'required|string', + '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()) { @@ -175,8 +226,9 @@ public function serverTypes(Request $request) ], 422); } + $tokenUuid = $this->getCloudProviderTokenUuid($request); $token = CloudProviderToken::whereTeamId($teamId) - ->whereUuid($request->cloud_provider_token_id) + ->whereUuid($tokenUuid) ->where('provider', 'hetzner') ->first(); @@ -204,11 +256,19 @@ public function serverTypes(Request $request) ], 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: true, - description: 'Cloud provider token UUID', + required: false, + deprecated: true, + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', schema: new OA\Schema(type: 'string') ), ], @@ -254,7 +314,8 @@ public function images(Request $request) } $validator = customApiValidator($request->all(), [ - 'cloud_provider_token_id' => 'required|string', + '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()) { @@ -264,8 +325,9 @@ public function images(Request $request) ], 422); } + $tokenUuid = $this->getCloudProviderTokenUuid($request); $token = CloudProviderToken::whereTeamId($teamId) - ->whereUuid($request->cloud_provider_token_id) + ->whereUuid($tokenUuid) ->where('provider', 'hetzner') ->first(); @@ -306,11 +368,19 @@ public function images(Request $request) ], 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: true, - description: 'Cloud provider token UUID', + required: false, + deprecated: true, + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', schema: new OA\Schema(type: 'string') ), ], @@ -353,7 +423,8 @@ public function sshKeys(Request $request) } $validator = customApiValidator($request->all(), [ - 'cloud_provider_token_id' => 'required|string', + '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()) { @@ -363,8 +434,9 @@ public function sshKeys(Request $request) ], 422); } + $tokenUuid = $this->getCloudProviderTokenUuid($request); $token = CloudProviderToken::whereTeamId($teamId) - ->whereUuid($request->cloud_provider_token_id) + ->whereUuid($tokenUuid) ->where('provider', 'hetzner') ->first(); @@ -398,9 +470,10 @@ public function sshKeys(Request $request) mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['cloud_provider_token_id', 'location', 'server_type', 'image', 'private_key_uuid'], + required: ['location', 'server_type', 'image', 'private_key_uuid'], properties: [ - 'cloud_provider_token_id' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Cloud provider token UUID'], + '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'], @@ -448,11 +521,16 @@ public function sshKeys(Request $request) 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', @@ -477,7 +555,8 @@ public function createServer(Request $request) } $validator = customApiValidator($request->all(), [ - 'cloud_provider_token_id' => 'required|string', + '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', @@ -529,8 +608,9 @@ public function createServer(Request $request) } // Validate cloud provider token + $tokenUuid = $this->getCloudProviderTokenUuid($request); $token = CloudProviderToken::whereTeamId($teamId) - ->whereUuid($request->cloud_provider_token_id) + ->whereUuid($tokenUuid) ->where('provider', 'hetzner') ->first(); @@ -620,7 +700,7 @@ public function createServer(Request $request) // Create server in Coolify database $server = Server::create([ - 'name' => $request->name, + 'name' => $normalizedServerName, 'ip' => $ipAddress, 'user' => 'root', 'port' => 22, @@ -644,6 +724,13 @@ public function createServer(Request $request) 'hetzner_server_id' => $hetznerServer['id'], 'ip' => $ipAddress, ])->setStatusCode(201); + } catch (RateLimitException $e) { + $response = response()->json(['message' => $e->getMessage()], 429); + if ($e->retryAfter !== null) { + $response->header('Retry-After', $e->retryAfter); + } + + return $response; } catch (\Throwable $e) { return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500); } diff --git a/app/Http/Controllers/Api/OpenApi.php b/app/Http/Controllers/Api/OpenApi.php index 69f71feaf..33d21ba5d 100644 --- a/app/Http/Controllers/Api/OpenApi.php +++ b/app/Http/Controllers/Api/OpenApi.php @@ -61,6 +61,22 @@ ), ] )), + new OA\Response( + response: 429, + description: 'Rate limit exceeded.', + headers: [ + new OA\Header( + header: 'Retry-After', + description: 'Number of seconds to wait before retrying.', + schema: new OA\Schema(type: 'integer', example: 60) + ), + ], + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Rate limit exceeded. Please try again later.'), + ] + )), ], )] class OpenApi diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index dd4d6e631..f7855090a 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Exceptions\RateLimitException; use Illuminate\Support\Facades\Http; class HetznerService @@ -46,6 +47,19 @@ private function request(string $method, string $endpoint, array $data = []) ->{$method}($this->baseUrl.$endpoint, $data); if (! $response->successful()) { + if ($response->status() === 429) { + $retryAfter = $response->header('Retry-After'); + if ($retryAfter === null) { + $resetTime = $response->header('RateLimit-Reset'); + $retryAfter = $resetTime ? max(0, (int) $resetTime - time()) : null; + } + + throw new RateLimitException( + 'Rate limit exceeded. Please try again later.', + $retryAfter !== null ? (int) $retryAfter : null + ); + } + throw new \Exception('Hetzner API error: '.$response->json('error.message', 'Unknown error')); } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index d4b5869e4..a60a47b93 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -142,11 +142,13 @@ public function execute_remote_command(...$commands) // 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->refresh(); - if ($this->application_deployment_queue->status !== ApplicationDeploymentStatus::FINISHED->value) { - $this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value; - $this->application_deployment_queue->save(); - } + // Avoid clobbering a deployment that may have just been marked FINISHED + $this->application_deployment_queue->newQuery() + ->where('id', $this->application_deployment_queue->id) + ->where('status', '!=', ApplicationDeploymentStatus::FINISHED->value) + ->update([ + 'status' => ApplicationDeploymentStatus::FAILED->value, + ]); } throw $lastError; } diff --git a/openapi.json b/openapi.json index de7353a84..fe8ca863e 100644 --- a/openapi.json +++ b/openapi.json @@ -364,6 +364,7 @@ }, "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." } }, @@ -778,6 +779,7 @@ }, "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." } }, @@ -1192,6 +1194,7 @@ }, "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." } }, @@ -1535,6 +1538,7 @@ }, "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." } }, @@ -1861,6 +1865,7 @@ }, "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." } }, @@ -6915,11 +6920,21 @@ "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": "Cloud provider token UUID", - "required": true, + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, "schema": { "type": "string" } @@ -6985,11 +7000,21 @@ "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": "Cloud provider token UUID", - "required": true, + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, "schema": { "type": "string" } @@ -7052,17 +7077,22 @@ "application\/json": { "schema": { "required": [ - "cloud_provider_token_id", "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": "Cloud provider token UUID" + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "deprecated": true }, "location": { "type": "string", diff --git a/openapi.yaml b/openapi.yaml index cc87e241e..a7faa8c72 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -267,6 +267,7 @@ paths: 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: @@ -536,6 +537,7 @@ paths: 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: @@ -805,6 +807,7 @@ paths: 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: @@ -1021,6 +1024,7 @@ paths: 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: @@ -1228,6 +1232,7 @@ paths: 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: @@ -4405,11 +4410,19 @@ paths: 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: 'Cloud provider token UUID' - required: true + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true schema: type: string responses: @@ -4437,11 +4450,19 @@ paths: 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: 'Cloud provider token UUID' - required: true + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + required: false + deprecated: true schema: type: string responses: @@ -4475,16 +4496,20 @@ paths: application/json: schema: required: - - cloud_provider_token_id - 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: 'Cloud provider token UUID' + description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.' + deprecated: true location: type: string example: nbg1 diff --git a/tests/Feature/CloudProviderTokenApiTest.php b/tests/Feature/CloudProviderTokenApiTest.php index 4623e0e96..da3acfd56 100644 --- a/tests/Feature/CloudProviderTokenApiTest.php +++ b/tests/Feature/CloudProviderTokenApiTest.php @@ -14,6 +14,9 @@ $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; @@ -225,7 +228,7 @@ ]); $response->assertStatus(400); - $response->assertJson(['message' => 'Invalid Hetzner token. Please check your API token.']); + $response->assertJson(['message' => 'Invalid hetzner token. Please check your API token.']); }); test('rejects extra fields not in allowed list', function () {