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
This commit is contained in:
Andras Bacsai 2025-12-11 12:12:43 +01:00
parent cf4985c596
commit 5d38147899
10 changed files with 238 additions and 46 deletions

View file

@ -161,6 +161,6 @@ private function getArchDockerInstallCommand(): string
private function getGenericDockerInstallCommand(): 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}";
} }
} }

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,7 +192,7 @@ public function applications(Request $request)
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', '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'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', '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'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', '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'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', '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'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', '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.'],
], ],
) )
), ),

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Enums\ProxyTypes; use App\Enums\ProxyTypes;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\CloudProviderToken; use App\Models\CloudProviderToken;
use App\Models\PrivateKey; use App\Models\PrivateKey;
@ -16,6 +17,15 @@
class HetznerController extends Controller 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( #[OA\Get(
summary: 'Get Hetzner Locations', summary: 'Get Hetzner Locations',
description: 'Get all available Hetzner datacenter locations.', description: 'Get all available Hetzner datacenter locations.',
@ -26,11 +36,19 @@ class HetznerController extends Controller
], ],
tags: ['Hetzner'], tags: ['Hetzner'],
parameters: [ 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( new OA\Parameter(
name: 'cloud_provider_token_id', name: 'cloud_provider_token_id',
in: 'query', in: 'query',
required: true, required: false,
description: 'Cloud provider token UUID', deprecated: true,
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
schema: new OA\Schema(type: 'string') schema: new OA\Schema(type: 'string')
), ),
], ],
@ -76,7 +94,8 @@ public function locations(Request $request)
} }
$validator = customApiValidator($request->all(), [ $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()) { if ($validator->fails()) {
@ -86,8 +105,9 @@ public function locations(Request $request)
], 422); ], 422);
} }
$tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId) $token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($request->cloud_provider_token_id) ->whereUuid($tokenUuid)
->where('provider', 'hetzner') ->where('provider', 'hetzner')
->first(); ->first();
@ -115,11 +135,19 @@ public function locations(Request $request)
], ],
tags: ['Hetzner'], tags: ['Hetzner'],
parameters: [ 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( new OA\Parameter(
name: 'cloud_provider_token_id', name: 'cloud_provider_token_id',
in: 'query', in: 'query',
required: true, required: false,
description: 'Cloud provider token UUID', deprecated: true,
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
schema: new OA\Schema(type: 'string') schema: new OA\Schema(type: 'string')
), ),
], ],
@ -141,7 +169,29 @@ public function locations(Request $request)
'cores' => ['type' => 'integer'], 'cores' => ['type' => 'integer'],
'memory' => ['type' => 'number'], 'memory' => ['type' => 'number'],
'disk' => ['type' => 'integer'], '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(), [ $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()) { if ($validator->fails()) {
@ -175,8 +226,9 @@ public function serverTypes(Request $request)
], 422); ], 422);
} }
$tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId) $token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($request->cloud_provider_token_id) ->whereUuid($tokenUuid)
->where('provider', 'hetzner') ->where('provider', 'hetzner')
->first(); ->first();
@ -204,11 +256,19 @@ public function serverTypes(Request $request)
], ],
tags: ['Hetzner'], tags: ['Hetzner'],
parameters: [ 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( new OA\Parameter(
name: 'cloud_provider_token_id', name: 'cloud_provider_token_id',
in: 'query', in: 'query',
required: true, required: false,
description: 'Cloud provider token UUID', deprecated: true,
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
schema: new OA\Schema(type: 'string') schema: new OA\Schema(type: 'string')
), ),
], ],
@ -254,7 +314,8 @@ public function images(Request $request)
} }
$validator = customApiValidator($request->all(), [ $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()) { if ($validator->fails()) {
@ -264,8 +325,9 @@ public function images(Request $request)
], 422); ], 422);
} }
$tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId) $token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($request->cloud_provider_token_id) ->whereUuid($tokenUuid)
->where('provider', 'hetzner') ->where('provider', 'hetzner')
->first(); ->first();
@ -306,11 +368,19 @@ public function images(Request $request)
], ],
tags: ['Hetzner'], tags: ['Hetzner'],
parameters: [ 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( new OA\Parameter(
name: 'cloud_provider_token_id', name: 'cloud_provider_token_id',
in: 'query', in: 'query',
required: true, required: false,
description: 'Cloud provider token UUID', deprecated: true,
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
schema: new OA\Schema(type: 'string') schema: new OA\Schema(type: 'string')
), ),
], ],
@ -353,7 +423,8 @@ public function sshKeys(Request $request)
} }
$validator = customApiValidator($request->all(), [ $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()) { if ($validator->fails()) {
@ -363,8 +434,9 @@ public function sshKeys(Request $request)
], 422); ], 422);
} }
$tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId) $token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($request->cloud_provider_token_id) ->whereUuid($tokenUuid)
->where('provider', 'hetzner') ->where('provider', 'hetzner')
->first(); ->first();
@ -398,9 +470,10 @@ public function sshKeys(Request $request)
mediaType: 'application/json', mediaType: 'application/json',
schema: new OA\Schema( schema: new OA\Schema(
type: 'object', type: 'object',
required: ['cloud_provider_token_id', 'location', 'server_type', 'image', 'private_key_uuid'], required: ['location', 'server_type', 'image', 'private_key_uuid'],
properties: [ 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'], 'location' => ['type' => 'string', 'example' => 'nbg1', 'description' => 'Hetzner location name'],
'server_type' => ['type' => 'string', 'example' => 'cx11', 'description' => 'Hetzner server type name'], 'server_type' => ['type' => 'string', 'example' => 'cx11', 'description' => 'Hetzner server type name'],
'image' => ['type' => 'integer', 'example' => 15512617, 'description' => 'Hetzner image ID'], 'image' => ['type' => 'integer', 'example' => 15512617, 'description' => 'Hetzner image ID'],
@ -448,11 +521,16 @@ public function sshKeys(Request $request)
response: 422, response: 422,
ref: '#/components/responses/422', ref: '#/components/responses/422',
), ),
new OA\Response(
response: 429,
ref: '#/components/responses/429',
),
] ]
)] )]
public function createServer(Request $request) public function createServer(Request $request)
{ {
$allowedFields = [ $allowedFields = [
'cloud_provider_token_uuid',
'cloud_provider_token_id', 'cloud_provider_token_id',
'location', 'location',
'server_type', 'server_type',
@ -477,7 +555,8 @@ public function createServer(Request $request)
} }
$validator = customApiValidator($request->all(), [ $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', 'location' => 'required|string',
'server_type' => 'required|string', 'server_type' => 'required|string',
'image' => 'required|integer', 'image' => 'required|integer',
@ -529,8 +608,9 @@ public function createServer(Request $request)
} }
// Validate cloud provider token // Validate cloud provider token
$tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId) $token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($request->cloud_provider_token_id) ->whereUuid($tokenUuid)
->where('provider', 'hetzner') ->where('provider', 'hetzner')
->first(); ->first();
@ -620,7 +700,7 @@ public function createServer(Request $request)
// Create server in Coolify database // Create server in Coolify database
$server = Server::create([ $server = Server::create([
'name' => $request->name, 'name' => $normalizedServerName,
'ip' => $ipAddress, 'ip' => $ipAddress,
'user' => 'root', 'user' => 'root',
'port' => 22, 'port' => 22,
@ -644,6 +724,13 @@ public function createServer(Request $request)
'hetzner_server_id' => $hetznerServer['id'], 'hetzner_server_id' => $hetznerServer['id'],
'ip' => $ipAddress, 'ip' => $ipAddress,
])->setStatusCode(201); ])->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) { } catch (\Throwable $e) {
return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500); 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 class OpenApi

