Files
ladill-hosting/app/Services/Hosting/Providers/ContaboProvider.php
T
isaaccladandCursor e251a4cf60
Deploy Ladill Hosting / deploy (push) Failing after 17s
Initial Ladill Hosting app with Gitea deploy pipeline.
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>
2026-06-06 16:24:20 +00:00

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