feat: add Hetzner server provisioning API endpoints (#7562)

This commit is contained in:
Andras Bacsai 2025-12-10 13:52:41 +01:00 committed by GitHub
commit 4fca2d457b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 2098 additions and 3 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Visus\Cuid2\Cuid2;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('cloud_provider_tokens', function (Blueprint $table) {
$table->string('uuid')->nullable()->unique()->after('id');
});
// Generate UUIDs for existing records
$tokens = DB::table('cloud_provider_tokens')->whereNull('uuid')->get();
foreach ($tokens as $token) {
DB::table('cloud_provider_tokens')
->where('id', $token->id)
->update(['uuid' => (string) new Cuid2]);
}
// Make uuid non-nullable after filling in values
Schema::table('cloud_provider_tokens', function (Blueprint $table) {
$table->string('uuid')->nullable(false)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('cloud_provider_tokens', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};

View file

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

View file

@ -0,0 +1,410 @@
<?php
use App\Models\CloudProviderToken;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
// Create an API token for the user
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
});
describe('GET /api/v1/cloud-tokens', function () {
test('lists all cloud provider tokens for the team', function () {
// Create some tokens
CloudProviderToken::factory()->count(3)->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/cloud-tokens');
$response->assertStatus(200);
$response->assertJsonCount(3);
$response->assertJsonStructure([
'*' => ['uuid', 'name', 'provider', 'team_id', 'servers_count', 'created_at', 'updated_at'],
]);
});
test('does not include tokens from other teams', function () {
// Create tokens for this team
CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
]);
// Create tokens for another team
$otherTeam = Team::factory()->create();
CloudProviderToken::factory()->count(2)->create([
'team_id' => $otherTeam->id,
'provider' => 'hetzner',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/cloud-tokens');
$response->assertStatus(200);
$response->assertJsonCount(1);
});
test('rejects request without authentication', function () {
$response = $this->getJson('/api/v1/cloud-tokens');
$response->assertStatus(401);
});
});
describe('GET /api/v1/cloud-tokens/{uuid}', function () {
test('gets cloud provider token by UUID', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
'name' => 'My Hetzner Token',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson("/api/v1/cloud-tokens/{$token->uuid}");
$response->assertStatus(200);
$response->assertJsonFragment(['name' => 'My Hetzner Token', 'provider' => 'hetzner']);
});
test('returns 404 for non-existent token', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/cloud-tokens/non-existent-uuid');
$response->assertStatus(404);
});
test('cannot access token from another team', function () {
$otherTeam = Team::factory()->create();
$token = CloudProviderToken::factory()->create([
'team_id' => $otherTeam->id,
'provider' => 'hetzner',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson("/api/v1/cloud-tokens/{$token->uuid}");
$response->assertStatus(404);
});
});
describe('POST /api/v1/cloud-tokens', function () {
test('creates a Hetzner cloud provider token', function () {
// Mock Hetzner API validation
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'provider' => 'hetzner',
'token' => 'test-hetzner-token',
'name' => 'My Hetzner Token',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid']);
// Verify token was created
$this->assertDatabaseHas('cloud_provider_tokens', [
'team_id' => $this->team->id,
'provider' => 'hetzner',
'name' => 'My Hetzner Token',
]);
});
test('creates a DigitalOcean cloud provider token', function () {
// Mock DigitalOcean API validation
Http::fake([
'https://api.digitalocean.com/v2/account' => Http::response([], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'provider' => 'digitalocean',
'token' => 'test-do-token',
'name' => 'My DO Token',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid']);
});
test('validates provider is required', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'token' => 'test-token',
'name' => 'My Token',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['provider']);
});
test('validates token is required', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'provider' => 'hetzner',
'name' => 'My Token',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['token']);
});
test('validates name is required', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'provider' => 'hetzner',
'token' => 'test-token',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['name']);
});
test('validates provider must be hetzner or digitalocean', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'provider' => 'invalid-provider',
'token' => 'test-token',
'name' => 'My Token',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['provider']);
});
test('rejects invalid Hetzner token', function () {
// Mock failed Hetzner API validation
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 401),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'provider' => 'hetzner',
'token' => 'invalid-token',
'name' => 'My Token',
]);
$response->assertStatus(400);
$response->assertJson(['message' => 'Invalid Hetzner token. Please check your API token.']);
});
test('rejects extra fields not in allowed list', function () {
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'provider' => 'hetzner',
'token' => 'test-token',
'name' => 'My Token',
'invalid_field' => 'invalid_value',
]);
$response->assertStatus(422);
});
});
describe('PATCH /api/v1/cloud-tokens/{uuid}', function () {
test('updates cloud provider token name', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
'name' => 'Old Name',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", [
'name' => 'New Name',
]);
$response->assertStatus(200);
// Verify token name was updated
$this->assertDatabaseHas('cloud_provider_tokens', [
'uuid' => $token->uuid,
'name' => 'New Name',
]);
});
test('validates name is required', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", []);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['name']);
});
test('cannot update token from another team', function () {
$otherTeam = Team::factory()->create();
$token = CloudProviderToken::factory()->create([
'team_id' => $otherTeam->id,
'provider' => 'hetzner',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", [
'name' => 'New Name',
]);
$response->assertStatus(404);
});
});
describe('DELETE /api/v1/cloud-tokens/{uuid}', function () {
test('deletes cloud provider token', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->deleteJson("/api/v1/cloud-tokens/{$token->uuid}");
$response->assertStatus(200);
$response->assertJson(['message' => 'Cloud provider token deleted.']);
// Verify token was deleted
$this->assertDatabaseMissing('cloud_provider_tokens', [
'uuid' => $token->uuid,
]);
});
test('cannot delete token from another team', function () {
$otherTeam = Team::factory()->create();
$token = CloudProviderToken::factory()->create([
'team_id' => $otherTeam->id,
'provider' => 'hetzner',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->deleteJson("/api/v1/cloud-tokens/{$token->uuid}");
$response->assertStatus(404);
});
test('returns 404 for non-existent token', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->deleteJson('/api/v1/cloud-tokens/non-existent-uuid');
$response->assertStatus(404);
});
});
describe('POST /api/v1/cloud-tokens/{uuid}/validate', function () {
test('validates a valid Hetzner token', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
]);
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate");
$response->assertStatus(200);
$response->assertJson(['valid' => true, 'message' => 'Token is valid.']);
});
test('detects invalid Hetzner token', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
]);
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 401),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate");
$response->assertStatus(200);
$response->assertJson(['valid' => false, 'message' => 'Invalid hetzner token. Please check your API token.']);
});
test('validates a valid DigitalOcean token', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'digitalocean',
]);
Http::fake([
'https://api.digitalocean.com/v2/account' => Http::response([], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate");
$response->assertStatus(200);
$response->assertJson(['valid' => true, 'message' => 'Token is valid.']);
});
});

