Files
ladill-hosting/app/Services/Hosting/HostingProvisioningService.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

391 lines
14 KiB
PHP

<?php
namespace App\Services\Hosting;
use App\Models\Domain;
use App\Models\HostingAccount;
use App\Models\HostingNode;
use App\Models\HostingPlan;
use App\Models\HostedSite;
use App\Models\ProvisioningJob;
use App\Models\User;
use App\Models\VpsInstance;
use App\Services\Hosting\Providers\ContaboProvider;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class HostingProvisioningService
{
public function __construct(
private ContaboProvider $contaboProvider,
private SharedNodeProvider $sharedNodeProvider,
private NodeCapacityService $nodeCapacityService,
private HostingResourcePolicyService $resourcePolicyService,
) {}
public function createSharedHostingAccount(User $user, HostingPlan $plan, string $domain): HostingAccount
{
return DB::transaction(function () use ($user, $plan, $domain) {
// Find available node
$node = $this->nodeCapacityService->findAvailableNode((int) $plan->disk_gb);
$resourceLimits = $this->resourcePolicyService->defaultLimitsForProduct(null);
if (!$node) {
throw new \RuntimeException('No available hosting nodes for shared hosting');
}
// Generate unique username
$username = $this->generateUsername($user, $domain);
// Create account record
$account = HostingAccount::create([
'user_id' => $user->id,
'hosting_plan_id' => $plan->id,
'hosting_node_id' => $node->id,
'username' => $username,
'primary_domain' => $domain,
'type' => HostingAccount::TYPE_SHARED,
'status' => HostingAccount::STATUS_PROVISIONING,
'allocated_disk_gb' => (int) $plan->disk_gb,
'cpu_limit_percent' => $resourceLimits['cpu_limit_percent'],
'memory_limit_mb' => $resourceLimits['memory_limit_mb'],
'process_limit' => $resourceLimits['process_limit'],
'io_limit_mb' => $resourceLimits['io_limit_mb'],
'inode_limit' => $resourceLimits['inode_limit'],
'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE,
'php_version' => $plan->php_versions[0] ?? '8.2',
'resource_limits' => [
'disk_gb' => $plan->disk_gb,
'bandwidth_gb' => $plan->bandwidth_gb,
'max_domains' => $plan->max_domains,
'max_databases' => $plan->max_databases,
'cpu_limit_percent' => $resourceLimits['cpu_limit_percent'],
'memory_limit_mb' => $resourceLimits['memory_limit_mb'],
'process_limit' => $resourceLimits['process_limit'],
'io_limit_mb' => $resourceLimits['io_limit_mb'],
'inode_limit' => $resourceLimits['inode_limit'],
],
]);
// Create provisioning job
$job = ProvisioningJob::create([
'type' => 'create_shared_account',
'provisionable_type' => HostingAccount::class,
'provisionable_id' => $account->id,
'provider' => 'shared_node',
'status' => ProvisioningJob::STATUS_PENDING,
'payload' => [
'username' => $username,
'domain' => $domain,
'node_id' => $node->id,
],
]);
// Dispatch async provisioning
dispatch(function () use ($account, $job, $node, $domain) {
$this->provisionSharedAccount($account, $job, $node, $domain);
})->afterCommit();
return $account;
});
}
public function createVpsInstance(User $user, HostingPlan $plan, array $config): VpsInstance
{
return DB::transaction(function () use ($user, $plan, $config) {
$hostname = $config['hostname'] ?? 'vps-' . Str::random(8) . '.ladill.com';
$instance = VpsInstance::create([
'user_id' => $user->id,
'hosting_plan_id' => $plan->id,
'name' => $config['name'] ?? $hostname,
'hostname' => $hostname,
'provider' => 'contabo',
'region' => $config['region'] ?? 'EU',
'image' => $config['image'] ?? 'afecbb85-e2fc-46f0-9684-b46b1faf00bb',
'cpu_cores' => $plan->cpu_cores,
'ram_mb' => $plan->ram_mb,
'disk_gb' => $plan->disk_gb,
'status' => 'provisioning',
'ssh_public_key' => $config['ssh_public_key'] ?? null,
'tags' => $config['tags'] ?? [],
]);
$job = ProvisioningJob::create([
'type' => 'create_vps_instance',
'provisionable_type' => VpsInstance::class,
'provisionable_id' => $instance->id,
'provider' => 'contabo',
'status' => ProvisioningJob::STATUS_PENDING,
'payload' => [
'product_id' => $plan->contabo_product_id,
'region' => $config['region'] ?? 'EU',
'image' => $config['image'] ?? 'afecbb85-e2fc-46f0-9684-b46b1faf00bb',
'ssh_keys' => $config['ssh_keys'] ?? [],
'user_data' => $config['user_data'] ?? null,
],
]);
dispatch(function () use ($instance, $job, $config, $plan) {
$this->provisionVpsInstance($instance, $job, $config, $plan);
})->afterCommit();
return $instance;
});
}
public function addSiteToAccount(HostingAccount $account, string $domain, string $type = 'addon'): HostedSite
{
$docRoot = "/home/{$account->username}/public_html/{$domain}";
$site = HostedSite::create([
'hosting_account_id' => $account->id,
'domain' => $domain,
'document_root' => $docRoot,
'type' => $type,
'status' => 'pending',
'php_version' => $account->php_version,
]);
try {
$this->sharedNodeProvider->addSite($site);
$site->update(['status' => 'active']);
// Sync to Domain registry
$this->syncToDomainRegistry($account, $domain, $site);
} catch (\Exception $e) {
$site->update(['status' => 'disabled']);
throw $e;
}
return $site;
}
/**
* Sync hosting site to unified Domain registry
*/
private function syncToDomainRegistry(HostingAccount $account, string $host, HostedSite $site): void
{
try {
$domain = Domain::where('host', $host)->first();
if ($domain) {
// Update existing domain to add hosting
$domain->update([
'hosting_account_id' => $account->id,
'source' => $domain->email_domain_id ? 'hosting_email' : 'hosting',
'onboarding_state' => $domain->onboarding_state === 'pending_ns' ? null : $domain->onboarding_state,
]);
} else {
// Create new domain entry
Domain::create([
'user_id' => $account->user_id,
'host' => $host,
'hosting_account_id' => $account->id,
'source' => 'hosting',
'type' => 'custom',
'status' => 'verified',
'onboarding_state' => null,
'verified_at' => now(),
]);
}
Log::info('HostingProvisioningService: Synced to Domain registry', [
'domain' => $host,
'site_id' => $site->id,
]);
} catch (\Throwable $e) {
Log::warning('HostingProvisioningService: Failed to sync to Domain registry', [
'domain' => $host,
'error' => $e->getMessage(),
]);
}
}
public function suspendAccount(HostingAccount $account, string $reason): bool
{
$result = $this->sharedNodeProvider->suspendAccount($account, $reason);
if ($result) {
$account->update([
'status' => HostingAccount::STATUS_SUSPENDED,
'suspension_reason' => $reason,
'suspended_at' => now(),
]);
}
return $result;
}
public function unsuspendAccount(HostingAccount $account): bool
{
$result = $this->sharedNodeProvider->unsuspendAccount($account);
if ($result) {
$account->update([
'status' => HostingAccount::STATUS_ACTIVE,
'suspension_reason' => null,
'suspended_at' => null,
]);
}
return $result;
}
public function terminateAccount(HostingAccount $account): bool
{
$result = $this->sharedNodeProvider->terminateAccount($account);
if ($result) {
$account->node?->decrementAccountCount();
$account->update(['status' => HostingAccount::STATUS_TERMINATED]);
$account->delete();
}
return $result;
}
public function controlVps(VpsInstance $instance, string $action): bool
{
return match ($action) {
'start' => $this->contaboProvider->startInstance($instance->provider_instance_id),
'stop' => $this->contaboProvider->stopInstance($instance->provider_instance_id),
'reboot' => $this->contaboProvider->rebootInstance($instance->provider_instance_id),
default => throw new \InvalidArgumentException("Unknown action: {$action}"),
};
}
public function terminateVps(VpsInstance $instance): bool
{
if ($instance->provider_instance_id) {
$this->contaboProvider->deleteInstance($instance->provider_instance_id);
}
$instance->update(['status' => 'terminated']);
$instance->delete();
return true;
}
private function provisionSharedAccount(HostingAccount $account, ProvisioningJob $job, HostingNode $node, string $domain): void
{
$job->markRunning();
try {
$result = $this->sharedNodeProvider->createAccount($account);
$account->update([
'status' => HostingAccount::STATUS_ACTIVE,
'home_directory' => $result['home_directory'],
'provisioned_at' => now(),
]);
// Create primary site
$site = HostedSite::create([
'hosting_account_id' => $account->id,
'domain' => $domain,
'document_root' => $result['document_root'],
'type' => 'primary',
'status' => 'pending',
'php_version' => $account->php_version,
]);
$this->sharedNodeProvider->addSite($site);
$site->update(['status' => 'active']);
// Sync to Domain registry
$this->syncToDomainRegistry($account, $domain, $site);
$node->incrementAccountCount();
$job->markCompleted([
'username' => $result['username'],
'home_directory' => $result['home_directory'],
'initial_password' => $result['password'],
]);
Log::info("Shared hosting account provisioned", [
'account_id' => $account->id,
'username' => $account->username,
]);
} catch (\Exception $e) {
Log::error("Shared hosting provisioning failed", [
'account_id' => $account->id,
'error' => $e->getMessage(),
]);
$account->update(['status' => HostingAccount::STATUS_FAILED]);
$job->markFailed($e->getMessage());
}
}
private function provisionVpsInstance(VpsInstance $instance, ProvisioningJob $job, array $config, HostingPlan $plan): void
{
$job->markRunning();
try {
$rootPassword = Str::password(16);
$result = $this->contaboProvider->createInstance([
'product_id' => $plan->contabo_product_id,
'name' => $instance->name,
'region' => $config['region'] ?? 'EU',
'image_id' => $config['image'] ?? 'afecbb85-e2fc-46f0-9684-b46b1faf00bb',
'root_password' => $rootPassword,
'ssh_keys' => $config['ssh_keys'] ?? [],
'user_data' => $config['user_data'] ?? null,
]);
$instance->update([
'provider_instance_id' => $result['instance_id'],
'ip_address' => $result['ip_address'],
'ipv6_address' => $result['ipv6_address'],
'datacenter' => $result['datacenter'],
'status' => 'running',
'power_status' => 'on',
'root_password_encrypted' => encrypt($rootPassword),
'provisioned_at' => now(),
]);
$job->markCompleted([
'instance_id' => $result['instance_id'],
'ip_address' => $result['ip_address'],
'root_password' => $rootPassword,
]);
Log::info("VPS instance provisioned", [
'instance_id' => $instance->id,
'provider_id' => $result['instance_id'],
]);
} catch (\Exception $e) {
Log::error("VPS provisioning failed", [
'instance_id' => $instance->id,
'error' => $e->getMessage(),
]);
$instance->update(['status' => 'failed']);
$job->markFailed($e->getMessage());
}
}
private function generateUsername(User $user, string $domain): string
{
$base = preg_replace('/[^a-z0-9]/', '', strtolower(explode('.', $domain)[0]));
$base = substr($base, 0, 8) ?: 'user';
$username = $base;
$counter = 1;
while (HostingAccount::where('username', $username)->exists()) {
$username = $base . $counter;
$counter++;
}
return $username;
}
}