Deploy Ladill Hosting / deploy (push) Failing after 17s
Shared web hosting extracted from the platform monolith, with CI deploy to /var/www/ladill-hosting matching Bird/Domains/Email. Co-authored-by: Cursor <cursoragent@cursor.com>
554 lines
19 KiB
PHP
554 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Hosting\Providers;
|
|
|
|
use App\Exceptions\ContaboApiException;
|
|
use App\Services\Hosting\Contracts\ComputeProviderInterface;
|
|
use Illuminate\Http\Client\PendingRequest;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
|
|
class ContaboProvider implements ComputeProviderInterface
|
|
{
|
|
private string $clientId;
|
|
private string $clientSecret;
|
|
private string $apiUser;
|
|
private string $apiPassword;
|
|
private ?string $productCatalogEndpoint;
|
|
private string $baseUrl = 'https://api.contabo.com/v1';
|
|
private string $authUrl = 'https://auth.contabo.com/auth/realms/contabo/protocol/openid-connect/token';
|
|
|
|
public function __construct()
|
|
{
|
|
$this->clientId = config('hosting.contabo.client_id');
|
|
$this->clientSecret = config('hosting.contabo.client_secret');
|
|
$this->apiUser = config('hosting.contabo.api_user');
|
|
$this->apiPassword = config('hosting.contabo.api_password');
|
|
$this->productCatalogEndpoint = config('hosting.contabo.product_catalog_endpoint');
|
|
}
|
|
|
|
private function getAccessToken(): string
|
|
{
|
|
return Cache::remember('contabo_access_token', 290, function () {
|
|
$response = Http::asForm()->post($this->authUrl, [
|
|
'client_id' => $this->clientId,
|
|
'client_secret' => $this->clientSecret,
|
|
'username' => $this->apiUser,
|
|
'password' => $this->apiPassword,
|
|
'grant_type' => 'password',
|
|
]);
|
|
|
|
if (!$response->successful()) {
|
|
Log::error('Contabo auth failed', ['response' => $response->body()]);
|
|
throw new \RuntimeException('Failed to authenticate with Contabo API');
|
|
}
|
|
|
|
return $response->json('access_token');
|
|
});
|
|
}
|
|
|
|
private function client(): PendingRequest
|
|
{
|
|
return Http::withToken($this->getAccessToken())
|
|
->baseUrl($this->baseUrl)
|
|
->withHeaders([
|
|
'x-request-id' => Str::uuid()->toString(),
|
|
])
|
|
->timeout(60);
|
|
}
|
|
|
|
public function createInstance(array $config): array
|
|
{
|
|
$payload = [
|
|
'productId' => $config['product_id'], // e.g., V45
|
|
'region' => $config['region'] ?? 'EU',
|
|
'displayName' => $config['display_name'] ?? $config['name'] ?? 'ladill-vps-' . uniqid(),
|
|
'period' => (int) ($config['period'] ?? 1),
|
|
];
|
|
|
|
if (!empty($config['image_id'])) {
|
|
$payload['imageId'] = $config['image_id'];
|
|
}
|
|
|
|
if (!empty($config['ssh_keys'])) {
|
|
$payload['sshKeys'] = $config['ssh_keys'];
|
|
}
|
|
|
|
if (!empty($config['root_password_secret_id'])) {
|
|
$payload['rootPassword'] = (int) $config['root_password_secret_id'];
|
|
} elseif (!empty($config['root_password'])) {
|
|
$payload['rootPassword'] = $this->createPasswordSecret(
|
|
(string) ($config['display_name'] ?? $config['name'] ?? 'server-password'),
|
|
(string) $config['root_password']
|
|
);
|
|
}
|
|
|
|
if (!empty($config['user_data'])) {
|
|
$payload['userData'] = base64_encode($config['user_data']);
|
|
}
|
|
|
|
if (!empty($config['license'])) {
|
|
$payload['license'] = $config['license'];
|
|
}
|
|
|
|
if (!empty($config['default_user'])) {
|
|
$payload['defaultUser'] = $config['default_user'];
|
|
}
|
|
|
|
if (!empty($config['application_id'])) {
|
|
$payload['applicationId'] = $config['application_id'];
|
|
}
|
|
|
|
if (!empty($config['add_ons'])) {
|
|
$payload['addOns'] = $config['add_ons'];
|
|
}
|
|
|
|
$response = $this->client()->post('/compute/instances', $payload);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Contabo create instance failed', [
|
|
'payload' => $payload,
|
|
'status' => $response->status(),
|
|
'response' => $response->body(),
|
|
]);
|
|
|
|
throw ContaboApiException::fromResponse($response->status(), (string) $response->body());
|
|
}
|
|
|
|
$data = $response->json('data.0');
|
|
|
|
return [
|
|
'instance_id' => $data['instanceId'],
|
|
'name' => $data['displayName'],
|
|
'ip_address' => $data['ipConfig']['v4']['ip'] ?? null,
|
|
'ipv6_address' => $data['ipConfig']['v6']['ip'] ?? null,
|
|
'status' => $this->mapStatus($data['status']),
|
|
'region' => $data['region'],
|
|
'datacenter' => $data['dataCenter'],
|
|
'image' => $data['imageId'],
|
|
'product_id' => $data['productId'],
|
|
'cpu_cores' => $data['cpuCores'] ?? null,
|
|
'ram_mb' => $data['ramMb'] ?? null,
|
|
'disk_mb' => $data['diskMb'] ?? null,
|
|
];
|
|
}
|
|
|
|
public function getInstance(string $instanceId): ?array
|
|
{
|
|
$response = $this->client()->get("/compute/instances/{$instanceId}");
|
|
|
|
if (!$response->successful()) {
|
|
if ($response->status() === 404) {
|
|
return null;
|
|
}
|
|
throw new \RuntimeException('Failed to get Contabo instance: ' . $response->body());
|
|
}
|
|
|
|
$data = $response->json('data.0');
|
|
|
|
return [
|
|
'instance_id' => $data['instanceId'],
|
|
'name' => $data['displayName'],
|
|
'ip_address' => $data['ipConfig']['v4']['ip'] ?? null,
|
|
'ipv6_address' => $data['ipConfig']['v6']['ip'] ?? null,
|
|
'status' => $this->mapStatus($data['status']),
|
|
'power_status' => $data['status'] === 'running' ? 'on' : 'off',
|
|
'region' => $data['region'],
|
|
'datacenter' => $data['dataCenter'],
|
|
'image' => $data['imageId'],
|
|
'product_id' => $data['productId'],
|
|
'cpu_cores' => $data['cpuCores'] ?? null,
|
|
'ram_mb' => $data['ramMb'] ?? null,
|
|
'disk_mb' => $data['diskMb'] ?? null,
|
|
'created_at' => $data['createdDate'] ?? null,
|
|
];
|
|
}
|
|
|
|
public function listInstances(array $filters = []): array
|
|
{
|
|
$query = array_filter([
|
|
'page' => $filters['page'] ?? 1,
|
|
'size' => $filters['per_page'] ?? 100,
|
|
'name' => $filters['name'] ?? null,
|
|
'region' => $filters['region'] ?? null,
|
|
'status' => $filters['status'] ?? null,
|
|
]);
|
|
|
|
$response = $this->client()->get('/compute/instances', $query);
|
|
|
|
if (!$response->successful()) {
|
|
throw new \RuntimeException('Failed to list Contabo instances: ' . $response->body());
|
|
}
|
|
|
|
$instances = [];
|
|
foreach ($response->json('data', []) as $data) {
|
|
$instances[] = [
|
|
'instance_id' => $data['instanceId'],
|
|
'name' => $data['displayName'],
|
|
'ip_address' => $data['ipConfig']['v4']['ip'] ?? null,
|
|
'status' => $this->mapStatus($data['status']),
|
|
'region' => $data['region'],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'instances' => $instances,
|
|
'total' => $response->json('_pagination.totalElements', count($instances)),
|
|
];
|
|
}
|
|
|
|
public function startInstance(string $instanceId): bool
|
|
{
|
|
$response = $this->client()->post("/compute/instances/{$instanceId}/actions/start");
|
|
return $response->successful();
|
|
}
|
|
|
|
public function stopInstance(string $instanceId): bool
|
|
{
|
|
$response = $this->client()->post("/compute/instances/{$instanceId}/actions/stop");
|
|
return $response->successful();
|
|
}
|
|
|
|
public function rebootInstance(string $instanceId): bool
|
|
{
|
|
$response = $this->client()->post("/compute/instances/{$instanceId}/actions/restart");
|
|
return $response->successful();
|
|
}
|
|
|
|
public function deleteInstance(string $instanceId): bool
|
|
{
|
|
$response = $this->client()->delete("/compute/instances/{$instanceId}");
|
|
return $response->successful();
|
|
}
|
|
|
|
public function reinstallInstance(string $instanceId, string $image): bool
|
|
{
|
|
$response = $this->client()->put("/compute/instances/{$instanceId}", [
|
|
'imageId' => $image,
|
|
]);
|
|
return $response->successful();
|
|
}
|
|
|
|
public function createSnapshot(string $instanceId, string $name): array
|
|
{
|
|
$response = $this->client()->post("/compute/instances/{$instanceId}/snapshots", [
|
|
'name' => $name,
|
|
'description' => "Snapshot created by Ladill on " . now()->toDateTimeString(),
|
|
]);
|
|
|
|
if (!$response->successful()) {
|
|
throw new \RuntimeException('Failed to create snapshot: ' . $response->body());
|
|
}
|
|
|
|
$data = $response->json('data.0');
|
|
|
|
return [
|
|
'snapshot_id' => $data['snapshotId'],
|
|
'name' => $data['name'],
|
|
'status' => $data['status'] ?? 'creating',
|
|
];
|
|
}
|
|
|
|
public function listSnapshots(string $instanceId): array
|
|
{
|
|
$response = $this->client()->get("/compute/instances/{$instanceId}/snapshots");
|
|
|
|
if (!$response->successful()) {
|
|
throw new \RuntimeException('Failed to list snapshots: ' . $response->body());
|
|
}
|
|
|
|
$snapshots = [];
|
|
foreach ($response->json('data', []) as $data) {
|
|
$snapshots[] = [
|
|
'snapshot_id' => $data['snapshotId'],
|
|
'name' => $data['name'],
|
|
'status' => $data['status'] ?? 'available',
|
|
'created_at' => $data['createdDate'] ?? null,
|
|
];
|
|
}
|
|
|
|
return $snapshots;
|
|
}
|
|
|
|
public function deleteSnapshot(string $snapshotId): bool
|
|
{
|
|
$response = $this->client()->delete("/compute/snapshots/{$snapshotId}");
|
|
return $response->successful();
|
|
}
|
|
|
|
public function restoreSnapshot(string $instanceId, string $snapshotId): bool
|
|
{
|
|
$response = $this->client()->post("/compute/instances/{$instanceId}/snapshots/{$snapshotId}/rollback");
|
|
return $response->successful();
|
|
}
|
|
|
|
public function getAvailableImages(): array
|
|
{
|
|
$images = [];
|
|
|
|
$page = 1;
|
|
$pageSize = 100;
|
|
do {
|
|
$response = $this->client()->get('/compute/images', [
|
|
'page' => $page,
|
|
'size' => $pageSize,
|
|
]);
|
|
|
|
if (!$response->successful()) {
|
|
throw new \RuntimeException('Failed to get images: ' . $response->body());
|
|
}
|
|
|
|
$batch = $response->json('data', []);
|
|
foreach ($batch as $data) {
|
|
$images[] = [
|
|
'id' => $data['imageId'],
|
|
'name' => $data['name'],
|
|
'description' => $data['description'] ?? '',
|
|
'os_type' => $data['osType'] ?? 'linux',
|
|
'standard_image' => $data['standardImage'] ?? true,
|
|
];
|
|
}
|
|
|
|
$page++;
|
|
} while (count($batch) === $pageSize);
|
|
|
|
if ($images === []) {
|
|
throw new \RuntimeException('Failed to get images: empty response');
|
|
}
|
|
|
|
return $images;
|
|
}
|
|
|
|
public function getAvailableRegions(): array
|
|
{
|
|
return [
|
|
['id' => 'EU', 'name' => 'European Union', 'country' => 'DE'],
|
|
['id' => 'US-central', 'name' => 'United States Central', 'country' => 'US'],
|
|
['id' => 'US-east', 'name' => 'United States East', 'country' => 'US'],
|
|
['id' => 'US-west', 'name' => 'United States West', 'country' => 'US'],
|
|
['id' => 'SIN', 'name' => 'Singapore', 'country' => 'SG'],
|
|
['id' => 'UK', 'name' => 'United Kingdom', 'country' => 'GB'],
|
|
['id' => 'AUS', 'name' => 'Australia', 'country' => 'AU'],
|
|
['id' => 'JPN', 'name' => 'Japan', 'country' => 'JP'],
|
|
];
|
|
}
|
|
|
|
public function getAvailableApplications(): array
|
|
{
|
|
return Cache::remember('contabo_applications', 3600, function () {
|
|
$applications = [];
|
|
|
|
$page = 1;
|
|
$pageSize = 100;
|
|
do {
|
|
$response = $this->client()->get('/compute/applications', [
|
|
'page' => $page,
|
|
'size' => $pageSize,
|
|
]);
|
|
|
|
if (!$response->successful()) {
|
|
throw new \RuntimeException('Failed to get applications: ' . $response->body());
|
|
}
|
|
|
|
$batch = $response->json('data', []);
|
|
foreach ($batch as $data) {
|
|
$applications[] = [
|
|
'id' => $data['applicationId'],
|
|
'name' => $data['name'],
|
|
'description' => $data['description'] ?? '',
|
|
'version' => $data['version'] ?? null,
|
|
'url' => $data['url'] ?? null,
|
|
];
|
|
}
|
|
|
|
$page++;
|
|
} while (count($batch) === $pageSize);
|
|
|
|
return $applications;
|
|
});
|
|
}
|
|
|
|
public function getAvailableProducts(): array
|
|
{
|
|
return Cache::remember('contabo_products', 3600, function () {
|
|
$liveProducts = $this->fetchProductCatalog();
|
|
|
|
return $liveProducts !== [] ? $liveProducts : $this->fallbackProducts();
|
|
});
|
|
}
|
|
|
|
private function fetchProductCatalog(): array
|
|
{
|
|
$endpoint = trim((string) $this->productCatalogEndpoint);
|
|
|
|
if ($endpoint === '') {
|
|
return [];
|
|
}
|
|
|
|
$response = str_starts_with($endpoint, 'http')
|
|
? Http::withToken($this->getAccessToken())
|
|
->withHeaders(['x-request-id' => Str::uuid()->toString()])
|
|
->timeout(60)
|
|
->get($endpoint)
|
|
: $this->client()->get($endpoint);
|
|
|
|
if (! $response->successful()) {
|
|
Log::warning('Contabo product catalog lookup failed', [
|
|
'endpoint' => $endpoint,
|
|
'status' => $response->status(),
|
|
'response' => Str::limit($response->body(), 500),
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
|
|
$rows = $response->json('data', $response->json('products', $response->json()));
|
|
|
|
if (! is_array($rows)) {
|
|
return [];
|
|
}
|
|
|
|
$products = [];
|
|
foreach ($rows as $row) {
|
|
if (! is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$product = $this->normalizeProductCatalogRow($row);
|
|
if ($product !== null) {
|
|
$products[] = $product;
|
|
}
|
|
}
|
|
|
|
return $products;
|
|
}
|
|
|
|
private function normalizeProductCatalogRow(array $row): ?array
|
|
{
|
|
$id = (string) ($row['id'] ?? $row['productId'] ?? $row['product_id'] ?? '');
|
|
|
|
if ($id === '') {
|
|
return null;
|
|
}
|
|
|
|
$monthlyPrice = $row['price_monthly']
|
|
?? $row['monthly']
|
|
?? $row['monthly_usd']
|
|
?? $row['monthlyUsd']
|
|
?? $row['monthlyPrice']
|
|
?? $row['price']
|
|
?? null;
|
|
|
|
if (! is_numeric($monthlyPrice)) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => $id,
|
|
'name' => (string) ($row['name'] ?? $row['product'] ?? $id),
|
|
'cpu' => isset($row['cpu']) ? (int) $row['cpu'] : (isset($row['cpuCores']) ? (int) $row['cpuCores'] : null),
|
|
'ram_gb' => isset($row['ram_gb']) ? (float) $row['ram_gb'] : (isset($row['ramGb']) ? (float) $row['ramGb'] : null),
|
|
'disk_gb' => isset($row['disk_gb']) ? (float) $row['disk_gb'] : (isset($row['diskGb']) ? (float) $row['diskGb'] : null),
|
|
'price_monthly' => (float) $monthlyPrice,
|
|
];
|
|
}
|
|
|
|
private function fallbackProducts(): array
|
|
{
|
|
return [
|
|
['id' => 'V91', 'name' => 'Cloud VPS 10 NVMe', 'cpu' => 3, 'ram_gb' => 8, 'disk_gb' => 75, 'price_monthly' => 4.99],
|
|
['id' => 'V94', 'name' => 'Cloud VPS 20 NVMe', 'cpu' => 6, 'ram_gb' => 12, 'disk_gb' => 100, 'price_monthly' => 7.00],
|
|
['id' => 'V97', 'name' => 'Cloud VPS 30 NVMe', 'cpu' => 8, 'ram_gb' => 24, 'disk_gb' => 200, 'price_monthly' => 14.00],
|
|
['id' => 'V100', 'name' => 'Cloud VPS 40 NVMe', 'cpu' => 12, 'ram_gb' => 48, 'disk_gb' => 250, 'price_monthly' => 25.00],
|
|
['id' => 'V45', 'name' => 'Legacy VPS S SSD', 'cpu' => 4, 'ram_gb' => 8, 'disk_gb' => 200, 'price_monthly' => 6.99],
|
|
['id' => 'V47', 'name' => 'Legacy VPS M SSD', 'cpu' => 6, 'ram_gb' => 16, 'disk_gb' => 400, 'price_monthly' => 12.99],
|
|
['id' => 'V46', 'name' => 'Legacy VPS L SSD', 'cpu' => 8, 'ram_gb' => 30, 'disk_gb' => 800, 'price_monthly' => 22.99],
|
|
['id' => 'V48', 'name' => 'Legacy VPS XL SSD', 'cpu' => 10, 'ram_gb' => 60, 'disk_gb' => 1600, 'price_monthly' => 44.99],
|
|
];
|
|
}
|
|
|
|
private function mapStatus(string $contaboStatus): string
|
|
{
|
|
return match ($contaboStatus) {
|
|
'running' => 'running',
|
|
'stopped' => 'stopped',
|
|
'provisioning', 'installing' => 'provisioning',
|
|
'error' => 'failed',
|
|
default => 'unknown',
|
|
};
|
|
}
|
|
|
|
public function createPasswordSecret(string $displayName, string $password): int
|
|
{
|
|
$response = $this->client()->post('/secrets', [
|
|
'name' => Str::limit($displayName, 200, '').'-password',
|
|
'value' => $password,
|
|
'type' => 'password',
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Contabo secret creation failed', ['response' => $response->body()]);
|
|
throw new \RuntimeException('Failed to create Contabo password secret: '.$response->body());
|
|
}
|
|
|
|
return (int) $response->json('data.0.secretId');
|
|
}
|
|
|
|
public function generateCloudInit(array $config): string
|
|
{
|
|
$packages = $config['packages'] ?? ['curl', 'wget', 'git', 'unzip'];
|
|
$sshKeys = $config['ssh_keys'] ?? [];
|
|
$writeFiles = $config['write_files'] ?? [];
|
|
$runCommands = $config['run_commands'] ?? [];
|
|
|
|
$cloudInit = [
|
|
'#cloud-config',
|
|
'package_update: true',
|
|
'package_upgrade: true',
|
|
'packages:',
|
|
];
|
|
|
|
foreach ($packages as $package) {
|
|
$cloudInit[] = " - {$package}";
|
|
}
|
|
|
|
if (!empty($sshKeys)) {
|
|
$cloudInit[] = 'ssh_authorized_keys:';
|
|
foreach ($sshKeys as $key) {
|
|
$cloudInit[] = " - {$key}";
|
|
}
|
|
}
|
|
|
|
if (! empty($writeFiles)) {
|
|
$cloudInit[] = 'write_files:';
|
|
foreach ($writeFiles as $file) {
|
|
$cloudInit[] = ' - path: ' . ($file['path'] ?? '/tmp/ladill-file');
|
|
if (! empty($file['owner'])) {
|
|
$cloudInit[] = ' owner: ' . $file['owner'];
|
|
}
|
|
if (! empty($file['permissions'])) {
|
|
$cloudInit[] = ' permissions: "' . $file['permissions'] . '"';
|
|
}
|
|
if (! empty($file['encoding'])) {
|
|
$cloudInit[] = ' encoding: ' . $file['encoding'];
|
|
}
|
|
$cloudInit[] = ' content: |';
|
|
foreach (preg_split("/\r\n|\n|\r/", (string) ($file['content'] ?? '')) ?: [] as $contentLine) {
|
|
$cloudInit[] = ' ' . $contentLine;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!empty($runCommands)) {
|
|
$cloudInit[] = 'runcmd:';
|
|
foreach ($runCommands as $cmd) {
|
|
$cloudInit[] = " - {$cmd}";
|
|
}
|
|
}
|
|
|
|
return implode("\n", $cloudInit);
|
|
}
|
|
}
|