mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(cloud-providers): show why Hetzner, DigitalOcean, and Vultr API requests fail
Provider errors now return 502 with a safe message, the provider status, and the provider error code instead of a bare 500. Hetzner no longer sends a create request again after a 5xx, and a VPS that Coolify did not save is deleted.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* An error response from a cloud provider API (Hetzner, DigitalOcean, Vultr).
|
||||
*
|
||||
* @see https://docs.hetzner.cloud/reference/cloud#errors
|
||||
*/
|
||||
class CloudProviderApiException extends Exception
|
||||
{
|
||||
public readonly ?string $errorCode;
|
||||
|
||||
/** @var array<int, string> */
|
||||
public readonly array $invalidFields;
|
||||
|
||||
/**
|
||||
* The error code and field names come from the provider, so only values that look like identifiers are kept.
|
||||
*
|
||||
* @param array<int, mixed> $invalidFields
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $provider,
|
||||
string $providerMessage,
|
||||
public readonly int $status,
|
||||
mixed $errorCode = null,
|
||||
array $invalidFields = [],
|
||||
) {
|
||||
parent::__construct("{$provider} API error: {$providerMessage}", $status);
|
||||
|
||||
$errorCode = is_string($errorCode) ? strtolower($errorCode) : null;
|
||||
$this->errorCode = $errorCode !== null && preg_match('/^[a-z_]{1,64}$/', $errorCode) ? $errorCode : null;
|
||||
$this->invalidFields = array_values(array_filter(
|
||||
$invalidFields,
|
||||
fn ($name) => is_string($name) && preg_match('/^[a-z0-9_.\[\]]{1,64}$/i', $name)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* A message that is safe to return to API clients. It is built only from the
|
||||
* HTTP status, the error code, and field names, never from the provider's free-text message.
|
||||
*/
|
||||
public function safeMessage(): string
|
||||
{
|
||||
return match (true) {
|
||||
$this->errorCode === 'resource_limit_exceeded' => "Your {$this->provider} account has reached a resource limit, for example the server limit. Remove unused resources or ask {$this->provider} for a higher limit.",
|
||||
$this->errorCode === 'resource_unavailable' => 'The selected server type, image, or location is not available now. Select a different location or server type.',
|
||||
$this->errorCode === 'uniqueness_error' => "A resource with the same name already exists in your {$this->provider} account, for example a server or an SSH key. Use a different name.",
|
||||
$this->errorCode === 'maintenance', $this->status === 423, $this->status >= 500 => "{$this->provider} cannot process the request now. Try again later.",
|
||||
$this->status === 401 => "The {$this->provider} API token is invalid or expired. Update the cloud provider token in Coolify.",
|
||||
$this->status === 403 => "The {$this->provider} API token does not have permission for this request. Use a token with read and write access.",
|
||||
$this->status === 404 => "{$this->provider} could not find a requested resource, for example the location, server type, image, or SSH key.",
|
||||
$this->status === 409 => "{$this->provider} rejected the request because of a conflict with an existing resource.",
|
||||
$this->status === 422 => "{$this->provider} rejected the request as invalid. Check the selected values and your account limits.".($this->invalidFields !== [] ? ' Invalid fields: '.implode(', ', $this->invalidFields).'.' : ''),
|
||||
default => "{$this->provider} returned HTTP {$this->status}".($this->errorCode !== null ? " (error code: {$this->errorCode})" : '').'.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The API response for a failed provider request: 502, because the upstream API rejected it.
|
||||
*/
|
||||
public function toJsonResponse(string $failureMessage): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => $failureMessage.' '.$this->safeMessage(),
|
||||
'provider_status' => $this->status,
|
||||
'provider_error_code' => $this->errorCode,
|
||||
], 502);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\CloudProviderApiException;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
|
||||
use App\Http\Controllers\Controller;
|
||||
@@ -210,6 +211,7 @@ class DigitalOceanController extends Controller
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
new OA\Response(response: 429, description: 'DigitalOcean rate limit exceeded.'),
|
||||
new OA\Response(response: 502, ref: '#/components/responses/502'),
|
||||
]
|
||||
)]
|
||||
public function createServer(Request $request): JsonResponse
|
||||
@@ -395,12 +397,13 @@ class DigitalOceanController extends Controller
|
||||
}
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable $e) {
|
||||
} catch (CloudProviderApiException $e) {
|
||||
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
|
||||
|
||||
logger()->error('Failed to create DigitalOcean server', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return $e->toJsonResponse('Failed to create DigitalOcean server.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
|
||||
report($e);
|
||||
|
||||
return response()->json(['message' => 'Failed to create DigitalOcean server.'], 500);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\CloudProviderApiException;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
|
||||
use App\Http\Controllers\Controller;
|
||||
@@ -90,6 +91,7 @@ class HetznerController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(response: 502, ref: '#/components/responses/502'),
|
||||
]
|
||||
)]
|
||||
public function locations(Request $request)
|
||||
@@ -127,7 +129,11 @@ class HetznerController extends Controller
|
||||
$locations = $hetznerService->getLocations();
|
||||
|
||||
return response()->json($locations);
|
||||
} catch (CloudProviderApiException $e) {
|
||||
return $e->toJsonResponse('Failed to fetch Hetzner locations.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500);
|
||||
}
|
||||
}
|
||||
@@ -212,6 +218,7 @@ class HetznerController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(response: 502, ref: '#/components/responses/502'),
|
||||
]
|
||||
)]
|
||||
public function serverTypes(Request $request)
|
||||
@@ -249,7 +256,11 @@ class HetznerController extends Controller
|
||||
$serverTypes = $hetznerService->getServerTypes();
|
||||
|
||||
return response()->json($serverTypes);
|
||||
} catch (CloudProviderApiException $e) {
|
||||
return $e->toJsonResponse('Failed to fetch Hetzner server types.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500);
|
||||
}
|
||||
}
|
||||
@@ -312,6 +323,7 @@ class HetznerController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(response: 502, ref: '#/components/responses/502'),
|
||||
]
|
||||
)]
|
||||
public function images(Request $request)
|
||||
@@ -362,7 +374,11 @@ class HetznerController extends Controller
|
||||
});
|
||||
|
||||
return response()->json(array_values($filtered));
|
||||
} catch (CloudProviderApiException $e) {
|
||||
return $e->toJsonResponse('Failed to fetch Hetzner images.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500);
|
||||
}
|
||||
}
|
||||
@@ -422,6 +438,7 @@ class HetznerController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(response: 502, ref: '#/components/responses/502'),
|
||||
]
|
||||
)]
|
||||
public function sshKeys(Request $request)
|
||||
@@ -459,7 +476,11 @@ class HetznerController extends Controller
|
||||
$sshKeys = $hetznerService->getSshKeys();
|
||||
|
||||
return response()->json($sshKeys);
|
||||
} catch (CloudProviderApiException $e) {
|
||||
return $e->toJsonResponse('Failed to fetch Hetzner SSH keys.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500);
|
||||
}
|
||||
}
|
||||
@@ -517,6 +538,7 @@ class HetznerController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(response: 502, ref: '#/components/responses/502'),
|
||||
]
|
||||
)]
|
||||
public function firewalls(Request $request)
|
||||
@@ -553,7 +575,11 @@ class HetznerController extends Controller
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
|
||||
return response()->json($hetznerService->getFirewalls());
|
||||
} catch (CloudProviderApiException $e) {
|
||||
return $e->toJsonResponse('Failed to fetch Hetzner firewalls.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner firewalls.'], 500);
|
||||
}
|
||||
}
|
||||
@@ -612,6 +638,7 @@ class HetznerController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(response: 502, ref: '#/components/responses/502'),
|
||||
]
|
||||
)]
|
||||
public function networks(Request $request)
|
||||
@@ -648,7 +675,11 @@ class HetznerController extends Controller
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
|
||||
return response()->json($hetznerService->getNetworks());
|
||||
} catch (CloudProviderApiException $e) {
|
||||
return $e->toJsonResponse('Failed to fetch Hetzner networks.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner networks.'], 500);
|
||||
}
|
||||
}
|
||||
@@ -728,6 +759,7 @@ class HetznerController extends Controller
|
||||
response: 429,
|
||||
ref: '#/components/responses/429',
|
||||
),
|
||||
new OA\Response(response: 502, ref: '#/components/responses/502'),
|
||||
]
|
||||
)]
|
||||
public function createServer(Request $request)
|
||||
@@ -1012,7 +1044,18 @@ class HetznerController extends Controller
|
||||
}
|
||||
|
||||
return $response;
|
||||
} catch (CloudProviderApiException $e) {
|
||||
return $e->toJsonResponse('Failed to create Hetzner server.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
if (isset($hetznerService, $hetznerServer['id'])) {
|
||||
try {
|
||||
$hetznerService->deleteServer((int) $hetznerServer['id']);
|
||||
} catch (\Throwable $cleanupError) {
|
||||
report($cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Failed to create Hetzner server.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,17 @@ use OpenApi\Attributes as OA;
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Rate limit exceeded. Please try again later.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 502,
|
||||
description: 'The cloud provider API rejected the request.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'Failed to create Hetzner server. The Hetzner API token does not have permission for this request. Use a token with read and write access.'),
|
||||
new OA\Property(property: 'provider_status', type: 'integer', description: 'The HTTP status from the cloud provider API.', example: 403),
|
||||
new OA\Property(property: 'provider_error_code', type: 'string', nullable: true, description: 'The error code from the cloud provider API, if it has one.', example: 'forbidden'),
|
||||
]
|
||||
)),
|
||||
],
|
||||
)]
|
||||
class OpenApi
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\CloudProviderApiException;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
|
||||
use App\Http\Controllers\Controller;
|
||||
@@ -193,6 +194,7 @@ class VultrController extends Controller
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'),
|
||||
new OA\Response(response: 422, description: 'Validation failed.'),
|
||||
new OA\Response(response: 429, description: 'Vultr API rate limit exceeded.'),
|
||||
new OA\Response(response: 502, ref: '#/components/responses/502'),
|
||||
]
|
||||
)]
|
||||
public function createServer(Request $request): JsonResponse
|
||||
@@ -403,12 +405,13 @@ class VultrController extends Controller
|
||||
}
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable $e) {
|
||||
} catch (CloudProviderApiException $e) {
|
||||
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
|
||||
|
||||
logger()->error('Failed to create Vultr server', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return $e->toJsonResponse('Failed to create Vultr server.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
|
||||
report($e);
|
||||
|
||||
return response()->json(['message' => 'Failed to create Vultr server.'], 500);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire\Server\New;
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\CloudProviderApiException;
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
@@ -12,7 +13,6 @@ use App\Rules\ValidCloudInitYaml;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\HetznerService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -358,22 +358,13 @@ class ByHetzner extends Component
|
||||
$this->loading_data = false;
|
||||
} catch (\Throwable $e) {
|
||||
$this->loading_data = false;
|
||||
$this->provider_data_error = $this->providerDataErrorMessage('Hetzner', $e, 'error.message');
|
||||
$this->provider_data_error = $e instanceof CloudProviderApiException
|
||||
? $e->getMessage()
|
||||
: 'Hetzner API error: '.$e->getMessage();
|
||||
$this->dispatch('error', $this->provider_data_error);
|
||||
}
|
||||
}
|
||||
|
||||
private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string
|
||||
{
|
||||
$details = $e->getMessage();
|
||||
|
||||
if ($e instanceof RequestException && $e->response) {
|
||||
$details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details;
|
||||
}
|
||||
|
||||
return "{$providerName} API error: {$details}";
|
||||
}
|
||||
|
||||
private function getCpuVendorInfo(array $serverType): ?string
|
||||
{
|
||||
$name = strtolower($serverType['name'] ?? '');
|
||||
@@ -766,20 +757,31 @@ class ByHetzner extends Component
|
||||
if (! isset($e->errors()['server'])) {
|
||||
throw $e;
|
||||
}
|
||||
if (isset($hetznerService, $hetznerServer['id'])) {
|
||||
try {
|
||||
$hetznerService->deleteServer((int) $hetznerServer['id']);
|
||||
} catch (\Throwable $cleanupError) {
|
||||
report($cleanupError);
|
||||
}
|
||||
}
|
||||
$this->deleteUntrackedHetznerServer($hetznerService ?? null, $hetznerServer['id'] ?? null);
|
||||
|
||||
return $this->dispatch('error', 'You have reached the server limit for your subscription.');
|
||||
} catch (\Throwable $e) {
|
||||
if (! isset($server)) {
|
||||
$this->deleteUntrackedHetznerServer($hetznerService ?? null, $hetznerServer['id'] ?? null);
|
||||
}
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteUntrackedHetznerServer(?HetznerService $hetznerService, ?int $hetznerServerId): void
|
||||
{
|
||||
if (! $hetznerService || ! $hetznerServerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$hetznerService->deleteServer($hetznerServerId);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.new.by-hetzner');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\CloudProviderApiException;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -45,7 +46,7 @@ class DigitalOceanService
|
||||
);
|
||||
}
|
||||
|
||||
throw new \Exception('DigitalOcean API error: '.$response->json('message', 'Unknown error'), $response->status());
|
||||
throw new CloudProviderApiException('DigitalOcean', $response->json('message', 'Unknown error'), $response->status(), $response->json('id'));
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\CloudProviderApiException;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -44,7 +45,17 @@ class HetznerService
|
||||
|
||||
// Exponential backoff for other retriable errors: 100ms, 200ms, 400ms
|
||||
return $attempt * 100;
|
||||
})
|
||||
}, function (\Exception $exception) use ($method) {
|
||||
if ($exception instanceof RequestException && $exception->response->status() === 429) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// A POST that failed with a 5xx or a timeout can still have created the resource, so only idempotent requests are sent again.
|
||||
// Client errors such as 401, 403, or 422 do not change when the request is sent again.
|
||||
$isServerError = ! $exception instanceof RequestException || $exception->response->status() >= 500;
|
||||
|
||||
return $isServerError && in_array(strtolower($method), ['get', 'delete'], true);
|
||||
}, throw: false)
|
||||
->{$method}($this->baseUrl.$endpoint, $data);
|
||||
|
||||
if (! $response->successful()) {
|
||||
@@ -61,7 +72,13 @@ class HetznerService
|
||||
);
|
||||
}
|
||||
|
||||
throw new \Exception('Hetzner API error: '.$response->json('error.message', 'Unknown error'));
|
||||
throw new CloudProviderApiException(
|
||||
'Hetzner',
|
||||
$response->json('error.message', 'Unknown error'),
|
||||
$response->status(),
|
||||
$response->json('error.code'),
|
||||
collect($response->json('error.details.fields', []))->pluck('name')->all(),
|
||||
);
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\CloudProviderApiException;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
@@ -28,7 +29,7 @@ class VultrService
|
||||
);
|
||||
}
|
||||
|
||||
throw new \Exception('Vultr API error: '.$response->json('error', 'Unknown error'), $response->status());
|
||||
throw new CloudProviderApiException('Vultr', $response->json('error', 'Unknown error'), $response->status());
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
|
||||
@@ -11529,6 +11529,9 @@
|
||||
},
|
||||
"429": {
|
||||
"description": "DigitalOcean rate limit exceeded."
|
||||
},
|
||||
"502": {
|
||||
"$ref": "#\/components\/responses\/502"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -12567,6 +12570,9 @@
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"502": {
|
||||
"$ref": "#\/components\/responses\/502"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -12678,6 +12684,9 @@
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"502": {
|
||||
"$ref": "#\/components\/responses\/502"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -12758,6 +12767,9 @@
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"502": {
|
||||
"$ref": "#\/components\/responses\/502"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -12829,6 +12841,9 @@
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"502": {
|
||||
"$ref": "#\/components\/responses\/502"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -12894,6 +12909,9 @@
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"502": {
|
||||
"$ref": "#\/components\/responses\/502"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -12962,6 +12980,9 @@
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"502": {
|
||||
"$ref": "#\/components\/responses\/502"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -13122,6 +13143,9 @@
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#\/components\/responses\/429"
|
||||
},
|
||||
"502": {
|
||||
"$ref": "#\/components\/responses\/502"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -24009,6 +24033,9 @@
|
||||
},
|
||||
"429": {
|
||||
"description": "Vultr API rate limit exceeded."
|
||||
},
|
||||
"502": {
|
||||
"$ref": "#\/components\/responses\/502"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -26425,6 +26452,35 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "The cloud provider API rejected the request.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Failed to create Hetzner server. The Hetzner API token does not have permission for this request. Use a token with read and write access."
|
||||
},
|
||||
"provider_status": {
|
||||
"description": "The HTTP status from the cloud provider API.",
|
||||
"type": "integer",
|
||||
"example": 403
|
||||
},
|
||||
"provider_error_code": {
|
||||
"description": "The error code from the cloud provider API, if it has one.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"example": "forbidden"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
|
||||
@@ -7536,6 +7536,8 @@ paths:
|
||||
description: 'Validation failed.'
|
||||
'429':
|
||||
description: 'DigitalOcean rate limit exceeded.'
|
||||
'502':
|
||||
$ref: '#/components/responses/502'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
@@ -8117,6 +8119,8 @@ paths:
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'502':
|
||||
$ref: '#/components/responses/502'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
@@ -8157,6 +8161,8 @@ paths:
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'502':
|
||||
$ref: '#/components/responses/502'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
@@ -8197,6 +8203,8 @@ paths:
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'502':
|
||||
$ref: '#/components/responses/502'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
@@ -8237,6 +8245,8 @@ paths:
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'502':
|
||||
$ref: '#/components/responses/502'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
@@ -8277,6 +8287,8 @@ paths:
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'502':
|
||||
$ref: '#/components/responses/502'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
@@ -8317,6 +8329,8 @@ paths:
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'502':
|
||||
$ref: '#/components/responses/502'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
@@ -8423,6 +8437,8 @@ paths:
|
||||
$ref: '#/components/responses/422'
|
||||
'429':
|
||||
$ref: '#/components/responses/429'
|
||||
'502':
|
||||
$ref: '#/components/responses/502'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
@@ -15197,6 +15213,8 @@ paths:
|
||||
description: 'Validation failed.'
|
||||
'429':
|
||||
description: 'Vultr API rate limit exceeded.'
|
||||
'502':
|
||||
$ref: '#/components/responses/502'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
@@ -16935,6 +16953,26 @@ components:
|
||||
type: string
|
||||
example: 'Rate limit exceeded. Please try again later.'
|
||||
type: object
|
||||
'502':
|
||||
description: 'The cloud provider API rejected the request.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
example: 'Failed to create Hetzner server. The Hetzner API token does not have permission for this request. Use a token with read and write access.'
|
||||
provider_status:
|
||||
description: 'The HTTP status from the cloud provider API.'
|
||||
type: integer
|
||||
example: 403
|
||||
provider_error_code:
|
||||
description: 'The error code from the cloud provider API, if it has one.'
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
example: forbidden
|
||||
type: object
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
|
||||
@@ -808,8 +808,142 @@ describe('POST /api/v1/servers/hetzner', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/servers/hetzner upstream errors', function () {
|
||||
beforeEach(function () {
|
||||
$this->createServerRequest = fn () => $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,
|
||||
]);
|
||||
|
||||
$this->fakeHetzner = fn (Closure $createServerResponse) => Http::fake(function (HttpRequest $request) use ($createServerResponse) {
|
||||
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 $createServerResponse();
|
||||
}
|
||||
if ($request->method() === 'DELETE' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456') {
|
||||
return Http::response([], 200);
|
||||
}
|
||||
|
||||
return Http::response([], 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('explains a read-only token without leaking the Hetzner message and does not retry', function () {
|
||||
($this->fakeHetzner)(fn () => Http::response([
|
||||
'error' => ['code' => 'forbidden', 'message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 403));
|
||||
|
||||
$response = ($this->createServerRequest)();
|
||||
|
||||
$response->assertStatus(502);
|
||||
$response->assertExactJson([
|
||||
'message' => 'Failed to create Hetzner server. The Hetzner API token does not have permission for this request. Use a token with read and write access.',
|
||||
'provider_status' => 403,
|
||||
'provider_error_code' => 'forbidden',
|
||||
]);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
Http::assertSentCount(3);
|
||||
$this->assertDatabaseMissing('servers', ['name' => 'test-server']);
|
||||
});
|
||||
|
||||
test('lists the invalid fields Hetzner reports', function () {
|
||||
($this->fakeHetzner)(fn () => Http::response([
|
||||
'error' => [
|
||||
'code' => 'invalid_input',
|
||||
'message' => 'invalid input in field server_type',
|
||||
'details' => ['fields' => [['name' => 'server_type'], ['name' => '<script>']]],
|
||||
],
|
||||
], 422));
|
||||
|
||||
$response = ($this->createServerRequest)();
|
||||
|
||||
$response->assertStatus(502);
|
||||
$response->assertJson([
|
||||
'message' => 'Failed to create Hetzner server. Hetzner rejected the request as invalid. Check the selected values and your account limits. Invalid fields: server_type.',
|
||||
'provider_error_code' => 'invalid_input',
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not send the create request again after a Hetzner server error', function () {
|
||||
($this->fakeHetzner)(fn () => Http::response([
|
||||
'error' => ['code' => 'unavailable', 'message' => 'service unavailable'],
|
||||
], 503));
|
||||
|
||||
$response = ($this->createServerRequest)();
|
||||
|
||||
$response->assertStatus(502);
|
||||
$response->assertJson(['message' => 'Failed to create Hetzner server. Hetzner cannot process the request now. Try again later.']);
|
||||
Http::assertSentCount(3);
|
||||
});
|
||||
|
||||
test('deletes the Hetzner server when registration in Coolify fails', function () {
|
||||
($this->fakeHetzner)(fn () => Http::response([
|
||||
'server' => ['id' => 456, 'name' => 'test-server', 'public_net' => []],
|
||||
], 201));
|
||||
|
||||
$response = ($this->createServerRequest)();
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to create Hetzner server.']);
|
||||
Http::assertSent(fn (HttpRequest $request) => $request->method() === 'DELETE'
|
||||
&& $request->url() === 'https://api.hetzner.cloud/v1/servers/456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/hetzner list endpoints upstream errors', function () {
|
||||
test('explains an invalid Hetzner token and does not retry it', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/locations*' => Http::response([
|
||||
'error' => ['code' => 'unauthorized', 'message' => 'unable to authenticate'],
|
||||
], 401),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(502);
|
||||
$response->assertExactJson([
|
||||
'message' => 'Failed to fetch Hetzner locations. The Hetzner API token is invalid or expired. Update the cloud provider token in Coolify.',
|
||||
'provider_status' => 401,
|
||||
'provider_error_code' => 'unauthorized',
|
||||
]);
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('sends a read request again after a Hetzner server error', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/locations*' => Http::sequence()
|
||||
->push(['error' => ['code' => 'unavailable', 'message' => 'service unavailable']], 503)
|
||||
->push(['locations' => [['id' => 1, 'name' => 'nbg1']], 'meta' => ['pagination' => ['next_page' => null]]], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment(['name' => 'nbg1']);
|
||||
Http::assertSentCount(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error responses do not leak exception details', function () {
|
||||
test('locations endpoint returns generic 500 message on upstream failure', function () {
|
||||
test('locations endpoint returns a safe 502 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/locations*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc /var/secret/path'],
|
||||
@@ -821,13 +955,17 @@ describe('error responses do not leak exception details', function () {
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner locations.']);
|
||||
$response->assertStatus(502);
|
||||
$response->assertExactJson([
|
||||
'message' => 'Failed to fetch Hetzner locations. Hetzner cannot process the request now. Try again later.',
|
||||
'provider_status' => 500,
|
||||
'provider_error_code' => null,
|
||||
]);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
expect($response->getContent())->not->toContain('/var/secret/path');
|
||||
});
|
||||
|
||||
test('server-types endpoint returns generic 500 message on upstream failure', function () {
|
||||
test('server-types endpoint returns a safe 502 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
@@ -839,12 +977,16 @@ describe('error responses do not leak exception details', function () {
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner server types.']);
|
||||
$response->assertStatus(502);
|
||||
$response->assertExactJson([
|
||||
'message' => 'Failed to fetch Hetzner server types. Hetzner cannot process the request now. Try again later.',
|
||||
'provider_status' => 500,
|
||||
'provider_error_code' => null,
|
||||
]);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('images endpoint returns generic 500 message on upstream failure', function () {
|
||||
test('images endpoint returns a safe 502 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/images*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
@@ -856,12 +998,16 @@ describe('error responses do not leak exception details', function () {
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner images.']);
|
||||
$response->assertStatus(502);
|
||||
$response->assertExactJson([
|
||||
'message' => 'Failed to fetch Hetzner images. Hetzner cannot process the request now. Try again later.',
|
||||
'provider_status' => 500,
|
||||
'provider_error_code' => null,
|
||||
]);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('ssh-keys endpoint returns generic 500 message on upstream failure', function () {
|
||||
test('ssh-keys endpoint returns a safe 502 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
@@ -873,12 +1019,16 @@ describe('error responses do not leak exception details', function () {
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/ssh-keys?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']);
|
||||
$response->assertStatus(502);
|
||||
$response->assertExactJson([
|
||||
'message' => 'Failed to fetch Hetzner SSH keys. Hetzner cannot process the request now. Try again later.',
|
||||
'provider_status' => 500,
|
||||
'provider_error_code' => null,
|
||||
]);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('firewalls endpoint returns generic 500 message on upstream failure', function () {
|
||||
test('firewalls endpoint returns a safe 502 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
@@ -890,12 +1040,16 @@ describe('error responses do not leak exception details', function () {
|
||||
'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.']);
|
||||
$response->assertStatus(502);
|
||||
$response->assertExactJson([
|
||||
'message' => 'Failed to fetch Hetzner firewalls. Hetzner cannot process the request now. Try again later.',
|
||||
'provider_status' => 500,
|
||||
'provider_error_code' => null,
|
||||
]);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('networks endpoint returns generic 500 message on upstream failure', function () {
|
||||
test('networks endpoint returns a safe 502 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/networks*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
@@ -907,8 +1061,12 @@ describe('error responses do not leak exception details', function () {
|
||||
'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.']);
|
||||
$response->assertStatus(502);
|
||||
$response->assertExactJson([
|
||||
'message' => 'Failed to fetch Hetzner networks. Hetzner cannot process the request now. Try again later.',
|
||||
'provider_status' => 500,
|
||||
'provider_error_code' => null,
|
||||
]);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Exceptions;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
@@ -183,6 +184,43 @@ describe('POST /api/v1/servers/digitalocean', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('explains a DigitalOcean permission error without leaking the DigitalOcean message', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/account/keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201),
|
||||
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'links' => ['pages' => []],
|
||||
], 200),
|
||||
'https://api.digitalocean.com/v2/droplets' => Http::response([
|
||||
'id' => 'forbidden',
|
||||
'message' => 'INTERNAL_LEAK_TOKEN_abc',
|
||||
], 403),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/digitalocean', [
|
||||
'cloud_provider_token_id' => $this->digitalOceanToken->uuid,
|
||||
'region' => 'nyc1',
|
||||
'size' => 's-1vcpu-1gb',
|
||||
'image' => 'ubuntu-24-04-x64',
|
||||
'name' => 'read-only-token',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(502);
|
||||
$response->assertExactJson([
|
||||
'message' => 'Failed to create DigitalOcean server. The DigitalOcean API token does not have permission for this request. Use a token with read and write access.',
|
||||
'provider_status' => 403,
|
||||
'provider_error_code' => 'forbidden',
|
||||
]);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
$this->assertDatabaseMissing('servers', ['name' => 'read-only-token']);
|
||||
});
|
||||
|
||||
test('deletes the droplet when local server persistence fails', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/account/keys' => Http::response([
|
||||
@@ -207,6 +245,7 @@ describe('POST /api/v1/servers/digitalocean', function () {
|
||||
'https://api.digitalocean.com/v2/droplets/987' => Http::response(null, 204),
|
||||
]);
|
||||
|
||||
Exceptions::fake();
|
||||
$eventDispatcher = Server::getEventDispatcher();
|
||||
Server::setEventDispatcher(clone $eventDispatcher);
|
||||
Server::created(function (): void {
|
||||
@@ -233,6 +272,7 @@ describe('POST /api/v1/servers/digitalocean', function () {
|
||||
$this->assertDatabaseMissing('servers', [
|
||||
'digitalocean_droplet_id' => 987,
|
||||
]);
|
||||
Exceptions::assertReported(fn (RuntimeException $e) => $e->getMessage() === 'local persistence failed');
|
||||
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
|
||||
&& $request->url() === 'https://api.digitalocean.com/v2/droplets/987');
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use App\Livewire\Server\New\ByHetzner;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -219,7 +220,7 @@ describe('Boarding Flow Integration', function () {
|
||||
->assertSeeHtml('x-text="current">Select SSH keys</span>');
|
||||
});
|
||||
|
||||
test('shows the cloud init script name only when saving the script', function () {
|
||||
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)
|
||||
@@ -306,6 +307,44 @@ describe('Hetzner data loading', function () {
|
||||
->and($component->get('hetznerNetworks'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('deletes the Hetzner server when saving it in Coolify fails', 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' => '203.0.113.10']]],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/servers/456' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$eventDispatcher = Server::getEventDispatcher();
|
||||
Server::setEventDispatcher(clone $eventDispatcher);
|
||||
Server::created(function (): void {
|
||||
throw new RuntimeException('local persistence failed');
|
||||
});
|
||||
|
||||
try {
|
||||
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)
|
||||
->call('submit');
|
||||
} finally {
|
||||
Server::setEventDispatcher($eventDispatcher);
|
||||
}
|
||||
|
||||
$this->assertDatabaseMissing('servers', ['hetzner_server_id' => 456]);
|
||||
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
|
||||
&& $request->url() === 'https://api.hetzner.cloud/v1/servers/456');
|
||||
});
|
||||
|
||||
test('rejects submitting without a public IP protocol before calling Hetzner', function () {
|
||||
Http::fake();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Exceptions;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -301,6 +302,43 @@ describe('POST /api/v1/servers/vultr', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('explains a Vultr permission error without leaking the Vultr message', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/ssh-keys' => Http::response([
|
||||
'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()],
|
||||
], 201),
|
||||
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
'https://api.vultr.com/v2/instances' => Http::response([
|
||||
'error' => 'INTERNAL_LEAK_TOKEN_abc',
|
||||
'status' => 403,
|
||||
], 403),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/vultr', [
|
||||
'cloud_provider_token_id' => $this->vultrToken->uuid,
|
||||
'region' => 'ewr',
|
||||
'plan' => 'vc2-1c-1gb',
|
||||
'os_id' => 2284,
|
||||
'name' => 'read-only-token',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(502);
|
||||
$response->assertExactJson([
|
||||
'message' => 'Failed to create Vultr server. The Vultr API token does not have permission for this request. Use a token with read and write access.',
|
||||
'provider_status' => 403,
|
||||
'provider_error_code' => null,
|
||||
]);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
$this->assertDatabaseMissing('servers', ['name' => 'read-only-token']);
|
||||
});
|
||||
|
||||
test('deletes the Vultr instance when local server persistence fails', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/ssh-keys' => Http::response([
|
||||
@@ -320,6 +358,7 @@ describe('POST /api/v1/servers/vultr', function () {
|
||||
'https://api.vultr.com/v2/instances/instance-persistence-fails' => Http::response(null, 204),
|
||||
]);
|
||||
|
||||
Exceptions::fake();
|
||||
$eventDispatcher = Server::getEventDispatcher();
|
||||
Server::setEventDispatcher(clone $eventDispatcher);
|
||||
Server::created(function (): void {
|
||||
@@ -347,6 +386,7 @@ describe('POST /api/v1/servers/vultr', function () {
|
||||
$this->assertDatabaseMissing('servers', [
|
||||
'vultr_instance_id' => 'instance-persistence-fails',
|
||||
]);
|
||||
Exceptions::assertReported(fn (RuntimeException $e) => $e->getMessage() === 'local persistence failed');
|
||||
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
|
||||
&& $request->url() === 'https://api.vultr.com/v2/instances/instance-persistence-fails');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user