diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php new file mode 100644 index 000000000..5a03fe59a --- /dev/null +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -0,0 +1,531 @@ +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'], + ]); + } +} diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php new file mode 100644 index 000000000..2d0ee7bb3 --- /dev/null +++ b/app/Http/Controllers/Api/HetznerController.php @@ -0,0 +1,651 @@ + []], + ], + 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); + } + } +} diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 607040269..700ab0992 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -2,9 +2,7 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Model; - -class CloudProviderToken extends Model +class CloudProviderToken extends BaseModel { protected $guarded = []; diff --git a/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php b/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php new file mode 100644 index 000000000..220be0fe9 --- /dev/null +++ b/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php @@ -0,0 +1,43 @@ +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'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 366a97d74..aaf7d794b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,9 +1,11 @@ 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']); diff --git a/tests/Feature/CloudProviderTokenApiTest.php b/tests/Feature/CloudProviderTokenApiTest.php new file mode 100644 index 000000000..4623e0e96 --- /dev/null +++ b/tests/Feature/CloudProviderTokenApiTest.php @@ -0,0 +1,410 @@ +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.']); + }); +}); diff --git a/tests/Feature/HetznerApiTest.php b/tests/Feature/HetznerApiTest.php new file mode 100644 index 000000000..298475934 --- /dev/null +++ b/tests/Feature/HetznerApiTest.php @@ -0,0 +1,447 @@ +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); + }); +});