feat(cloud-provider): Add support for Hetzner firewalls, internal networks and backups when creating the Server (#9646)
This commit is contained in:
commit
e81a2c6996
12 changed files with 1288 additions and 89 deletions
|
|
@ -460,6 +460,195 @@ public function sshKeys(Request $request)
|
|||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Firewalls',
|
||||
description: 'Get all existing Hetzner firewalls for the current project.',
|
||||
path: '/hetzner/firewalls',
|
||||
operationId: 'get-hetzner-firewalls',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner firewalls.',
|
||||
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'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function firewalls(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
|
||||
return response()->json($hetznerService->getFirewalls());
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner firewalls.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Networks',
|
||||
description: 'Get all existing Hetzner private networks for the current project.',
|
||||
path: '/hetzner/networks',
|
||||
operationId: 'get-hetzner-networks',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner networks.',
|
||||
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'],
|
||||
'ip_range' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function networks(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
|
||||
return response()->json($hetznerService->getNetworks());
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner networks.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Hetzner Server',
|
||||
description: 'Create a new server on Hetzner and register it in Coolify.',
|
||||
|
|
@ -487,7 +676,10 @@ public function sshKeys(Request $request)
|
|||
'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)'],
|
||||
'enable_backups' => ['type' => 'boolean', 'example' => false, 'description' => 'Enable Hetzner server backups after creation (adds 20% to the monthly server fee)'],
|
||||
'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'],
|
||||
'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'],
|
||||
'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'],
|
||||
'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'],
|
||||
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'],
|
||||
],
|
||||
|
|
@ -545,7 +737,10 @@ public function createServer(Request $request)
|
|||
'private_key_uuid',
|
||||
'enable_ipv4',
|
||||
'enable_ipv6',
|
||||
'enable_backups',
|
||||
'hetzner_ssh_key_ids',
|
||||
'hetzner_firewall_ids',
|
||||
'hetzner_network_ids',
|
||||
'cloud_init_script',
|
||||
'instant_validate',
|
||||
];
|
||||
|
|
@ -571,8 +766,13 @@ public function createServer(Request $request)
|
|||
'private_key_uuid' => 'required|string',
|
||||
'enable_ipv4' => 'nullable|boolean',
|
||||
'enable_ipv6' => 'nullable|boolean',
|
||||
'enable_backups' => 'nullable|boolean',
|
||||
'hetzner_ssh_key_ids' => 'nullable|array',
|
||||
'hetzner_ssh_key_ids.*' => 'integer',
|
||||
'hetzner_firewall_ids' => 'nullable|array',
|
||||
'hetzner_firewall_ids.*' => 'integer',
|
||||
'hetzner_network_ids' => 'nullable|array',
|
||||
'hetzner_network_ids.*' => 'integer',
|
||||
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
|
||||
'instant_validate' => 'nullable|boolean',
|
||||
]);
|
||||
|
|
@ -608,13 +808,32 @@ public function createServer(Request $request)
|
|||
if (is_null($request->enable_ipv6)) {
|
||||
$request->offsetSet('enable_ipv6', true);
|
||||
}
|
||||
if (is_null($request->enable_backups)) {
|
||||
$request->offsetSet('enable_backups', false);
|
||||
}
|
||||
if (is_null($request->hetzner_ssh_key_ids)) {
|
||||
$request->offsetSet('hetzner_ssh_key_ids', []);
|
||||
}
|
||||
if (is_null($request->hetzner_firewall_ids)) {
|
||||
$request->offsetSet('hetzner_firewall_ids', []);
|
||||
}
|
||||
if (is_null($request->hetzner_network_ids)) {
|
||||
$request->offsetSet('hetzner_network_ids', []);
|
||||
}
|
||||
if (is_null($request->instant_validate)) {
|
||||
$request->offsetSet('instant_validate', false);
|
||||
}
|
||||
|
||||
if (! $request->boolean('enable_ipv4') && ! $request->boolean('enable_ipv6')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'enable_ipv4' => ['Enable at least one public IP protocol.'],
|
||||
'enable_ipv6' => ['Enable at least one public IP protocol.'],
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Validate cloud provider token
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
|
|
@ -687,6 +906,18 @@ public function createServer(Request $request)
|
|||
],
|
||||
];
|
||||
|
||||
$firewallIds = array_values(array_unique($request->hetzner_firewall_ids));
|
||||
if ($firewallIds !== []) {
|
||||
$params['firewalls'] = array_map(function (int $firewallId): array {
|
||||
return ['firewall' => $firewallId];
|
||||
}, $firewallIds);
|
||||
}
|
||||
|
||||
$networkIds = array_values(array_unique($request->hetzner_network_ids));
|
||||
if ($networkIds !== []) {
|
||||
$params['networks'] = $networkIds;
|
||||
}
|
||||
|
||||
// Add cloud-init script if provided
|
||||
if (! empty($request->cloud_init_script)) {
|
||||
$params['user_data'] = $request->cloud_init_script;
|
||||
|
|
@ -723,6 +954,14 @@ public function createServer(Request $request)
|
|||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
if ($request->enable_backups) {
|
||||
try {
|
||||
$hetznerService->enableServerBackup((int) $hetznerServer['id']);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate server if requested
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ class ByHetzner extends Component
|
|||
|
||||
public array $hetznerSshKeys = [];
|
||||
|
||||
public array $hetznerFirewalls = [];
|
||||
|
||||
public array $hetznerNetworks = [];
|
||||
|
||||
public ?string $selected_location = null;
|
||||
|
||||
public ?int $selected_image = null;
|
||||
|
|
@ -54,6 +58,10 @@ class ByHetzner extends Component
|
|||
|
||||
public array $selectedHetznerSshKeyIds = [];
|
||||
|
||||
public array $selectedHetznerFirewallIds = [];
|
||||
|
||||
public array $selectedHetznerNetworkIds = [];
|
||||
|
||||
public string $server_name = '';
|
||||
|
||||
public ?int $private_key_id = null;
|
||||
|
|
@ -64,6 +72,10 @@ class ByHetzner extends Component
|
|||
|
||||
public bool $enable_ipv6 = true;
|
||||
|
||||
public bool $enable_backups = false;
|
||||
|
||||
public bool $show_cloud_init_script = false;
|
||||
|
||||
public ?string $cloud_init_script = null;
|
||||
|
||||
public bool $save_cloud_init_script = false;
|
||||
|
|
@ -112,10 +124,15 @@ public function resetSelection()
|
|||
{
|
||||
$this->selected_token_id = null;
|
||||
$this->current_step = 1;
|
||||
$this->enable_backups = false;
|
||||
$this->cloud_init_script = null;
|
||||
$this->save_cloud_init_script = false;
|
||||
$this->cloud_init_script_name = null;
|
||||
$this->selected_cloud_init_script_id = null;
|
||||
$this->show_cloud_init_script = false;
|
||||
$this->selectedHetznerSshKeyIds = [];
|
||||
$this->selectedHetznerFirewallIds = [];
|
||||
$this->selectedHetznerNetworkIds = [];
|
||||
}
|
||||
|
||||
public function loadTokens()
|
||||
|
|
@ -164,8 +181,14 @@ protected function rules(): array
|
|||
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
|
||||
'selectedHetznerSshKeyIds' => 'nullable|array',
|
||||
'selectedHetznerSshKeyIds.*' => 'integer',
|
||||
'selectedHetznerFirewallIds' => 'nullable|array',
|
||||
'selectedHetznerFirewallIds.*' => 'integer',
|
||||
'selectedHetznerNetworkIds' => 'nullable|array',
|
||||
'selectedHetznerNetworkIds.*' => 'integer',
|
||||
'enable_ipv4' => 'required|boolean',
|
||||
'enable_ipv6' => 'required|boolean',
|
||||
'enable_backups' => 'required|boolean',
|
||||
'show_cloud_init_script' => 'boolean',
|
||||
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
|
||||
'save_cloud_init_script' => 'boolean',
|
||||
'cloud_init_script_name' => 'nullable|string|max:255',
|
||||
|
|
@ -245,6 +268,9 @@ public function previousStep()
|
|||
private function loadHetznerData(string $token)
|
||||
{
|
||||
$this->loading_data = true;
|
||||
$this->selectedHetznerSshKeyIds = [];
|
||||
$this->selectedHetznerFirewallIds = [];
|
||||
$this->selectedHetznerNetworkIds = [];
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token);
|
||||
|
|
@ -274,6 +300,14 @@ private function loadHetznerData(string $token)
|
|||
->toArray();
|
||||
// Load SSH keys from Hetzner
|
||||
$this->hetznerSshKeys = $hetznerService->getSshKeys();
|
||||
$this->hetznerFirewalls = collect($hetznerService->getFirewalls())
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->toArray();
|
||||
$this->hetznerNetworks = collect($hetznerService->getNetworks())
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->toArray();
|
||||
$this->loading_data = false;
|
||||
} catch (\Throwable $e) {
|
||||
$this->loading_data = false;
|
||||
|
|
@ -349,6 +383,37 @@ public function getAvailableImagesProperty()
|
|||
return $filtered;
|
||||
}
|
||||
|
||||
public function getAvailableNetworksProperty(): array
|
||||
{
|
||||
$attachableNetworks = collect($this->hetznerNetworks)
|
||||
->filter(function (array $network) {
|
||||
return collect($network['subnets'] ?? [])->contains(function (array $subnet) {
|
||||
return in_array($subnet['type'] ?? null, ['cloud', 'server'], true);
|
||||
});
|
||||
});
|
||||
|
||||
if (! $this->selected_location) {
|
||||
return $attachableNetworks->values()->toArray();
|
||||
}
|
||||
|
||||
$location = collect($this->locations)->firstWhere('name', $this->selected_location);
|
||||
$networkZone = $location['network_zone'] ?? null;
|
||||
|
||||
if (! $networkZone) {
|
||||
return $attachableNetworks->values()->toArray();
|
||||
}
|
||||
|
||||
return $attachableNetworks
|
||||
->filter(function (array $network) use ($networkZone) {
|
||||
return collect($network['subnets'] ?? [])->contains(function (array $subnet) use ($networkZone) {
|
||||
return in_array($subnet['type'] ?? null, ['cloud', 'server'], true)
|
||||
&& ($subnet['network_zone'] ?? null) === $networkZone;
|
||||
});
|
||||
})
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getSelectedServerPriceProperty(): ?string
|
||||
{
|
||||
if (! $this->selected_server_type) {
|
||||
|
|
@ -366,11 +431,74 @@ public function getSelectedServerPriceProperty(): ?string
|
|||
return '€'.number_format($price, 2);
|
||||
}
|
||||
|
||||
public function getSelectedServerBackupSurchargeProperty(): ?string
|
||||
{
|
||||
if (! $this->selected_server_type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type);
|
||||
|
||||
if (! $serverType || ! isset($serverType['prices'][0]['price_monthly']['gross'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$price = (float) $serverType['prices'][0]['price_monthly']['gross'];
|
||||
|
||||
return '€'.number_format($price * 0.2, 2);
|
||||
}
|
||||
|
||||
public function getAdvancedHetznerOptionsSummaryProperty(): array
|
||||
{
|
||||
$summary = [];
|
||||
|
||||
if (count($this->selectedHetznerSshKeyIds) > 0) {
|
||||
$summary[] = count($this->selectedHetznerSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedHetznerSshKeyIds));
|
||||
}
|
||||
|
||||
if (count($this->selectedHetznerFirewallIds) > 0) {
|
||||
$summary[] = count($this->selectedHetznerFirewallIds).' '.str('firewall')->plural(count($this->selectedHetznerFirewallIds));
|
||||
}
|
||||
|
||||
if (count($this->selectedHetznerNetworkIds) > 0) {
|
||||
$summary[] = count($this->selectedHetznerNetworkIds).' private '.str('network')->plural(count($this->selectedHetznerNetworkIds));
|
||||
}
|
||||
|
||||
if ($this->enable_backups) {
|
||||
$summary[] = 'Backups on';
|
||||
}
|
||||
|
||||
if (! $this->enable_ipv4 || ! $this->enable_ipv6) {
|
||||
$summary[] = collect([
|
||||
$this->enable_ipv4 ? 'IPv4' : null,
|
||||
$this->enable_ipv6 ? 'IPv6' : null,
|
||||
])->filter()->join(' + ') ?: 'No public IP';
|
||||
}
|
||||
|
||||
if ($this->show_cloud_init_script || filled($this->cloud_init_script) || filled($this->selected_cloud_init_script_id)) {
|
||||
$summary[] = 'Cloud-init';
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
public function showCloudInitScript(): void
|
||||
{
|
||||
$this->show_cloud_init_script = true;
|
||||
}
|
||||
|
||||
public function updatedSelectedLocation($value)
|
||||
{
|
||||
// Reset server type and image when location changes
|
||||
$this->selected_server_type = null;
|
||||
$this->selected_image = null;
|
||||
|
||||
$this->selectedHetznerNetworkIds = array_values(array_filter(
|
||||
$this->selectedHetznerNetworkIds,
|
||||
function (int $selectedNetworkId): bool {
|
||||
return collect($this->availableNetworks)->contains('id', $selectedNetworkId);
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
public function updatedSelectedServerType($value)
|
||||
|
|
@ -390,6 +518,14 @@ public function updatedSelectedCloudInitScriptId($value)
|
|||
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value);
|
||||
$this->cloud_init_script = $script->script;
|
||||
$this->cloud_init_script_name = $script->name;
|
||||
$this->show_cloud_init_script = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedSaveCloudInitScript(bool $value): void
|
||||
{
|
||||
if (! $value) {
|
||||
$this->cloud_init_script_name = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -399,12 +535,11 @@ public function clearCloudInitScript()
|
|||
$this->cloud_init_script = '';
|
||||
$this->cloud_init_script_name = '';
|
||||
$this->save_cloud_init_script = false;
|
||||
$this->show_cloud_init_script = false;
|
||||
}
|
||||
|
||||
private function createHetznerServer(string $token): array
|
||||
private function createHetznerServer(HetznerService $hetznerService): array
|
||||
{
|
||||
$hetznerService = new HetznerService($token);
|
||||
|
||||
// Get the private key and extract public key
|
||||
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
|
||||
|
||||
|
|
@ -458,6 +593,18 @@ private function createHetznerServer(string $token): array
|
|||
],
|
||||
];
|
||||
|
||||
$firewallIds = array_values(array_unique($this->selectedHetznerFirewallIds));
|
||||
if ($firewallIds !== []) {
|
||||
$params['firewalls'] = array_map(function (int $firewallId): array {
|
||||
return ['firewall' => $firewallId];
|
||||
}, $firewallIds);
|
||||
}
|
||||
|
||||
$networkIds = array_values(array_unique($this->selectedHetznerNetworkIds));
|
||||
if ($networkIds !== []) {
|
||||
$params['networks'] = $networkIds;
|
||||
}
|
||||
|
||||
// Add cloud-init script if provided
|
||||
if (! empty($this->cloud_init_script)) {
|
||||
$params['user_data'] = $this->cloud_init_script;
|
||||
|
|
@ -473,6 +620,13 @@ public function submit()
|
|||
{
|
||||
$this->validate();
|
||||
|
||||
if (! $this->enable_ipv4 && ! $this->enable_ipv6) {
|
||||
$this->addError('enable_ipv4', 'Enable at least one public IP protocol.');
|
||||
$this->addError('enable_ipv6', 'Enable at least one public IP protocol.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->authorize('create', Server::class);
|
||||
|
||||
|
|
@ -492,9 +646,10 @@ public function submit()
|
|||
}
|
||||
|
||||
$hetznerToken = $this->getHetznerToken();
|
||||
$hetznerService = new HetznerService($hetznerToken);
|
||||
|
||||
// Create server on Hetzner
|
||||
$hetznerServer = $this->createHetznerServer($hetznerToken);
|
||||
$hetznerServer = $this->createHetznerServer($hetznerService);
|
||||
|
||||
// Determine IP address to use (prefer IPv4, fallback to IPv6)
|
||||
$ipAddress = null;
|
||||
|
|
@ -524,6 +679,14 @@ public function submit()
|
|||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
if ($this->enable_backups) {
|
||||
try {
|
||||
$hetznerService->enableServerBackup((int) $hetznerServer['id']);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->from_onboarding) {
|
||||
// Complete the boarding when server is successfully created via Hetzner
|
||||
currentTeam()->update([
|
||||
|
|
|
|||
|
|
@ -118,6 +118,16 @@ public function getSshKeys(): array
|
|||
return $this->requestPaginated('get', '/ssh_keys', 'ssh_keys');
|
||||
}
|
||||
|
||||
public function getFirewalls(): array
|
||||
{
|
||||
return $this->requestPaginated('get', '/firewalls', 'firewalls');
|
||||
}
|
||||
|
||||
public function getNetworks(): array
|
||||
{
|
||||
return $this->requestPaginated('get', '/networks', 'networks');
|
||||
}
|
||||
|
||||
public function uploadSshKey(string $name, string $publicKey): array
|
||||
{
|
||||
$response = $this->request('post', '/ssh_keys', [
|
||||
|
|
@ -136,6 +146,13 @@ public function createServer(array $params): array
|
|||
return $response['server'] ?? [];
|
||||
}
|
||||
|
||||
public function enableServerBackup(int $serverId): array
|
||||
{
|
||||
$response = $this->request('post', "/servers/{$serverId}/actions/enable_backup");
|
||||
|
||||
return $response['action'] ?? [];
|
||||
}
|
||||
|
||||
public function getServer(int $serverId): array
|
||||
{
|
||||
$response = $this->request('get', "/servers/{$serverId}");
|
||||
|
|
|
|||
152
openapi.json
152
openapi.json
|
|
@ -8784,6 +8784,139 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"\/hetzner\/firewalls": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Hetzner"
|
||||
],
|
||||
"summary": "Get Hetzner Firewalls",
|
||||
"description": "Get all existing Hetzner firewalls for the current project.",
|
||||
"operationId": "get-hetzner-firewalls",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cloud_provider_token_uuid",
|
||||
"in": "query",
|
||||
"description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cloud_provider_token_id",
|
||||
"in": "query",
|
||||
"description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.",
|
||||
"required": false,
|
||||
"deprecated": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of Hetzner firewalls.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/hetzner\/networks": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Hetzner"
|
||||
],
|
||||
"summary": "Get Hetzner Networks",
|
||||
"description": "Get all existing Hetzner private networks for the current project.",
|
||||
"operationId": "get-hetzner-networks",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cloud_provider_token_uuid",
|
||||
"in": "query",
|
||||
"description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cloud_provider_token_id",
|
||||
"in": "query",
|
||||
"description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.",
|
||||
"required": false,
|
||||
"deprecated": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of Hetzner networks.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"ip_range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/servers\/hetzner": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
|
@ -8851,6 +8984,11 @@
|
|||
"example": true,
|
||||
"description": "Enable IPv6 (default: true)"
|
||||
},
|
||||
"enable_backups": {
|
||||
"type": "boolean",
|
||||
"example": false,
|
||||
"description": "Enable Hetzner server backups after creation (adds 20% to the monthly server fee)"
|
||||
},
|
||||
"hetzner_ssh_key_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
|
@ -8858,6 +8996,20 @@
|
|||
},
|
||||
"description": "Additional Hetzner SSH key IDs"
|
||||
},
|
||||
"hetzner_firewall_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Existing Hetzner firewall IDs to apply during server creation"
|
||||
},
|
||||
"hetzner_network_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Existing Hetzner network IDs to attach during server creation"
|
||||
},
|
||||
"cloud_init_script": {
|
||||
"type": "string",
|
||||
"description": "Cloud-init YAML script (optional)"
|
||||
|
|
|
|||
92
openapi.yaml
92
openapi.yaml
|
|
@ -5606,6 +5606,86 @@ paths:
|
|||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/hetzner/firewalls:
|
||||
get:
|
||||
tags:
|
||||
- Hetzner
|
||||
summary: 'Get Hetzner Firewalls'
|
||||
description: 'Get all existing Hetzner firewalls for the current project.'
|
||||
operationId: get-hetzner-firewalls
|
||||
parameters:
|
||||
-
|
||||
name: cloud_provider_token_uuid
|
||||
in: query
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: cloud_provider_token_id
|
||||
in: query
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.'
|
||||
required: false
|
||||
deprecated: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'List of Hetzner firewalls.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
properties: { id: { type: integer }, name: { type: string } }
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/hetzner/networks:
|
||||
get:
|
||||
tags:
|
||||
- Hetzner
|
||||
summary: 'Get Hetzner Networks'
|
||||
description: 'Get all existing Hetzner private networks for the current project.'
|
||||
operationId: get-hetzner-networks
|
||||
parameters:
|
||||
-
|
||||
name: cloud_provider_token_uuid
|
||||
in: query
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: cloud_provider_token_id
|
||||
in: query
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.'
|
||||
required: false
|
||||
deprecated: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'List of Hetzner networks.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
properties: { id: { type: integer }, name: { type: string }, ip_range: { type: string } }
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/servers/hetzner:
|
||||
post:
|
||||
tags:
|
||||
|
|
@ -5662,10 +5742,22 @@ paths:
|
|||
type: boolean
|
||||
example: true
|
||||
description: 'Enable IPv6 (default: true)'
|
||||
enable_backups:
|
||||
type: boolean
|
||||
example: false
|
||||
description: 'Enable Hetzner server backups after creation (adds 20% to the monthly server fee)'
|
||||
hetzner_ssh_key_ids:
|
||||
type: array
|
||||
items: { type: integer }
|
||||
description: 'Additional Hetzner SSH key IDs'
|
||||
hetzner_firewall_ids:
|
||||
type: array
|
||||
items: { type: integer }
|
||||
description: 'Existing Hetzner firewall IDs to apply during server creation'
|
||||
hetzner_network_ids:
|
||||
type: array
|
||||
items: { type: integer }
|
||||
description: 'Existing Hetzner network IDs to attach during server creation'
|
||||
cloud_init_script:
|
||||
type: string
|
||||
description: 'Cloud-init YAML script (optional)'
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ @utility info-helper {
|
|||
}
|
||||
|
||||
@utility info-helper-popup {
|
||||
@apply hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 max-w-sm whitespace-normal break-words;
|
||||
@apply hidden absolute right-0 z-40 w-max max-w-[min(20rem,calc(100vw-2rem))] text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 whitespace-normal break-words;
|
||||
}
|
||||
|
||||
@utility buyme {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
@props([
|
||||
'inline' => false,
|
||||
'triggerClass' => '',
|
||||
'panelClass' => '',
|
||||
])
|
||||
|
||||
<div x-data="{
|
||||
dropdownOpen: false,
|
||||
panelStyles: '',
|
||||
|
|
@ -9,7 +15,7 @@
|
|||
this.dropdownOpen = false;
|
||||
},
|
||||
updatePanelPosition() {
|
||||
if (window.innerWidth >= 768) {
|
||||
if ({{ $inline ? 'true' : 'false' }} || window.innerWidth >= 768) {
|
||||
this.panelStyles = '';
|
||||
|
||||
return;
|
||||
|
|
@ -37,9 +43,13 @@
|
|||
this.panelStyles = `position: fixed; left: ${left}px; top: ${top}px;`;
|
||||
});
|
||||
}
|
||||
}" class="relative" @click.outside="close()" x-on:resize.window="if (dropdownOpen) updatePanelPosition()">
|
||||
<button x-ref="trigger" @click="dropdownOpen ? close() : open()"
|
||||
class="inline-flex items-center justify-start pr-8 transition-colors focus:outline-hidden disabled:opacity-50 disabled:pointer-events-none">
|
||||
}" @class(['relative', 'w-full' => $inline]) @click.outside="if (! {{ $inline ? 'true' : 'false' }}) close()" x-on:resize.window="if (dropdownOpen) updatePanelPosition()">
|
||||
<button type="button" x-ref="trigger" @click="dropdownOpen ? close() : open()"
|
||||
@class([
|
||||
'inline-flex items-center justify-start pr-8 transition-colors focus:outline-hidden disabled:opacity-50 disabled:pointer-events-none',
|
||||
'w-full border border-neutral-300 bg-white px-3 py-2 text-left dark:border-coolgray-300 dark:bg-coolgray-100' => $inline,
|
||||
$triggerClass,
|
||||
])>
|
||||
<span class="flex flex-col items-start h-full leading-none">
|
||||
{{ $title }}
|
||||
</span>
|
||||
|
|
@ -50,11 +60,18 @@ class="inline-flex items-center justify-start pr-8 transition-colors focus:outli
|
|||
</svg>
|
||||
</button>
|
||||
|
||||
<div x-ref="panel" x-show="dropdownOpen" @click.away="close()" x-transition:enter="ease-out duration-200"
|
||||
<div x-ref="panel" x-show="dropdownOpen" @click.away="if (! {{ $inline ? 'true' : 'false' }}) close()" x-transition:enter="ease-out duration-200"
|
||||
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
|
||||
:style="panelStyles" class="absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6" x-cloak>
|
||||
<div
|
||||
class="border border-neutral-300 bg-white p-1 shadow-sm dark:border-coolgray-300 dark:bg-coolgray-200">
|
||||
:style="panelStyles" @class([
|
||||
'mt-1 w-full' => $inline,
|
||||
'absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6' => ! $inline,
|
||||
]) x-cloak>
|
||||
<div @class([
|
||||
'border border-neutral-300 bg-white p-1 dark:border-coolgray-300',
|
||||
'shadow-sm dark:bg-coolgray-200' => ! $inline,
|
||||
'border-0 bg-transparent shadow-none dark:border-0 dark:bg-transparent' => $inline,
|
||||
$panelClass,
|
||||
])>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<div x-data="{ open: false }" @click.stop="open = !open" @click.outside="open = false"
|
||||
{{ $attributes->merge(['class' => 'group']) }}>
|
||||
{{ $attributes->merge(['class' => 'group relative inline-block align-middle']) }}>
|
||||
<div class="info-helper">
|
||||
@isset($icon)
|
||||
{{ $icon }}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@
|
|||
@if (isset($serverType['cpu_vendor_info']) && $serverType['cpu_vendor_info'])
|
||||
({{ $serverType['cpu_vendor_info'] }})
|
||||
@endif
|
||||
, {{ $serverType['memory'] }}GB RAM,
|
||||
, {{ $serverType['memory'] }}GB RAM,
|
||||
{{ $serverType['disk'] }}GB
|
||||
@if (isset($serverType['architecture']))
|
||||
[{{ $serverType['architecture'] }}]
|
||||
|
|
@ -135,58 +135,118 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
|
|||
</p>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<x-forms.datalist label="Additional SSH Keys (from Hetzner)" id="selectedHetznerSshKeyIds"
|
||||
helper="Select existing SSH keys from your Hetzner account to add to this server. The Coolify SSH key will be automatically added."
|
||||
:multiple="true" :disabled="count($hetznerSshKeys) === 0" :placeholder="count($hetznerSshKeys) > 0
|
||||
? 'Search and select SSH keys...'
|
||||
: 'No SSH keys found in Hetzner account'">
|
||||
@foreach ($hetznerSshKeys as $sshKey)
|
||||
<option value="{{ $sshKey['id'] }}">
|
||||
{{ $sshKey['name'] }} - {{ substr($sshKey['fingerprint'], 0, 20) }}...
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium">Network Configuration</label>
|
||||
<div class="flex gap-4">
|
||||
<x-forms.checkbox id="enable_ipv4" label="Enable IPv4"
|
||||
helper="Enable public IPv4 address for this server" />
|
||||
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
|
||||
helper="Enable public IPv6 address for this server" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
|
||||
@if ($saved_cloud_init_scripts->count() > 0)
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
|
||||
<option value="">Load saved script...</option>
|
||||
@foreach ($saved_cloud_init_scripts as $script)
|
||||
<option value="{{ $script->id }}">{{ $script->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
<x-forms.button type="button" wire:click="clearCloudInitScript">
|
||||
Clear
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<x-dropdown inline panelClass="max-h-[55vh] overflow-y-auto scrollbar">
|
||||
<x-slot:title>
|
||||
<span class="text-sm font-medium">Advanced Hetzner options</span>
|
||||
<span class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">SSH keys, firewalls, private networks, backups, and cloud-init.</span>
|
||||
@if (count($this->advancedHetznerOptionsSummary) > 0)
|
||||
<span class="mt-1 flex flex-wrap gap-1.5">
|
||||
@foreach ($this->advancedHetznerOptionsSummary as $summaryItem)
|
||||
<span class="rounded bg-neutral-200 px-2 py-0.5 text-xs text-neutral-700 dark:bg-coolgray-100 dark:text-neutral-300">
|
||||
{{ $summaryItem }}
|
||||
</span>
|
||||
@endforeach
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<x-forms.textarea id="cloud_init_script" label=""
|
||||
helper="Add a cloud-init script to run when the server is created. See Hetzner's documentation for details."
|
||||
rows="8" />
|
||||
</x-slot>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
|
||||
<div class="flex-1">
|
||||
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
|
||||
<div class="flex w-full flex-col gap-4 p-3">
|
||||
<div>
|
||||
<x-forms.datalist label="Extra SSH Keys" id="selectedHetznerSshKeyIds"
|
||||
helper="Select existing SSH keys from your Hetzner account to add to this server. The Coolify SSH key will be automatically added."
|
||||
:multiple="true" :disabled="count($hetznerSshKeys) === 0" :placeholder="count($hetznerSshKeys) > 0
|
||||
? 'Search and select SSH keys...'
|
||||
: 'No SSH keys found in Hetzner account'">
|
||||
@foreach ($hetznerSshKeys as $sshKey)
|
||||
<option value="{{ $sshKey['id'] }}">
|
||||
{{ $sshKey['name'] }} - {{ substr($sshKey['fingerprint'], 0, 20) }}...
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<x-forms.datalist label="Firewalls" id="selectedHetznerFirewallIds"
|
||||
helper="Optionally apply existing Hetzner firewalls when the server is created."
|
||||
:multiple="true" :disabled="count($hetznerFirewalls) === 0" :placeholder="count($hetznerFirewalls) > 0
|
||||
? 'Search and select firewalls...'
|
||||
: 'No firewalls found in Hetzner account'">
|
||||
@foreach ($hetznerFirewalls as $firewall)
|
||||
<option value="{{ $firewall['id'] }}">
|
||||
{{ $firewall['name'] }}
|
||||
@if (isset($firewall['rules']))
|
||||
- {{ count($firewall['rules']) }} rules
|
||||
@endif
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
|
||||
<x-forms.datalist label="Private Networks" id="selectedHetznerNetworkIds"
|
||||
helper="Optionally attach one or more private networks. Networks are filtered to the selected location's network zone when possible."
|
||||
:multiple="true" :disabled="count($this->availableNetworks) === 0" :placeholder="count($this->availableNetworks) > 0
|
||||
? 'Search and select networks...'
|
||||
: 'No compatible networks found'">
|
||||
@foreach ($this->availableNetworks as $network)
|
||||
<option value="{{ $network['id'] }}">
|
||||
{{ $network['name'] }} - {{ $network['ip_range'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<x-forms.checkbox id="enable_ipv4" label="Enable IPv4"
|
||||
helper="Enable public IPv4 address for this server" fullWidth />
|
||||
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
|
||||
helper="Enable public IPv6 address for this server" fullWidth />
|
||||
<x-forms.checkbox id="enable_backups" label="Enable Hetzner Backups" fullWidth
|
||||
helper="Hetzner bills backups at an additional 20% of the server monthly fee{{ $this->selectedServerBackupSurcharge ? ' (about ' . $this->selectedServerBackupSurcharge . '/mo for the selected server type)' : '' }}." />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
@if (! $show_cloud_init_script && empty($cloud_init_script) && empty($selected_cloud_init_script_id))
|
||||
<div>
|
||||
<x-forms.button type="button" wire:click="showCloudInitScript">
|
||||
Add cloud-init script
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
|
||||
@if ($saved_cloud_init_scripts->count() > 0)
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
|
||||
<option value="">Load saved script...</option>
|
||||
@foreach ($saved_cloud_init_scripts as $script)
|
||||
<option value="{{ $script->id }}">{{ $script->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
<x-forms.button type="button" wire:click="clearCloudInitScript">
|
||||
Clear
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@else
|
||||
<x-forms.button type="button" wire:click="clearCloudInitScript">
|
||||
Remove
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<x-forms.textarea id="cloud_init_script" label=""
|
||||
helper="Add a cloud-init script to run when the server is created. See Hetzner's documentation for details."
|
||||
rows="8" />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
|
||||
@if ($save_cloud_init_script)
|
||||
<div class="flex-1">
|
||||
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dropdown>
|
||||
|
||||
<div class="flex gap-2 justify-between">
|
||||
<x-forms.button type="button" wire:click="previousStep">
|
||||
|
|
@ -201,4 +261,4 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
|
|||
@endif
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@
|
|||
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::get('/hetzner/firewalls', [HetznerController::class, 'firewalls'])->middleware(['api.ability:read']);
|
||||
Route::get('/hetzner/networks', [HetznerController::class, 'networks'])->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']);
|
||||
|
|
|
|||
|
|
@ -6,14 +6,22 @@
|
|||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request as HttpRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('cache.default', 'array');
|
||||
config()->set('app.maintenance.driver', 'file');
|
||||
config()->set('app.maintenance.store', 'array');
|
||||
|
||||
InstanceSettings::query()->whereKey(0)->delete();
|
||||
$settings = new InstanceSettings(['is_api_enabled' => true]);
|
||||
$settings = new InstanceSettings([
|
||||
'is_api_enabled' => true,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
Once::flush();
|
||||
|
|
@ -29,15 +37,25 @@
|
|||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
// Create a Hetzner cloud provider token
|
||||
$this->hetznerToken = CloudProviderToken::factory()->create([
|
||||
$this->hetznerToken = CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'name' => 'Test Hetzner Token',
|
||||
'token' => 'test-hetzner-api-token',
|
||||
]);
|
||||
|
||||
// Create a private key
|
||||
$this->privateKey = PrivateKey::factory()->create([
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test SSH key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -239,17 +257,105 @@
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/hetzner/firewalls', function () {
|
||||
test('gets Hetzner firewalls', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
|
||||
'firewalls' => [
|
||||
['id' => 38, 'name' => 'web-firewall', 'rules' => []],
|
||||
['id' => 39, 'name' => 'ssh-firewall', 'rules' => []],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['name' => 'web-firewall']);
|
||||
});
|
||||
|
||||
test('member read token cannot use a stored cloud provider token', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
|
||||
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
|
||||
'firewalls' => [['id' => 38, 'name' => 'web-firewall']],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$memberToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertForbidden();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/hetzner/networks', function () {
|
||||
test('gets Hetzner networks', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/networks*' => Http::response([
|
||||
'networks' => [
|
||||
['id' => 456, 'name' => 'private-eu', 'ip_range' => '10.0.0.0/16', 'subnets' => []],
|
||||
['id' => 457, 'name' => 'private-us', 'ip_range' => '10.1.0.0/16', 'subnets' => []],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['name' => 'private-eu']);
|
||||
});
|
||||
|
||||
test('member read token cannot use a stored cloud provider token', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
|
||||
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/networks*' => Http::response([
|
||||
'networks' => [['id' => 456, 'name' => 'private-eu']],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$memberToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertForbidden();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
});
|
||||
|
||||
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_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201),
|
||||
'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,
|
||||
|
|
@ -289,15 +395,134 @@
|
|||
]);
|
||||
});
|
||||
|
||||
test('enables backups after creating a Hetzner server when requested', function () {
|
||||
Http::fake(function (HttpRequest $request) {
|
||||
if ($request->method() === 'GET' && str_starts_with($request->url(), 'https://api.hetzner.cloud/v1/ssh_keys')) {
|
||||
return Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/ssh_keys') {
|
||||
return Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers') {
|
||||
return Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
'name' => 'test-server',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '1.2.3.4'],
|
||||
'ipv6' => ['ip' => '2001:db8::1'],
|
||||
],
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup') {
|
||||
return Http::response([
|
||||
'action' => ['id' => 789, 'command' => 'enable_backup', 'status' => 'running'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
return Http::response([], 404);
|
||||
});
|
||||
|
||||
$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,
|
||||
'enable_backups' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
Http::assertSent(fn (HttpRequest $request) => $request->method() === 'POST'
|
||||
&& $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup');
|
||||
});
|
||||
|
||||
test('registers server when backup enablement fails after Hetzner creation', function () {
|
||||
Http::fake(function (HttpRequest $request) {
|
||||
if ($request->method() === 'GET' && str_starts_with($request->url(), 'https://api.hetzner.cloud/v1/ssh_keys')) {
|
||||
return Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/ssh_keys') {
|
||||
return Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers') {
|
||||
return Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
'name' => 'test-server',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '1.2.3.4'],
|
||||
'ipv6' => ['ip' => '2001:db8::1'],
|
||||
],
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup') {
|
||||
return Http::response([
|
||||
'error' => ['message' => 'backup unavailable'],
|
||||
], 500);
|
||||
}
|
||||
|
||||
return Http::response([], 404);
|
||||
});
|
||||
|
||||
$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,
|
||||
'enable_backups' => true,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonFragment(['hetzner_server_id' => 456, 'ip' => '1.2.3.4']);
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-server',
|
||||
'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_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201),
|
||||
'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,
|
||||
|
|
@ -331,13 +556,10 @@
|
|||
'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',
|
||||
$response->assertStatus(400);
|
||||
$response->assertJson([
|
||||
'message' => 'Invalid request.',
|
||||
'error' => 'Invalid JSON.',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -375,13 +597,13 @@
|
|||
|
||||
test('prefers IPv4 when both IPv4 and IPv6 are enabled', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'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,
|
||||
|
|
@ -412,13 +634,13 @@
|
|||
|
||||
test('uses IPv6 when only IPv6 is enabled', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'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,
|
||||
|
|
@ -447,6 +669,75 @@
|
|||
$response->assertJsonFragment(['ip' => '2001:db8::1']);
|
||||
});
|
||||
|
||||
test('rejects server creation when both public IP protocols are disabled', function () {
|
||||
Http::fake();
|
||||
|
||||
$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' => false,
|
||||
'enable_ipv6' => false,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['enable_ipv4', 'enable_ipv6']);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('passes selected firewalls and networks to Hetzner server creation', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'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,
|
||||
'name' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'hetzner_firewall_ids' => [38, 39],
|
||||
'hetzner_network_ids' => [456, 457, 456],
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
Http::assertSent(function (HttpRequest $request): bool {
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://api.hetzner.cloud/v1/servers'
|
||||
&& $request['networks'] === [456, 457]
|
||||
&& $request['firewalls'] === [
|
||||
['firewall' => 38],
|
||||
['firewall' => 39],
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects extra fields not in allowed list', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
|
|
@ -545,4 +836,38 @@
|
|||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('firewalls endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner firewalls.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('networks endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/networks*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner networks.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByHetzner;
|
||||
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;
|
||||
use Livewire\Livewire;
|
||||
|
||||
// Note: Full Livewire integration tests require database setup
|
||||
|
|
@ -159,7 +162,7 @@
|
|||
|
||||
test('completes boarding when server is created from onboarding', function () {
|
||||
// Verify boarding is initially enabled
|
||||
expect($this->team->fresh()->show_boarding)->toBeTrue();
|
||||
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
|
||||
|
||||
// Mount the component with from_onboarding flag
|
||||
$component = Livewire::test(ByHetzner::class)
|
||||
|
|
@ -176,13 +179,142 @@
|
|||
|
||||
test('boarding flag remains unchanged when not from onboarding', function () {
|
||||
// Verify boarding is initially enabled
|
||||
expect($this->team->fresh()->show_boarding)->toBeTrue();
|
||||
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
|
||||
|
||||
// Mount the component without from_onboarding flag (default false)
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('from_onboarding', false);
|
||||
|
||||
// Boarding should still be enabled since it wasn't created from onboarding
|
||||
expect($this->team->fresh()->show_boarding)->toBeTrue();
|
||||
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
|
||||
});
|
||||
|
||||
test('uses the shared dropdown UI for advanced Hetzner options', function () {
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('current_step', 2)
|
||||
->assertSee('Advanced Hetzner options')
|
||||
->assertSeeHtml('dropdownOpen')
|
||||
->assertSeeHtml('x-ref="panel"')
|
||||
->assertSeeHtml('dark:bg-coolgray-100')
|
||||
->assertSeeHtml('dark:bg-transparent')
|
||||
->assertSeeHtml('@click.outside="if (! true) close()"');
|
||||
});
|
||||
|
||||
test('renders advanced Hetzner option controls inside the dropdown menu', function () {
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('current_step', 2)
|
||||
->assertSee('Extra SSH Keys')
|
||||
->assertSee('Firewalls')
|
||||
->assertSee('Private Networks')
|
||||
->assertSee('Enable Hetzner Backups')
|
||||
->assertSee('Add cloud-init script')
|
||||
->assertSee('additional 20% of the server monthly fee');
|
||||
});
|
||||
|
||||
test('shows the cloud init script name only when saving the script', function () {
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('current_step', 2)
|
||||
->set('show_cloud_init_script', true)
|
||||
->assertSee('Cloud-Init Script')
|
||||
->assertSee('Save this script for later use')
|
||||
->assertDontSee('Script name...')
|
||||
->set('save_cloud_init_script', true)
|
||||
->assertSee('Script name...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hetzner data loading', function () {
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->hetznerToken = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'test-hetzner-api-token',
|
||||
]);
|
||||
|
||||
$this->privateKey = PrivateKey::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('loads firewalls and networks for the selected Hetzner token', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/locations*' => Http::response([
|
||||
'locations' => [
|
||||
['id' => 1, 'name' => 'nbg1', 'city' => 'Nuremberg', 'country' => 'DE', 'network_zone' => 'eu-central'],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
|
||||
'server_types' => [
|
||||
['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'cores' => 1, 'memory' => 2.0, 'disk' => 20, 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/images*' => Http::response([
|
||||
'images' => [
|
||||
['id' => 15512617, 'name' => 'ubuntu-24.04', 'type' => 'system', 'deprecated' => false, 'architecture' => 'x86'],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
|
||||
'firewalls' => [
|
||||
['id' => 38, 'name' => 'web-firewall', 'rules' => []],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/networks*' => Http::response([
|
||||
'networks' => [
|
||||
[
|
||||
'id' => 456,
|
||||
'name' => 'private-eu',
|
||||
'ip_range' => '10.0.0.0/16',
|
||||
'subnets' => [
|
||||
['type' => 'cloud', 'network_zone' => 'eu-central'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ByHetzner::class)
|
||||
->set('selected_token_id', $this->hetznerToken->id)
|
||||
->call('nextStep')
|
||||
->assertSet('current_step', 2);
|
||||
|
||||
expect($component->get('hetznerFirewalls'))->toHaveCount(1)
|
||||
->and($component->get('hetznerNetworks'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('rejects submitting without a public IP protocol before calling Hetzner', function () {
|
||||
Http::fake();
|
||||
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('current_step', 2)
|
||||
->set('selected_token_id', $this->hetznerToken->id)
|
||||
->set('server_name', 'test-server')
|
||||
->set('selected_location', 'nbg1')
|
||||
->set('selected_server_type', 'cx11')
|
||||
->set('selected_image', 15512617)
|
||||
->set('private_key_id', $this->privateKey->id)
|
||||
->set('enable_ipv4', false)
|
||||
->set('enable_ipv6', false)
|
||||
->call('submit')
|
||||
->assertHasErrors(['enable_ipv4', 'enable_ipv6']);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue