Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
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>
This commit is contained in:
@@ -0,0 +1,553 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user