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); } }