Add platform admin hosting API for Ladill account provisioning.
Deploy Ladill Hosting / deploy (push) Successful in 1m29s

Expose authenticated service endpoints for assigning, listing, renewing, and managing hosting accounts from the platform, with tests and deploy docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-03 22:54:00 +00:00
co-authored by Cursor
parent b0e1cfab4e
commit 2c9877a87c
14 changed files with 806 additions and 66 deletions
@@ -0,0 +1,243 @@
<?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'] !== ''
? (string) $input['primary_domain']
: 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);
}
}
@@ -909,16 +909,7 @@ NGINX;
$domain = $site->domain;
$docRoot = $site->document_root ?: "/home/{$username}/public_html/{$domain}";
// Create document root
$quotedDocRoot = escapeshellarg($docRoot);
$this->ensureAdministrativeResultSucceeded(
$this->runLocalAdminOperationOrRemote($ssh, 'mkdir', [$docRoot], "mkdir -p {$quotedDocRoot}"),
"Failed to create document root for {$domain}"
);
$this->ensureAdministrativeResultSucceeded(
$this->runLocalAdminOperationOrRemote($ssh, 'chown', ["{$username}:{$username}", $docRoot], "chown {$username}:{$username} {$quotedDocRoot}"),
"Failed to assign document root ownership for {$domain}"
);
$this->ensureSiteDocumentRoot($ssh, $username, $docRoot);
$this->hardenAccountFilesystem($account, [$docRoot]);
$this->applyManagedSiteConfig($ssh, $site, $docRoot, $username, [
@@ -931,6 +922,24 @@ NGINX;
];
}
private function ensureSiteDocumentRoot(mixed $ssh, string $username, string $docRoot): void
{
$quotedDocRoot = escapeshellarg($docRoot);
$this->ensureAdministrativeResultSucceeded(
$this->runLocalAdminOperationOrRemote($ssh, 'mkdir', [$docRoot], "mkdir -p {$quotedDocRoot}"),
"Failed to create document root at {$docRoot}"
);
$this->ensureAdministrativeResultSucceeded(
$this->runLocalAdminOperationOrRemote(
$ssh,
'run-cmd',
["chown {$username}:" . self::WEB_SERVER_GROUP . " {$quotedDocRoot} && chmod 2750 {$quotedDocRoot}"],
"chown {$username}:" . self::WEB_SERVER_GROUP . " {$quotedDocRoot} && chmod 2750 {$quotedDocRoot}"
),
"Failed to set document root ownership at {$docRoot}"
);
}
public function hardenAccountFilesystem(HostingAccount $account, array $additionalDocumentRoots = []): void
{
$node = $account->node;
@@ -1002,6 +1011,7 @@ NGINX;
$domain = $site->domain;
$email = $account->user?->email ?: 'admin@' . $domain;
$docRoot = $site->document_root ?: "/home/{$account->username}/public_html/{$domain}";
$this->ensureSiteDocumentRoot($ssh, $account->username, $docRoot);
$hadCertificate = $this->siteHasCertificate($ssh, $domain);
Log::info("SSL: Starting certificate request", [
@@ -1677,7 +1687,7 @@ LIMITS;
'runtime_port' => $port,
'runtime_type' => $appType,
]),
]);
])->save();
try {
$this->applyManagedSiteConfig($ssh, $site, $site->document_root, $username, [
@@ -2451,14 +2461,7 @@ SERVICE;
NGINX;
if ($proxyPort !== null) {
$httpBody = <<<NGINX
access_log /home/{$username}/logs/{$domain}-access.log;
error_log /home/{$username}/logs/{$domain}-error.log;
{$acmeLocation}
location / {
proxy_pass http://127.0.0.1:{$proxyPort};
$proxyHeaders = <<<NGINX
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
@@ -2469,6 +2472,27 @@ NGINX;
proxy_cache_bypass \$http_upgrade;
proxy_read_timeout 300;
proxy_connect_timeout 300;
NGINX;
$apiPort = data_get($site->app_config, 'api_runtime_port');
$apiLocation = is_numeric($apiPort)
? <<<NGINX
location /api/ {
proxy_pass http://127.0.0.1:{$apiPort};
{$proxyHeaders}
}
NGINX
: '';
$httpBody = <<<NGINX
access_log /home/{$username}/logs/{$domain}-access.log;
error_log /home/{$username}/logs/{$domain}-error.log;
{$acmeLocation}
{$apiLocation}
location / {
proxy_pass http://127.0.0.1:{$proxyPort};
{$proxyHeaders}
}
client_max_body_size 64M;