Files
ladill-hosting/app/Services/Infrastructure/ContaboInfrastructureProvisioner.php
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

351 lines
14 KiB
PHP

<?php
namespace App\Services\Infrastructure;
use App\Jobs\ProvisionContaboInfrastructureJob;
use App\Models\ContaboInfrastructureProvision;
use App\Models\EmailServer;
use App\Models\HostingNode;
use App\Models\User;
use App\Services\Hosting\Providers\ContaboProvider;
use App\Services\Mail\EmailServerPoolService;
use App\Services\Mail\MailServerNfsService;
use Illuminate\Support\Str;
class ContaboInfrastructureProvisioner
{
public function __construct(
private ContaboProvider $contabo,
private ContaboInfrastructureSshKeyService $sshKeys,
private ContaboInfrastructureCloudInitBuilder $cloudInit,
private EmailServerPoolService $emailPool,
private MailServerNfsService $mailNfs,
) {}
public function isEnabled(): bool
{
return (bool) config('infrastructure.contabo.enabled', true)
&& config('hosting.contabo.client_id')
&& config('hosting.contabo.client_secret');
}
/**
* @param array<string, mixed> $input
*/
public function startHostingNode(array $input, ?User $actor = null): ContaboInfrastructureProvision
{
$this->assertEnabled();
$keys = $this->sshKeys->generate();
$placeholderIp = (string) config('infrastructure.contabo.placeholder_ip', '169.254.254.254');
$node = HostingNode::create([
'name' => (string) $input['name'],
'hostname' => (string) $input['hostname'],
'ip_address' => $placeholderIp,
'type' => (string) ($input['type'] ?? HostingNode::TYPE_SHARED),
'role' => (string) ($input['role'] ?? HostingNode::ROLE_PRIMARY),
'primary_hosting_node_id' => $input['primary_hosting_node_id'] ?? null,
'segment' => (string) ($input['segment'] ?? HostingNode::SEGMENT_GENERAL),
'provider' => HostingNode::PROVIDER_CONTABO,
'region' => (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')),
'cpu_cores' => $input['cpu_cores'] ?? null,
'ram_mb' => $input['ram_mb'] ?? null,
'disk_gb' => $input['disk_gb'] ?? null,
'bandwidth_tb' => $input['bandwidth_tb'] ?? null,
'max_accounts' => (int) ($input['max_accounts'] ?? 100),
'oversell_ratio' => (float) ($input['oversell_ratio'] ?? 3),
'ssh_port' => 22,
'ssh_private_key' => $keys['private_key'],
'status' => HostingNode::STATUS_PROVISIONING,
]);
$provision = $this->createProvision(
purpose: ContaboInfrastructureProvision::PURPOSE_HOSTING_NODE,
name: (string) $input['name'],
productId: (string) ($input['contabo_product_id'] ?? config('infrastructure.contabo.hosting_product_id', 'V97')),
region: (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')),
imageId: (string) ($input['image_id'] ?? config('infrastructure.contabo.default_image_id')),
payload: $input,
keys: $keys,
hostingNodeId: $node->id,
actor: $actor,
);
ProvisionContaboInfrastructureJob::dispatch($provision);
return $provision;
}
/**
* @param array<string, mixed> $input
*/
public function startEmailServer(array $input, ?User $actor = null): ContaboInfrastructureProvision
{
$this->assertEnabled();
$isExtension = ($input['role'] ?? EmailServer::ROLE_PRIMARY) === EmailServer::ROLE_EXTENSION;
$keys = $this->sshKeys->generate();
$mailHost = (string) ($input['mail_host'] ?? config('mailinfra.mail_host', 'mail.ladill.com'));
$placeholderIp = (string) config('infrastructure.contabo.placeholder_ip', '169.254.254.254');
$mailDbPassword = $isExtension ? null : Str::password(32);
$primary = null;
if ($isExtension) {
$primary = EmailServer::findOrFail($input['primary_email_server_id']);
}
$server = EmailServer::create([
'name' => (string) $input['name'],
'hostname' => (string) ($input['hostname'] ?? $mailHost),
'ip_address' => $placeholderIp,
'mail_host' => $mailHost,
'role' => $isExtension ? EmailServer::ROLE_EXTENSION : EmailServer::ROLE_PRIMARY,
'primary_email_server_id' => $primary?->id,
'provider' => EmailServer::PROVIDER_CONTABO,
'region' => (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')),
'cpu_cores' => $input['cpu_cores'] ?? null,
'ram_mb' => $input['ram_mb'] ?? null,
'disk_gb' => $input['disk_gb'] ?? null,
'max_domains' => $input['max_domains'] ?? 5000,
'max_mailboxes' => $input['max_mailboxes'] ?? 50000,
'db_host' => $isExtension ? $primary?->db_host : config('infrastructure.mail_stack.db_host'),
'db_port' => $primary?->db_port ?? 3306,
'db_database' => config('infrastructure.mail_stack.db_database', 'mail'),
'db_username' => $isExtension ? $primary?->db_username : config('infrastructure.mail_stack.db_username', 'mailuser'),
'db_password' => $isExtension ? $primary?->db_password : $mailDbPassword,
'ssh_host' => null,
'ssh_user' => 'root',
'ssh_port' => 22,
'ssh_private_key' => $keys['private_key'],
'status' => EmailServer::STATUS_PROVISIONING,
'is_default' => ! $isExtension && (bool) ($input['is_default'] ?? false),
]);
if ($isExtension && $primary) {
$this->emailPool->syncExtensionFromPrimary($server, $primary);
}
$purpose = $isExtension
? ContaboInfrastructureProvision::PURPOSE_EMAIL_EXTENSION
: ContaboInfrastructureProvision::PURPOSE_EMAIL_PRIMARY;
$productId = $isExtension
? (string) ($input['contabo_product_id'] ?? config('infrastructure.contabo.mail_extension_product_id', 'V94'))
: (string) ($input['contabo_product_id'] ?? config('infrastructure.contabo.mail_primary_product_id', 'V97'));
$provision = $this->createProvision(
purpose: $purpose,
name: (string) $input['name'],
productId: $productId,
region: (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')),
imageId: (string) ($input['image_id'] ?? config('infrastructure.contabo.default_image_id')),
payload: array_merge($input, [
'mail_host' => $mailHost,
'primary_ip' => $primary?->ip_address,
'primary_email_server_id' => $primary?->id,
]),
keys: $keys,
emailServerId: $server->id,
mailDbPassword: $mailDbPassword,
actor: $actor,
);
ProvisionContaboInfrastructureJob::dispatch($provision);
return $provision;
}
public function createContaboInstance(ContaboInfrastructureProvision $provision): void
{
$provision->update(['status' => ContaboInfrastructureProvision::STATUS_CREATING]);
$provision->addLog('Creating Contabo compute instance…');
$userData = $this->cloudInit->build($provision);
$result = $this->contabo->createInstance([
'product_id' => $provision->contabo_product_id,
'region' => $provision->region,
'image_id' => $provision->image_id,
'display_name' => 'ladill-'.$provision->purpose.'-'.$provision->id,
'user_data' => $userData,
'default_user' => 'root',
]);
$provision->update([
'contabo_instance_id' => $result['instance_id'],
'status' => ContaboInfrastructureProvision::STATUS_WAITING_IP,
'instance_created_at' => now(),
]);
if ($provision->hosting_node_id) {
HostingNode::whereKey($provision->hosting_node_id)->update([
'provider_instance_id' => $result['instance_id'],
'cpu_cores' => $result['cpu_cores'] ?? null,
'ram_mb' => $result['ram_mb'] ?? null,
'disk_gb' => isset($result['disk_mb']) ? (int) ceil($result['disk_mb'] / 1024) : null,
]);
}
if ($provision->email_server_id) {
EmailServer::whereKey($provision->email_server_id)->update([
'provider_instance_id' => $result['instance_id'],
'cpu_cores' => $result['cpu_cores'] ?? null,
'ram_mb' => $result['ram_mb'] ?? null,
'disk_gb' => isset($result['disk_mb']) ? (int) ceil($result['disk_mb'] / 1024) : null,
]);
}
$provision->addLog('Instance '.$result['instance_id'].' created; waiting for IP address.');
}
public function pollInstanceIp(ContaboInfrastructureProvision $provision): bool
{
if (! $provision->contabo_instance_id) {
return false;
}
$instance = $this->contabo->getInstance($provision->contabo_instance_id);
$ip = $instance['ip_address'] ?? null;
if (! is_string($ip) || $ip === '' || $ip === '0.0.0.0') {
return false;
}
$provision->update([
'status' => ContaboInfrastructureProvision::STATUS_BOOTSTRAPPING,
'ip_assigned_at' => now(),
]);
$provision->addLog("Instance IP assigned: {$ip}");
if ($provision->hosting_node_id) {
HostingNode::whereKey($provision->hosting_node_id)->update([
'ip_address' => $ip,
'ipv6_address' => $instance['ipv6_address'] ?? null,
]);
}
if ($provision->email_server_id) {
EmailServer::whereKey($provision->email_server_id)->update([
'ip_address' => $ip,
'ipv6_address' => $instance['ipv6_address'] ?? null,
'ssh_host' => $ip,
]);
// Note: db_host is intentionally NOT set to the server's own IP.
// The mail DB is centralized; primaries keep the central db_host.
}
return true;
}
/**
* @param array<string, mixed> $callback
*/
public function completeFromCallback(ContaboInfrastructureProvision $provision, array $callback): void
{
if ($provision->status === ContaboInfrastructureProvision::STATUS_COMPLETED) {
return;
}
$provision->addLog('Bootstrap completion callback received.');
if ($provision->email_server_id) {
$server = EmailServer::find($provision->email_server_id);
if ($server?->isPrimary()) {
EmailServer::whereKey($server->id)->update([
// Mail DB is centralized — point at the central host, not the server IP.
'db_host' => config('infrastructure.mail_stack.db_host'),
'ssh_host' => $server->ip_address,
]);
$this->emailPool->syncExtensionCredentials($server->fresh());
}
if ($server?->isExtension() && $server->primary) {
$this->mailNfs->refreshPrimaryExports($server->primary);
}
}
$this->activateResources($provision);
$provision->update([
'status' => ContaboInfrastructureProvision::STATUS_COMPLETED,
'completed_at' => now(),
]);
$provision->addLog('Provisioning completed successfully.');
}
public function activateResources(ContaboInfrastructureProvision $provision): void
{
if ($provision->hosting_node_id) {
HostingNode::whereKey($provision->hosting_node_id)->update([
'status' => HostingNode::STATUS_ACTIVE,
]);
}
if ($provision->email_server_id) {
EmailServer::whereKey($provision->email_server_id)->update([
'status' => EmailServer::STATUS_ACTIVE,
]);
}
}
public function retry(ContaboInfrastructureProvision $provision): void
{
if ($provision->status !== ContaboInfrastructureProvision::STATUS_FAILED) {
return;
}
$provision->update([
'status' => ContaboInfrastructureProvision::STATUS_PENDING,
'error_message' => null,
'failed_at' => null,
]);
$provision->addLog('Retry requested.');
ProvisionContaboInfrastructureJob::dispatch($provision);
}
/**
* @param array<string, mixed> $payload
* @param array{public_key: string, private_key: string} $keys
*/
private function createProvision(
string $purpose,
string $name,
string $productId,
string $region,
?string $imageId,
array $payload,
array $keys,
?int $hostingNodeId = null,
?int $emailServerId = null,
?string $mailDbPassword = null,
?User $actor = null,
): ContaboInfrastructureProvision {
return ContaboInfrastructureProvision::create([
'purpose' => $purpose,
'status' => ContaboInfrastructureProvision::STATUS_PENDING,
'name' => $name,
'contabo_product_id' => $productId,
'region' => $region,
'image_id' => $imageId ?: null,
'completion_token' => Str::random(48),
'payload' => $payload,
'log' => [],
'ssh_public_key' => $keys['public_key'],
'ssh_private_key' => $keys['private_key'],
'generated_mail_db_password' => $mailDbPassword,
'hosting_node_id' => $hostingNodeId,
'email_server_id' => $emailServerId,
'created_by' => $actor?->id,
]);
}
private function assertEnabled(): void
{
if (! $this->isEnabled()) {
throw new \RuntimeException('Contabo infrastructure provisioning is not configured. Set CONTABO_* credentials in .env.');
}
}
}