View file

@ -0,0 +1,447 @@
<?php
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
// Create an API token for the user
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
$this->bearerToken = $this->token->plainTextToken;
// Create a Hetzner cloud provider token
$this->hetznerToken = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
'token' => 'test-hetzner-api-token',
]);
// Create a private key
$this->privateKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
]);
});
describe('GET /api/v1/hetzner/locations', function () {
test('gets Hetzner locations', function () {
Http::fake([
'https://api.hetzner.cloud/v1/locations*' => Http::response([
'locations' => [
['id' => 1, 'name' => 'nbg1', 'description' => 'Nuremberg 1 DC Park 1', 'country' => 'DE', 'city' => 'Nuremberg'],
['id' => 2, 'name' => 'hel1', 'description' => 'Helsinki 1 DC Park 8', 'country' => 'FI', 'city' => 'Helsinki'],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'nbg1']);
});
test('requires cloud_provider_token_id parameter', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/locations');
$response->assertStatus(422);
$response->assertJsonValidationErrors(['cloud_provider_token_id']);
});
test('returns 404 for non-existent token', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id=non-existent-uuid');
$response->assertStatus(404);
});
});
describe('GET /api/v1/hetzner/server-types', function () {
test('gets Hetzner server types', function () {
Http::fake([
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
'server_types' => [
['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'cores' => 1, 'memory' => 2.0, 'disk' => 20],
['id' => 2, 'name' => 'cx21', 'description' => 'CX21', 'cores' => 2, 'memory' => 4.0, 'disk' => 40],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'cx11']);
});
test('filters out deprecated server types', function () {
Http::fake([
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
'server_types' => [
['id' => 1, 'name' => 'cx11', 'deprecated' => false],
['id' => 2, 'name' => 'cx21', 'deprecated' => true],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'cx11']);
$response->assertJsonMissing(['name' => 'cx21']);
});
});
describe('GET /api/v1/hetzner/images', function () {
test('gets Hetzner images', function () {
Http::fake([
'https://api.hetzner.cloud/v1/images*' => Http::response([
'images' => [
['id' => 1, 'name' => 'ubuntu-20.04', 'type' => 'system', 'deprecated' => false],
['id' => 2, 'name' => 'ubuntu-22.04', 'type' => 'system', 'deprecated' => false],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'ubuntu-20.04']);
});
test('filters out deprecated images', function () {
Http::fake([
'https://api.hetzner.cloud/v1/images*' => Http::response([
'images' => [
['id' => 1, 'name' => 'ubuntu-20.04', 'type' => 'system', 'deprecated' => false],
['id' => 2, 'name' => 'ubuntu-16.04', 'type' => 'system', 'deprecated' => true],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'ubuntu-20.04']);
$response->assertJsonMissing(['name' => 'ubuntu-16.04']);
});
test('filters out non-system images', function () {
Http::fake([
'https://api.hetzner.cloud/v1/images*' => Http::response([
'images' => [
['id' => 1, 'name' => 'ubuntu-20.04', 'type' => 'system', 'deprecated' => false],
['id' => 2, 'name' => 'my-snapshot', 'type' => 'snapshot', 'deprecated' => false],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'ubuntu-20.04']);
$response->assertJsonMissing(['name' => 'my-snapshot']);
});
});
describe('GET /api/v1/hetzner/ssh-keys', function () {
test('gets Hetzner SSH keys', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [
['id' => 1, 'name' => 'my-key', 'fingerprint' => 'aa:bb:cc:dd'],
['id' => 2, 'name' => 'another-key', 'fingerprint' => 'ee:ff:11:22'],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/ssh-keys?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'my-key']);
});
});
describe('POST /api/v1/servers/hetzner', function () {
test('creates a Hetzner server', function () {
// Mock Hetzner API calls
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
'name' => 'test-server',
'public_net' => [
'ipv4' => ['ip' => '1.2.3.4'],
'ipv6' => ['ip' => '2001:db8::1'],
],
],
], 201),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'name' => 'test-server',
'private_key_uuid' => $this->privateKey->uuid,
'enable_ipv4' => true,
'enable_ipv6' => true,
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid', 'hetzner_server_id', 'ip']);
$response->assertJsonFragment(['hetzner_server_id' => 456, 'ip' => '1.2.3.4']);
// Verify server was created in database
$this->assertDatabaseHas('servers', [
'name' => 'test-server',
'ip' => '1.2.3.4',
'team_id' => $this->team->id,
'hetzner_server_id' => 456,
]);
});
test('generates server name if not provided', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
'public_net' => [
'ipv4' => ['ip' => '1.2.3.4'],
],
],
], 201),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertStatus(201);
// Verify a server was created with a generated name
$this->assertDatabaseCount('servers', 1);
});
test('validates required fields', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', []);
$response->assertStatus(422);
$response->assertJsonValidationErrors([
'cloud_provider_token_id',
'location',
'server_type',
'image',
'private_key_uuid',
]);
});
test('validates cloud_provider_token_id exists', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => 'non-existent-uuid',
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertStatus(404);
$response->assertJson(['message' => 'Hetzner cloud provider token not found.']);
});
test('validates private_key_uuid exists', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'private_key_uuid' => 'non-existent-uuid',
]);
$response->assertStatus(404);
$response->assertJson(['message' => 'Private key not found.']);
});
test('prefers IPv4 when both IPv4 and IPv6 are enabled', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
'public_net' => [
'ipv4' => ['ip' => '1.2.3.4'],
'ipv6' => ['ip' => '2001:db8::1'],
],
],
], 201),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'private_key_uuid' => $this->privateKey->uuid,
'enable_ipv4' => true,
'enable_ipv6' => true,
]);
$response->assertStatus(201);
$response->assertJsonFragment(['ip' => '1.2.3.4']);
});
test('uses IPv6 when only IPv6 is enabled', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
'public_net' => [
'ipv4' => ['ip' => null],
'ipv6' => ['ip' => '2001:db8::1'],
],
],
], 201),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'private_key_uuid' => $this->privateKey->uuid,
'enable_ipv4' => false,
'enable_ipv6' => true,
]);
$response->assertStatus(201);
$response->assertJsonFragment(['ip' => '2001:db8::1']);
});
test('rejects extra fields not in allowed list', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'private_key_uuid' => $this->privateKey->uuid,
'invalid_field' => 'invalid_value',
]);
$response->assertStatus(422);
});
test('rejects request without authentication', function () {
$response = $this->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertStatus(401);
});
});