View file

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

View file

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

View file

@ -364,6 +364,7 @@
}, },
"autogenerate_domain": { "autogenerate_domain": {
"type": "boolean", "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." "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": { "autogenerate_domain": {
"type": "boolean", "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." "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": { "autogenerate_domain": {
"type": "boolean", "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." "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": { "autogenerate_domain": {
"type": "boolean", "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." "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": { "autogenerate_domain": {
"type": "boolean", "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." "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).", "description": "Get all available Hetzner system images (operating systems).",
"operationId": "get-hetzner-images", "operationId": "get-hetzner-images",
"parameters": [ "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", "name": "cloud_provider_token_id",
"in": "query", "in": "query",
"description": "Cloud provider token UUID", "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.",
"required": true, "required": false,
"deprecated": true,
"schema": { "schema": {
"type": "string" "type": "string"
} }
@ -6985,11 +7000,21 @@
"description": "Get all SSH keys stored in the Hetzner account.", "description": "Get all SSH keys stored in the Hetzner account.",
"operationId": "get-hetzner-ssh-keys", "operationId": "get-hetzner-ssh-keys",
"parameters": [ "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", "name": "cloud_provider_token_id",
"in": "query", "in": "query",
"description": "Cloud provider token UUID", "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.",
"required": true, "required": false,
"deprecated": true,
"schema": { "schema": {
"type": "string" "type": "string"
} }
@ -7052,17 +7077,22 @@
"application\/json": { "application\/json": {
"schema": { "schema": {
"required": [ "required": [
"cloud_provider_token_id",
"location", "location",
"server_type", "server_type",
"image", "image",
"private_key_uuid" "private_key_uuid"
], ],
"properties": { "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": { "cloud_provider_token_id": {
"type": "string", "type": "string",
"example": "abc123", "example": "abc123",
"description": "Cloud provider token UUID" "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.",
"deprecated": true
}, },
"location": { "location": {
"type": "string", "type": "string",

View file

@ -267,6 +267,7 @@ paths:
description: 'Force domain usage even if conflicts are detected. Default is false.' description: 'Force domain usage even if conflicts are detected. Default is false.'
autogenerate_domain: autogenerate_domain:
type: boolean 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." description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
type: object type: object
responses: responses:
@ -536,6 +537,7 @@ paths:
description: 'Force domain usage even if conflicts are detected. Default is false.' description: 'Force domain usage even if conflicts are detected. Default is false.'
autogenerate_domain: autogenerate_domain:
type: boolean 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." description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
type: object type: object
responses: responses:
@ -805,6 +807,7 @@ paths:
description: 'Force domain usage even if conflicts are detected. Default is false.' description: 'Force domain usage even if conflicts are detected. Default is false.'
autogenerate_domain: autogenerate_domain:
type: boolean 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." description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
type: object type: object
responses: responses:
@ -1021,6 +1024,7 @@ paths:
description: 'Force domain usage even if conflicts are detected. Default is false.' description: 'Force domain usage even if conflicts are detected. Default is false.'
autogenerate_domain: autogenerate_domain:
type: boolean 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." description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
type: object type: object
responses: responses:
@ -1228,6 +1232,7 @@ paths:
description: 'Force domain usage even if conflicts are detected. Default is false.' description: 'Force domain usage even if conflicts are detected. Default is false.'
autogenerate_domain: autogenerate_domain:
type: boolean 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." description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
type: object type: object
responses: responses:
@ -4405,11 +4410,19 @@ paths:
description: 'Get all available Hetzner system images (operating systems).' description: 'Get all available Hetzner system images (operating systems).'
operationId: get-hetzner-images operationId: get-hetzner-images
parameters: 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 name: cloud_provider_token_id
in: query in: query
description: 'Cloud provider token UUID' description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.'
required: true required: false
deprecated: true
schema: schema:
type: string type: string
responses: responses:
@ -4437,11 +4450,19 @@ paths:
description: 'Get all SSH keys stored in the Hetzner account.' description: 'Get all SSH keys stored in the Hetzner account.'
operationId: get-hetzner-ssh-keys operationId: get-hetzner-ssh-keys
parameters: 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 name: cloud_provider_token_id
in: query in: query
description: 'Cloud provider token UUID' description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.'
required: true required: false
deprecated: true
schema: schema:
type: string type: string
responses: responses:
@ -4475,16 +4496,20 @@ paths:
application/json: application/json:
schema: schema:
required: required:
- cloud_provider_token_id
- location - location
- server_type - server_type
- image - image
- private_key_uuid - private_key_uuid
properties: 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: cloud_provider_token_id:
type: string type: string
example: abc123 example: abc123
description: 'Cloud provider token UUID' description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.'
deprecated: true
location: location:
type: string type: string
example: nbg1 example: nbg1

View file

@ -14,6 +14,9 @@
$this->user = User::factory()->create(); $this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']); $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 // Create an API token for the user
$this->token = $this->user->createToken('test-token', ['*']); $this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken; $this->bearerToken = $this->token->plainTextToken;
@ -225,7 +228,7 @@
]); ]);
$response->assertStatus(400); $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 () { test('rejects extra fields not in allowed list', function () {