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:
parent
cf4985c596
commit
5d38147899
10 changed files with 238 additions and 46 deletions
|
|
@ -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}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
app/Exceptions/RateLimitException.php
Normal file
15
app/Exceptions/RateLimitException.php
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class RateLimitException extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message = 'Rate limit exceeded.',
|
||||||
|
public readonly ?int $retryAfter = null
|
||||||
|
) {
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -192,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.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
openapi.json
42
openapi.json
|
|
@ -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",
|
||||||
|
|
|
||||||
37
openapi.yaml
37
openapi.yaml
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue