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>
391 lines
14 KiB
PHP
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;
|
|
}
|
|
}
|