Deploy Ladill Hosting / deploy (push) Successful in 47s
Treat ns_auto domains as managed DNS, publish subdomain A records via PowerDNS without failing the UX when the local DNS registry is missing, and promote real linked domains over pending-setup.local placeholders. Co-authored-by: Cursor <cursoragent@cursor.com>
248 lines
8.9 KiB
PHP
248 lines
8.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Hosting;
|
|
|
|
use App\Jobs\ProvisionHostingAccountJob;
|
|
use App\Models\HostingAccount;
|
|
use App\Models\HostingProduct;
|
|
use App\Models\User;
|
|
use Carbon\CarbonInterface;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class AdminHostingAssignmentService
|
|
{
|
|
public function __construct(
|
|
private NodeCapacityService $nodeCapacity,
|
|
private HostingResourcePolicyService $resourcePolicy,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $input
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function assign(User $owner, array $input): array
|
|
{
|
|
$product = HostingProduct::query()->findOrFail((int) $input['hosting_product_id']);
|
|
|
|
if (! $product->is_active) {
|
|
throw ValidationException::withMessages([
|
|
'hosting_product_id' => 'Selected hosting product is not active.',
|
|
]);
|
|
}
|
|
|
|
$username = isset($input['username']) && $input['username'] !== ''
|
|
? (string) $input['username']
|
|
: $this->generateUsername($owner);
|
|
|
|
if (HostingAccount::query()->where('username', $username)->exists()) {
|
|
throw ValidationException::withMessages([
|
|
'username' => 'This username is already taken.',
|
|
]);
|
|
}
|
|
|
|
$primaryDomain = isset($input['primary_domain']) && $input['primary_domain'] !== ''
|
|
? strtolower(trim((string) $input['primary_domain']))
|
|
: null;
|
|
|
|
if ($primaryDomain !== null && PlaceholderPrimaryDomainService::isPlaceholderDomain($primaryDomain)) {
|
|
$primaryDomain = null;
|
|
}
|
|
|
|
$accountType = match ($product->type) {
|
|
'single_domain', 'multi_domain', 'wordpress' => HostingAccount::TYPE_SHARED,
|
|
'vps' => HostingAccount::TYPE_VPS,
|
|
'dedicated' => HostingAccount::TYPE_DEDICATED,
|
|
'email_standalone' => HostingAccount::TYPE_SHARED,
|
|
default => HostingAccount::TYPE_SHARED,
|
|
};
|
|
|
|
$node = null;
|
|
$nodeId = null;
|
|
if ($accountType === HostingAccount::TYPE_SHARED) {
|
|
$node = $this->nodeCapacity->findAvailableNodeForProduct($product);
|
|
$nodeId = $node?->id;
|
|
}
|
|
|
|
$resourceLimits = $this->resourcePolicy->defaultLimitsForProduct($product);
|
|
$durationMonths = (int) $input['duration_months'];
|
|
|
|
$hostingAccount = HostingAccount::create([
|
|
'user_id' => $owner->id,
|
|
'hosting_product_id' => $product->id,
|
|
'hosting_node_id' => $nodeId,
|
|
'username' => $username,
|
|
'primary_domain' => $primaryDomain,
|
|
'type' => $accountType,
|
|
'status' => HostingAccount::STATUS_PENDING,
|
|
'allocated_disk_gb' => (int) ($product->disk_gb ?? 0),
|
|
'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,
|
|
'expires_at' => $this->expiresAtFromDuration($durationMonths),
|
|
'resource_limits' => array_merge([
|
|
'disk_gb' => (int) ($product->disk_gb ?? 0),
|
|
'max_domains' => $product->max_domains,
|
|
'max_databases' => $product->max_databases,
|
|
], $resourceLimits),
|
|
'metadata' => [
|
|
'assigned_by_admin' => $input['assigned_by_admin'] ?? null,
|
|
'assigned_at' => now()->toISOString(),
|
|
'assigned_duration_months' => $durationMonths,
|
|
'notes' => $input['notes'] ?? null,
|
|
],
|
|
]);
|
|
|
|
if ($node) {
|
|
ProvisionHostingAccountJob::dispatch($hostingAccount->id);
|
|
|
|
return [
|
|
'account' => $this->serializeAccount($hostingAccount->fresh(['product'])),
|
|
'provisioning' => true,
|
|
'message' => "Hosting product '{$product->name}' assigned. Provisioning is in progress.",
|
|
];
|
|
}
|
|
|
|
$hostingAccount->update([
|
|
'status' => HostingAccount::STATUS_ACTIVE,
|
|
'provisioned_at' => now(),
|
|
]);
|
|
|
|
return [
|
|
'account' => $this->serializeAccount($hostingAccount->fresh(['product'])),
|
|
'provisioning' => false,
|
|
'message' => "Hosting product '{$product->name}' assigned.",
|
|
];
|
|
}
|
|
|
|
public function updateDuration(HostingAccount $account, int $durationMonths, ?int $adminId = null): array
|
|
{
|
|
$metadata = (array) ($account->metadata ?? []);
|
|
$metadata['assigned_duration_months'] = $durationMonths;
|
|
$metadata['duration_updated_by_admin'] = $adminId;
|
|
$metadata['duration_updated_at'] = now()->toISOString();
|
|
|
|
$account->update([
|
|
'expires_at' => $this->expiresAtFromDuration($durationMonths),
|
|
'metadata' => $metadata,
|
|
]);
|
|
|
|
return $this->serializeAccount($account->fresh(['product']));
|
|
}
|
|
|
|
public function renew(HostingAccount $account, ?int $adminId = null): array
|
|
{
|
|
if (! $account->canBeRenewed()) {
|
|
throw ValidationException::withMessages([
|
|
'account' => 'This hosting account does not have a configured renewal duration.',
|
|
]);
|
|
}
|
|
|
|
$months = $account->assignedDurationMonths();
|
|
$account->renew($months, [
|
|
'renewed_by_admin' => $adminId,
|
|
'renewed_at' => now()->toISOString(),
|
|
]);
|
|
|
|
return $this->serializeAccount($account->fresh(['product']));
|
|
}
|
|
|
|
public function unsuspend(HostingAccount $account, HostingResourcePolicyService $resourcePolicy): void
|
|
{
|
|
if (
|
|
$account->status !== HostingAccount::STATUS_SUSPENDED
|
|
&& $account->resource_status !== HostingAccount::RESOURCE_STATUS_SUSPENDED
|
|
) {
|
|
throw ValidationException::withMessages([
|
|
'account' => 'This hosting account is not suspended.',
|
|
]);
|
|
}
|
|
|
|
$resourcePolicy->unsuspendAccount($account, 'Manual unsuspension by admin.');
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function serializeAccount(HostingAccount $account): array
|
|
{
|
|
$product = $account->product;
|
|
|
|
return [
|
|
'id' => $account->id,
|
|
'username' => $account->username,
|
|
'primary_domain' => $account->primary_domain,
|
|
'type' => $account->type,
|
|
'status' => $account->status,
|
|
'resource_status' => $account->resource_status,
|
|
'expires_at' => $account->expires_at?->toIso8601String(),
|
|
'provisioned_at' => $account->provisioned_at?->toIso8601String(),
|
|
'metadata' => (array) ($account->metadata ?? []),
|
|
'product' => $product ? [
|
|
'id' => $product->id,
|
|
'name' => $product->name,
|
|
'type' => $product->type,
|
|
'slug' => $product->slug,
|
|
] : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public function listProducts(): array
|
|
{
|
|
return HostingProduct::query()
|
|
->where('is_active', true)
|
|
->orderBy('sort_order')
|
|
->orderBy('name')
|
|
->get()
|
|
->map(fn (HostingProduct $product) => [
|
|
'id' => $product->id,
|
|
'name' => $product->name,
|
|
'type' => $product->type,
|
|
'slug' => $product->slug,
|
|
'display_currency' => $product->display_currency,
|
|
'display_price_monthly' => $product->display_price_monthly,
|
|
])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function generateUsername(User $user): string
|
|
{
|
|
$baseUsername = strtolower(str_replace([' ', '.', '_'], '', $user->name));
|
|
$baseUsername = preg_replace('/[^a-z0-9]/', '', $baseUsername);
|
|
|
|
if (strlen($baseUsername) < 3) {
|
|
$baseUsername = strtolower(substr($user->email, 0, str_contains($user->email, '@') ? strpos($user->email, '@') : strlen($user->email)));
|
|
$baseUsername = preg_replace('/[^a-z0-9]/', '', $baseUsername);
|
|
}
|
|
|
|
if (strlen($baseUsername) < 3) {
|
|
$baseUsername = 'user'.$user->id;
|
|
}
|
|
|
|
$username = substr($baseUsername, 0, 16);
|
|
$counter = 1;
|
|
$originalUsername = $username;
|
|
|
|
while (HostingAccount::query()->where('username', $username)->exists()) {
|
|
$suffix = (string) $counter;
|
|
$maxBaseLength = 16 - strlen($suffix);
|
|
$username = substr($originalUsername, 0, $maxBaseLength).$suffix;
|
|
$counter++;
|
|
}
|
|
|
|
return $username;
|
|
}
|
|
|
|
private function expiresAtFromDuration(int $durationMonths): CarbonInterface
|
|
{
|
|
return now()->addMonthsNoOverflow($durationMonths);
|
|
}
|
|
}
|