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

943 lines
38 KiB
PHP

<?php
namespace App\Services\Hosting;
use App\Models\CustomerHostingOrder;
use App\Models\HostingProduct;
use App\Services\Hosting\Providers\ContaboProvider;
use Illuminate\Validation\ValidationException;
class ServerOrderService
{
private const INSTALL_LATER_IMAGE = '__install_later__';
private const PASSWORD_PATTERN = '/^((?=.*?[A-Z])(?=.*?[a-z]))(((?=(?:.*\d){1})(?=(?:.*[!@#$^&*?_~]){2,}))|((?=(?:.*\d){3})(?=.*?[!@#$^&*?_~]))).{8,}$/';
private const APPLICATION_NAME_MAPPINGS = [
'ipfs_node' => ['IPFS', 'ipfs'],
'flux_node' => ['Flux', 'flux', 'FluxOS'],
'horizon_node' => ['Horizen', 'horizon', 'Zen'],
'ethereum_node' => ['Ethereum', 'eth-docker'],
'bitcoin_node' => ['Bitcoin'],
];
private ?array $availableImages = null;
public function __construct(
private HostingPricingService $pricing,
private ContaboProvider $contaboProvider,
private ServerPanelCapabilityService $panelCapabilities,
) {}
public function passwordPattern(): string
{
return self::PASSWORD_PATTERN;
}
/**
* @return array<string, mixed>
*/
public function frontendPayload(HostingProduct $product): array
{
$defaults = $this->defaultSelections($product);
return [
'id' => $product->id,
'name' => $product->name,
'currency' => $product->display_currency,
'prices' => $this->baseCyclePrices($product),
'setup_fees' => $this->setupFeesByCycle($product),
'catalog' => $this->catalogForProduct($product),
'defaults' => $defaults,
'panel_snapshot' => $this->panelCapabilities->describeSelection($product, $defaults, $this->catalogForProduct($product)),
];
}
/**
* @return array<string, mixed>
*/
public function catalogForProduct(HostingProduct $product): array
{
$images = $this->imageOptions($product);
$defaultImage = $this->defaultImageOption($images)['value'] ?? null;
$defaultImageMeta = $defaultImage ? $this->findOption($images, $defaultImage) : null;
return [
'images' => $images,
'regions' => $this->optionCollection($product, (array) config('hosting.server_order.regions', [])),
'licenses' => $this->optionCollection($product, (array) config('hosting.server_order.licenses', [])),
'applications' => $this->applicationOptions($product),
'additional_ip' => $this->optionCollection($product, (array) config('hosting.server_order.additional_ip', [])),
'private_networking' => $this->optionCollection($product, (array) config('hosting.server_order.private_networking', [])),
'storage_types' => $this->optionCollection($product, (array) config('hosting.server_order.storage_types', [])),
'object_storage' => $this->optionCollection($product, (array) config('hosting.server_order.object_storage', [])),
'linux_default_users' => $this->defaultUserOptions($product, 'linux'),
'windows_default_users' => $this->defaultUserOptions($product, 'windows'),
'default_image_os_family' => $defaultImageMeta['os_family'] ?? 'linux',
];
}
/**
* @param array<string, mixed> $input
* @return array{selection: array<string, mixed>, quote: array<string, mixed>}
*/
public function selectionAndQuote(HostingProduct $product, array $input): array
{
$catalog = $this->catalogForProduct($product);
$defaults = $this->defaultSelections($product);
$billingCycle = (string) ($input['billing_cycle'] ?? CustomerHostingOrder::CYCLE_MONTHLY);
$selection = [
'image' => $this->validatedOptionValue($catalog['images'], (string) ($input['image'] ?? $defaults['image'])),
'region' => $this->validatedOptionValue($catalog['regions'], (string) ($input['region'] ?? $defaults['region'])),
'license' => $this->validatedOptionValue($catalog['licenses'], (string) ($input['license'] ?? $defaults['license'])),
'application' => $this->validatedOptionValue($catalog['applications'], (string) ($input['application'] ?? $defaults['application'])),
'additional_ip' => $this->validatedOptionValue($catalog['additional_ip'], (string) ($input['additional_ip'] ?? $defaults['additional_ip'])),
'private_networking' => $this->validatedOptionValue($catalog['private_networking'], (string) ($input['private_networking'] ?? $defaults['private_networking'])),
'storage_type' => $this->validatedOptionValue($catalog['storage_types'], (string) ($input['storage_type'] ?? $defaults['storage_type'])),
'object_storage' => $this->validatedOptionValue($catalog['object_storage'], (string) ($input['object_storage'] ?? $defaults['object_storage'])),
];
$image = $this->findOption($catalog['images'], $selection['image']);
$defaultUsers = $this->defaultUserOptions($product, (string) ($image['os_family'] ?? 'linux'));
$selection['default_user'] = $this->validatedOptionValue(
$defaultUsers,
(string) ($input['default_user'] ?? $image['default_user'] ?? array_key_first($defaultUsers))
);
$this->assertCompatibleSelections($selection, $catalog);
$quote = $this->quote($product, $selection, $billingCycle, $catalog);
return [
'selection' => $selection,
'quote' => $quote,
'selection_summary' => $this->selectionSummary($product, $selection, $catalog),
'panel_snapshot' => $this->panelCapabilities->describeSelection($product, $selection, $catalog),
];
}
/**
* @param array<string, mixed> $selection
* @param array<string, mixed>|null $catalog
* @return array<string, mixed>
*/
public function quote(HostingProduct $product, array $selection, string $billingCycle, ?array $catalog = null): array
{
$catalog ??= $this->catalogForProduct($product);
$basePrices = $this->baseCyclePrices($product);
$baseTotal = (float) ($basePrices[$billingCycle] ?? 0);
$lines = [[
'code' => 'base_plan',
'label' => $product->name,
'amount' => round($baseTotal, 2),
'automated' => true,
]];
$manualFollowUp = [];
$setupFee = $this->setupFeeForCycle($product, $billingCycle);
if ($setupFee > 0) {
$lines[] = [
'code' => 'setup_fee',
'label' => $this->setupFeeLabel($product, $billingCycle),
'amount' => round($setupFee, 2),
'automated' => true,
'one_time' => true,
];
}
foreach ([
'image' => 'images',
'region' => 'regions',
'license' => 'licenses',
'application' => 'applications',
'additional_ip' => 'additional_ip',
'private_networking' => 'private_networking',
'storage_type' => 'storage_types',
'object_storage' => 'object_storage',
] as $selectionKey => $catalogKey) {
$value = (string) ($selection[$selectionKey] ?? '');
if ($value === '') {
continue;
}
$option = $this->findOption($catalog[$catalogKey], $value);
if (! $option) {
continue;
}
$amount = (float) ($option['prices'][$billingCycle] ?? 0);
if ($amount <= 0) {
continue;
}
$lines[] = [
'code' => $selectionKey,
'label' => (string) ($option['label'] ?? $value),
'amount' => round($amount, 2),
'automated' => (bool) ($option['automated'] ?? false),
];
if (! ($option['automated'] ?? false)) {
$manualFollowUp[] = (string) ($option['label'] ?? $value);
}
}
$total = round(collect($lines)->sum('amount'), 2);
return [
'currency' => $product->display_currency,
'billing_cycle' => $billingCycle,
'months' => $this->cycleMonths($billingCycle),
'base_total' => round($baseTotal, 2),
'line_items' => $lines,
'total' => $total,
'manual_follow_up' => array_values(array_unique($manualFollowUp)),
];
}
/**
* @param array<string, mixed> $selection
* @return array<string, mixed>
*/
public function provisioningConfig(HostingProduct $product, array $selection, string $serverName, string $billingCycle): array
{
$catalog = $this->catalogForProduct($product);
$image = $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''));
$license = $this->findOption($catalog['licenses'], (string) ($selection['license'] ?? ''));
$application = $this->findOption($catalog['applications'], (string) ($selection['application'] ?? ''));
$region = $this->findOption($catalog['regions'], (string) ($selection['region'] ?? ''));
$additionalIp = $this->findOption($catalog['additional_ip'], (string) ($selection['additional_ip'] ?? ''));
$privateNetworking = $this->findOption($catalog['private_networking'], (string) ($selection['private_networking'] ?? ''));
$storageType = $this->findOption($catalog['storage_types'], (string) ($selection['storage_type'] ?? ''));
$objectStorage = $this->findOption($catalog['object_storage'], (string) ($selection['object_storage'] ?? ''));
$addOns = [];
foreach ([$image, $additionalIp, $privateNetworking, $storageType] as $option) {
$addOnKey = (string) ($option['add_on'] ?? '');
if ($addOnKey !== '') {
$addOns[$addOnKey] = new \stdClass();
}
}
$manualFollowUp = [];
foreach ([$license, $application, $storageType, $objectStorage] as $option) {
if ($option && ! ($option['automated'] ?? false)) {
$manualFollowUp[] = (string) ($option['label'] ?? $option['value'] ?? 'Option');
}
}
$applicationId = $application['application_id'] ?? null;
$userData = $this->cloudInitForOption((array) $application, (string) ($selection['default_user'] ?? ($image['default_user'] ?? 'root')));
return [
'product_id' => $product->contabo_product_id,
'image_id' => ! ($image['is_install_later'] ?? false) ? ($image['image_id'] ?? $selection['image'] ?? config('hosting.vps.default_image')) : null,
'region' => (string) ($region['value'] ?? $selection['region'] ?? $product->contabo_region ?? config('hosting.vps.default_region', 'EU')),
'display_name' => $serverName,
'default_user' => (string) ($selection['default_user'] ?? ($image['default_user'] ?? 'root')),
'license' => $license['license'] ?? null,
'panel' => $license['panel'] ?? null,
'application_id' => is_string($applicationId) && $applicationId !== '' ? $applicationId : null,
'user_data' => $userData,
'period' => $this->cycleMonths($billingCycle),
'add_ons' => $addOns,
'manual_follow_up' => array_values(array_unique($manualFollowUp)),
'panel_snapshot' => $this->panelCapabilities->describeSelection($product, $selection, $catalog),
];
}
/**
* @return array<string, mixed>
*/
public function defaultSelections(HostingProduct $product): array
{
$catalog = $this->catalogForProduct($product);
$defaultImage = $catalog['images'][0]['value'] ?? null;
$defaultImageMeta = $defaultImage ? $this->findOption($catalog['images'], $defaultImage) : null;
$defaultUserOptions = $this->defaultUserOptions($product, (string) ($defaultImageMeta['os_family'] ?? 'linux'));
return [
'image' => $defaultImage,
'region' => (string) ($catalog['regions'][0]['value'] ?? 'EU'),
'license' => 'none',
'application' => 'none',
'additional_ip' => 'none',
'private_networking' => 'disabled',
'storage_type' => 'included',
'object_storage' => 'none',
'default_user' => (string) ($defaultUserOptions[0]['value'] ?? 'root'),
];
}
/**
* @param array<string, mixed> $selection
* @param array<string, mixed>|null $catalog
* @return array<int, array<string, mixed>>
*/
public function selectionSummary(HostingProduct $product, array $selection, ?array $catalog = null): array
{
$catalog ??= $this->catalogForProduct($product);
$image = $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''));
$defaultUsers = $this->defaultUserOptions($product, (string) ($image['os_family'] ?? 'linux'));
return array_values(array_filter([
$this->selectionSummaryItem('image', 'Image', $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''))),
$this->selectionSummaryItem('region', 'Region', $this->findOption($catalog['regions'], (string) ($selection['region'] ?? ''))),
$this->selectionSummaryItem('license', 'License', $this->findOption($catalog['licenses'], (string) ($selection['license'] ?? ''))),
$this->selectionSummaryItem('application', 'Preinstalled App', $this->findOption($catalog['applications'], (string) ($selection['application'] ?? ''))),
$this->selectionSummaryItem('default_user', 'Default User', $this->findOption($defaultUsers, (string) ($selection['default_user'] ?? '')), true),
$this->selectionSummaryItem('additional_ip', 'IP Address', $this->findOption($catalog['additional_ip'], (string) ($selection['additional_ip'] ?? ''))),
$this->selectionSummaryItem('private_networking', 'Private Networking', $this->findOption($catalog['private_networking'], (string) ($selection['private_networking'] ?? ''))),
$this->selectionSummaryItem('storage_type', 'Storage Type', $this->findOption($catalog['storage_types'], (string) ($selection['storage_type'] ?? ''))),
$this->selectionSummaryItem('object_storage', 'Object Storage', $this->findOption($catalog['object_storage'], (string) ($selection['object_storage'] ?? ''))),
]));
}
/**
* @return array<string, float>
*/
public function baseCyclePrices(HostingProduct $product): array
{
$cycles = [
CustomerHostingOrder::CYCLE_MONTHLY,
CustomerHostingOrder::CYCLE_QUARTERLY,
CustomerHostingOrder::CYCLE_SEMIANNUAL,
CustomerHostingOrder::CYCLE_YEARLY,
];
$prices = [];
foreach ($cycles as $cycle) {
$price = $product->getPriceForCycle($cycle);
if ($price !== null) {
$prices[$cycle] = (float) $price;
}
}
return $prices;
}
/**
* @return array<string, float>
*/
public function setupFeesByCycle(HostingProduct $product): array
{
$fees = [];
foreach (array_keys($this->baseCyclePrices($product)) as $cycle) {
$fees[$cycle] = $this->setupFeeForCycle($product, $cycle);
}
return $fees;
}
public function setupFeeForCycle(HostingProduct $product, string $billingCycle): float
{
if (! $product->requiresContabo()) {
return 0.0;
}
$productId = trim((string) $product->contabo_product_id);
if ($productId === '') {
return 0.0;
}
foreach ((array) config('hosting.pricing.setup_fee_rules', []) as $rule) {
if (! in_array($productId, array_map('strval', (array) ($rule['product_ids'] ?? [])), true)) {
continue;
}
if (! in_array($billingCycle, array_map('strval', (array) ($rule['billing_cycles'] ?? [])), true)) {
continue;
}
if (isset($rule['fixed_eur_by_cycle'])) {
$eurAmount = (float) ($rule['fixed_eur_by_cycle'][$billingCycle] ?? 0);
if ($eurAmount <= 0) {
return 0.0;
}
$eurToGhs = app(\App\Services\ExchangeRateService::class)->getEurToGhs();
$margin = $this->pricing->getMargin($product->category);
$ghs = $eurAmount * $eurToGhs * (1 + ($margin / 100));
return round($ghs / 5) * 5;
}
$monthlyPrice = (float) $product->getPriceForCycle(CustomerHostingOrder::CYCLE_MONTHLY);
$multiplier = (float) ($rule['monthly_price_multiplier'] ?? 1);
return round($monthlyPrice * $multiplier, 2);
}
return 0.0;
}
private function setupFeeLabel(HostingProduct $product, string $billingCycle): string
{
$productId = trim((string) $product->contabo_product_id);
foreach ((array) config('hosting.pricing.setup_fee_rules', []) as $rule) {
if (
in_array($productId, array_map('strval', (array) ($rule['product_ids'] ?? [])), true)
&& in_array($billingCycle, array_map('strval', (array) ($rule['billing_cycles'] ?? [])), true)
) {
return (string) ($rule['label'] ?? 'One-time setup fee');
}
}
return 'One-time setup fee';
}
public function cycleMonths(string $billingCycle): int
{
return match ($billingCycle) {
CustomerHostingOrder::CYCLE_MONTHLY => 1,
CustomerHostingOrder::CYCLE_QUARTERLY => 3,
CustomerHostingOrder::CYCLE_SEMIANNUAL => 6,
CustomerHostingOrder::CYCLE_YEARLY => 12,
CustomerHostingOrder::CYCLE_BIENNIAL => 24,
default => 1,
};
}
/**
* @param array<int, array<string, mixed>> $options
* @return array<string, mixed>|null
*/
private function findOption(array $options, ?string $value): ?array
{
foreach ($options as $option) {
if ((string) ($option['value'] ?? '') === (string) $value) {
return $option;
}
}
return null;
}
/**
* @param array<int, array<string, mixed>> $options
*/
private function validatedOptionValue(array $options, string $value): string
{
return $this->findOption($options, $value)['value'] ?? (string) ($options[0]['value'] ?? '');
}
/**
* @param array<string, array<string, mixed>> $options
* @return array<int, array<string, mixed>>
*/
private function optionCollection(HostingProduct $product, array $options): array
{
$items = [];
foreach ($options as $value => $option) {
if (! $this->optionIsAvailable($option, (string) $value)) {
continue;
}
$items[] = [
'value' => (string) $value,
'label' => (string) ($option['label'] ?? $value),
'description' => (string) ($option['description'] ?? ''),
'monthly_usd' => (float) ($option['monthly_usd'] ?? 0),
'prices' => $this->cyclePricesForRecurringUsd($product, (float) ($option['monthly_usd'] ?? 0)),
'license' => $option['license'] ?? null,
'panel' => $option['panel'] ?? null,
'application_id' => $option['application_id'] ?? null,
'cloud_init_preset' => $option['cloud_init_preset'] ?? null,
'add_on' => $option['add_on'] ?? null,
'compatible_os_families' => $this->normalizedOsFamilies($option['compatible_os_families'] ?? []),
'requires_image' => (bool) ($option['requires_image'] ?? false),
'requires_managed_stack_image' => (bool) ($option['requires_managed_stack_image'] ?? false),
'automated' => (bool) ($option['automated'] ?? false),
];
}
return $items;
}
/**
* @return array<int, array<string, mixed>>
*/
private function imageOptions(HostingProduct $product): array
{
if ($this->availableImages !== null) {
return $this->availableImages;
}
$images = [];
try {
foreach ($this->contaboProvider->getAvailableImages() as $image) {
$rule = $this->matchImageRule($image);
$images[] = [
'value' => (string) $image['id'],
'label' => (string) ($image['name'] ?? ($rule['label'] ?? 'Image')),
'description' => (string) ($image['description'] ?? ($rule['label'] ?? '')),
'image_id' => (string) $image['id'],
'os_family' => (string) ($rule['os_family'] ?? 'linux'),
'default_user' => (string) ($rule['default_user'] ?? 'root'),
'monthly_usd' => (float) ($rule['monthly_usd'] ?? 0),
'prices' => $this->cyclePricesForRecurringUsd($product, (float) ($rule['monthly_usd'] ?? 0)),
'automated' => true,
'add_on' => ! empty($rule['requires_custom_image_addon']) ? 'customImage' : null,
];
}
} catch (\Throwable) {
// Fall back to configured image list when the API is unavailable.
}
if ($images !== []) {
usort($images, fn (array $a, array $b): int => strcmp((string) ($a['label'] ?? ''), (string) ($b['label'] ?? '')));
return $this->availableImages = $this->prependInstallLaterImage($product, $images);
}
$fallback = [];
foreach ((array) config('hosting.server_order.fallback_images', []) as $image) {
$fallback[] = [
'value' => (string) ($image['value'] ?? ''),
'label' => (string) ($image['label'] ?? 'Image'),
'description' => (string) ($image['description'] ?? ''),
'image_id' => (string) ($image['value'] ?? ''),
'os_family' => (string) ($image['os_family'] ?? 'linux'),
'default_user' => (string) ($image['default_user'] ?? 'root'),
'monthly_usd' => (float) ($image['monthly_usd'] ?? 0),
'prices' => $this->cyclePricesForRecurringUsd($product, (float) ($image['monthly_usd'] ?? 0)),
'automated' => (bool) ($image['automated'] ?? false),
'add_on' => ! empty($image['requires_custom_image_addon']) ? 'customImage' : null,
];
}
return $this->availableImages = $this->prependInstallLaterImage($product, $fallback);
}
/**
* @param array<string, mixed> $image
* @return array<string, mixed>
*/
private function matchImageRule(array $image): array
{
$name = strtolower(trim((string) ($image['name'] ?? '')));
$standardImage = (bool) ($image['standard_image'] ?? true);
$rules = (array) config('hosting.server_order.image_pricing_rules', []);
foreach ($rules as $rule) {
$isCustomRule = (bool) ($rule['custom_image'] ?? false);
if ($isCustomRule && ! $standardImage) {
return $rule;
}
foreach ((array) ($rule['match'] ?? []) as $needle) {
if ($needle !== '' && str_contains($name, strtolower((string) $needle))) {
return $rule;
}
}
}
return [
'label' => $image['name'] ?? 'Image',
'monthly_usd' => 0,
'os_family' => str_contains(strtolower((string) ($image['os_type'] ?? 'linux')), 'win') ? 'windows' : 'linux',
'default_user' => str_contains(strtolower((string) ($image['os_type'] ?? 'linux')), 'win') ? 'administrator' : 'root',
'requires_custom_image_addon' => ! $standardImage,
];
}
/**
* @return array<int, array<string, mixed>>
*/
private function defaultUserOptions(HostingProduct $product, string $osFamily): array
{
$configured = str_starts_with($osFamily, 'win')
? (array) config('hosting.server_order.windows_default_users', [])
: (array) config('hosting.server_order.linux_default_users', []);
$options = [];
foreach ($configured as $value => $option) {
$options[] = [
'value' => (string) $value,
'label' => (string) ($option['label'] ?? $value),
];
}
return $options;
}
/**
* @return array<int, array<string, mixed>>
*/
private function applicationOptions(HostingProduct $product): array
{
$configApps = (array) config('hosting.server_order.applications', []);
$cachedIds = \Illuminate\Support\Facades\Cache::get('contabo_blockchain_app_ids', []);
if ($this->hasMissingConfiguredApplicationIds($configApps)) {
$resolvedIds = $this->resolveApplicationIdsFromCatalog($configApps);
if ($resolvedIds !== []) {
$cachedIds = array_merge($cachedIds, $resolvedIds);
\Illuminate\Support\Facades\Cache::put('contabo_blockchain_app_ids', $cachedIds, now()->addDays(7));
}
}
foreach ($configApps as $key => $option) {
$appId = $option['application_id'] ?? null;
if (($appId === null || $appId === '') && isset($cachedIds[$key])) {
$configApps[$key]['application_id'] = $cachedIds[$key]['id'];
$appId = $configApps[$key]['application_id'];
}
$preset = trim((string) ($option['cloud_init_preset'] ?? ''));
$resolvedAppId = trim((string) $appId);
if ($key !== 'none' && $resolvedAppId === '' && $preset === '') {
$configApps[$key]['allow_unresolved'] = true;
$configApps[$key]['automated'] = false;
$description = trim((string) ($configApps[$key]['description'] ?? ''));
if ($description !== '' && ! str_contains(strtolower($description), 'manual')) {
$configApps[$key]['description'] = $description.' Manual setup may be required after provisioning.';
}
}
}
return $this->optionCollection($product, $configApps);
}
/**
* @param array<string, array<string, mixed>> $configApps
*/
private function hasMissingConfiguredApplicationIds(array $configApps): bool
{
foreach ($configApps as $key => $option) {
if (! array_key_exists($key, self::APPLICATION_NAME_MAPPINGS)) {
continue;
}
if (trim((string) ($option['application_id'] ?? '')) === '') {
return true;
}
}
return false;
}
/**
* @param array<string, array<string, mixed>> $configApps
* @return array<string, array{id: string, name: string, description: string}>
*/
private function resolveApplicationIdsFromCatalog(array $configApps): array
{
try {
$applications = $this->contaboProvider->getAvailableApplications();
} catch (\Throwable) {
return [];
}
$mapped = [];
foreach (self::APPLICATION_NAME_MAPPINGS as $configKey => $searchTerms) {
if (! isset($configApps[$configKey])) {
continue;
}
if (trim((string) ($configApps[$configKey]['application_id'] ?? '')) !== '') {
continue;
}
foreach ($applications as $app) {
$name = strtolower((string) ($app['name'] ?? ''));
foreach ($searchTerms as $term) {
if ($term !== '' && str_contains($name, strtolower($term))) {
$mapped[$configKey] = [
'id' => (string) ($app['id'] ?? ''),
'name' => (string) ($app['name'] ?? $configKey),
'description' => (string) ($app['description'] ?? ''),
];
break 2;
}
}
}
}
return array_filter($mapped, static fn (array $app): bool => $app['id'] !== '');
}
/**
* @param array<int, array<string, mixed>> $images
* @return array<string, mixed>|null
*/
private function defaultImageOption(array $images): ?array
{
foreach ($images as $image) {
if (! ($image['is_install_later'] ?? false)) {
return $image;
}
}
return $images[0] ?? null;
}
/**
* @param array<int, array<string, mixed>> $images
* @return array<int, array<string, mixed>>
*/
private function prependInstallLaterImage(HostingProduct $product, array $images): array
{
array_unshift($images, [
'value' => self::INSTALL_LATER_IMAGE,
'label' => 'Install Later',
'description' => 'Provision the server first and choose the final OS installation afterward.',
'image_id' => null,
'os_family' => 'linux',
'default_user' => 'root',
'monthly_usd' => 0.0,
'prices' => $this->cyclePricesForRecurringUsd($product, 0.0),
'automated' => true,
'add_on' => null,
'is_install_later' => true,
]);
return $images;
}
/**
* @param array<string, mixed> $selection
* @param array<string, mixed> $catalog
*/
private function assertCompatibleSelections(array $selection, array $catalog): void
{
$image = $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''));
$license = $this->findOption($catalog['licenses'], (string) ($selection['license'] ?? ''));
$application = $this->findOption($catalog['applications'], (string) ($selection['application'] ?? ''));
$errors = [];
if (! $this->optionSupportsImage($license, $image)) {
$errors['license'] = 'The selected control panel is not compatible with this image.';
}
if (! $this->optionSupportsImage($application, $image)) {
$errors['application'] = 'The selected preinstalled app is not compatible with this image.';
}
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
}
/**
* @param array<string, mixed>|null $option
* @param array<string, mixed>|null $image
*/
private function optionSupportsImage(?array $option, ?array $image): bool
{
if (! $option) {
return true;
}
$imageLabel = strtolower((string) ($image['label'] ?? ''));
$bundledPanel = $this->bundledPanelFromImageLabel($imageLabel);
$optionLicense = strtolower((string) ($option['license'] ?? ''));
if (($option['requires_image'] ?? false) && ($image['is_install_later'] ?? false)) {
return false;
}
if (($option['requires_managed_stack_image'] ?? false) && ! $this->imageSupportsManagedStack($image)) {
return false;
}
if ($bundledPanel !== null && (string) ($option['value'] ?? '') === 'none') {
return false;
}
if ($bundledPanel === 'cpanel' && str_starts_with($optionLicense, 'plesk')) {
return false;
}
if ($bundledPanel === 'plesk' && str_starts_with($optionLicense, 'cpanel')) {
return false;
}
$families = (array) ($option['compatible_os_families'] ?? []);
if ($families === []) {
return true;
}
$osFamily = strtolower((string) ($image['os_family'] ?? 'linux'));
foreach ($families as $family) {
if ($family !== '' && str_starts_with($osFamily, strtolower((string) $family))) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed>|null $image
*/
private function imageSupportsManagedStack(?array $image): bool
{
if (! $image || ($image['is_install_later'] ?? false)) {
return false;
}
$imageLabel = strtolower((string) ($image['label'] ?? ''));
$osFamily = strtolower((string) ($image['os_family'] ?? 'linux'));
if (str_starts_with($osFamily, 'win') || $this->imageIncludesVendorPanel($imageLabel)) {
return false;
}
foreach ((array) config('hosting.server_order.managed_stack_supported_images', []) as $supported) {
$supportedOsFamily = strtolower((string) ($supported['os_family'] ?? 'linux'));
if ($supportedOsFamily !== '' && $supportedOsFamily !== $osFamily) {
continue;
}
foreach ((array) ($supported['match'] ?? []) as $needle) {
$needle = strtolower(trim((string) $needle));
if ($needle !== '' && str_contains($imageLabel, $needle)) {
return true;
}
}
}
return false;
}
private function imageIncludesVendorPanel(string $imageLabel): bool
{
return $this->bundledPanelFromImageLabel($imageLabel) !== null;
}
private function bundledPanelFromImageLabel(string $imageLabel): ?string
{
if (str_contains($imageLabel, 'cpanel') || str_contains($imageLabel, 'whm')) {
return 'cpanel';
}
if (str_contains($imageLabel, 'plesk')) {
return 'plesk';
}
return null;
}
/**
* @param array<string, mixed> $option
*/
private function optionIsAvailable(array $option, string $value): bool
{
if ($value === 'none') {
return true;
}
if (! empty($option['allow_unresolved'])) {
return true;
}
$applicationId = trim((string) ($option['application_id'] ?? ''));
$preset = trim((string) ($option['cloud_init_preset'] ?? ''));
if ($applicationId !== '' || $preset !== '') {
return true;
}
return array_key_exists('application_id', $option) || array_key_exists('cloud_init_preset', $option)
? false
: true;
}
/**
* @param mixed $families
* @return array<int, string>
*/
private function normalizedOsFamilies(mixed $families): array
{
$items = is_array($families) ? $families : [];
return array_values(array_filter(array_map(
static fn ($family): string => strtolower(trim((string) $family)),
$items
)));
}
/**
* @param array<string, mixed> $option
*/
private function cloudInitForOption(array $option, string $defaultUser): ?string
{
$preset = (string) ($option['cloud_init_preset'] ?? '');
if ($preset === '') {
return null;
}
return match ($preset) {
'webmin' => $this->contaboProvider->generateCloudInit([
'packages' => ['curl', 'wget'],
'run_commands' => [
'bash -lc \'if command -v apt-get >/dev/null 2>&1; then curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y webmin; elif command -v dnf >/dev/null 2>&1; then dnf install -y perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && dnf install -y webmin; elif command -v yum >/dev/null 2>&1; then yum install -y perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && yum install -y webmin; fi\'',
],
]),
'webmin_lamp' => $this->contaboProvider->generateCloudInit([
'packages' => ['curl', 'wget'],
'run_commands' => [
'bash -lc \'if command -v apt-get >/dev/null 2>&1; then apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y apache2 mariadb-server php libapache2-mod-php php-mysql curl wget && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y webmin; elif command -v dnf >/dev/null 2>&1; then dnf install -y httpd mariadb-server php php-mysqlnd curl wget perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && dnf install -y webmin; elif command -v yum >/dev/null 2>&1; then yum install -y httpd mariadb-server php php-mysqlnd curl wget perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && yum install -y webmin; fi && (systemctl enable --now apache2 || systemctl enable --now httpd || true) && (systemctl enable --now mariadb || systemctl enable --now mysqld || true)\'',
],
]),
default => $this->contaboProvider->generateCloudInit([
'packages' => ['curl', 'wget'],
'run_commands' => [
sprintf('echo "Preset %s selected for %s"', $preset, $defaultUser),
],
]),
};
}
/**
* @param array<string, mixed>|null $option
* @return array<string, mixed>|null
*/
private function selectionSummaryItem(string $key, string $label, ?array $option, bool $priceOptional = false): ?array
{
if (! $option) {
return null;
}
$amount = (float) ($option['prices'][CustomerHostingOrder::CYCLE_MONTHLY] ?? 0);
if (! $priceOptional && ($option['label'] ?? null) === null) {
return null;
}
return [
'key' => $key,
'label' => $label,
'value' => (string) ($option['label'] ?? $option['value'] ?? ''),
'description' => (string) ($option['description'] ?? ''),
'automated' => (bool) ($option['automated'] ?? true),
'monthly_amount' => round($amount, 2),
];
}
/**
* @return array<string, float>
*/
private function cyclePricesForRecurringUsd(HostingProduct $product, float $monthlyUsd): array
{
$monthlyGhs = $monthlyUsd > 0
? $this->pricing->convertUsdRecurringOption($monthlyUsd, $product->category)
: 0.0;
return [
CustomerHostingOrder::CYCLE_MONTHLY => round($monthlyGhs, 2),
CustomerHostingOrder::CYCLE_QUARTERLY => round($monthlyGhs * 3, 2),
CustomerHostingOrder::CYCLE_SEMIANNUAL => round($monthlyGhs * 6, 2),
CustomerHostingOrder::CYCLE_YEARLY => round($monthlyGhs * 12, 2),
];
}
}