CluPilotCloud/app/Provisioning/Steps/Customer/ReserveResources.php

126 lines
4.3 KiB
PHP

<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class ReserveResources extends CustomerStep
{
public function key(): string
{
return 'reserve_resources';
}
public function maxDuration(): int
{
return 60;
}
public function execute(ProvisioningRun $run): StepResult
{
$order = $this->order($run);
// Idempotent: an instance already exists for this order.
$existing = Instance::query()->where('order_id', $order->id)->first();
if ($existing !== null) {
$this->putContext($run, $existing);
$this->linkToSubscription($run, $existing);
return StepResult::advance();
}
$subscription = $this->subscription($run);
if ($subscription === null) {
return StepResult::fail('no_subscription');
}
// Sizes come from the contract, not the catalogue: this customer gets
// the machine they paid for even if the plan was edited since.
$plan = $this->plan($run);
// Place + create under a per-datacenter lock so concurrent reservations
// can't both pick the same host and overcommit its capacity.
$instance = Cache::lock('placement:'.$order->datacenter, 30)->block(10, function () use ($order, $plan) {
$host = Host::placeableIn($order->datacenter, (int) $plan['disk_gb']);
if ($host === null) {
return null;
}
return Instance::create([
'customer_id' => $order->customer_id,
'order_id' => $order->id,
'host_id' => $host->id,
'plan' => $order->plan,
'quota_gb' => $plan['quota_gb'],
'disk_gb' => $plan['disk_gb'],
'ram_mb' => $plan['ram_mb'],
'cores' => $plan['cores'],
'subdomain' => $this->uniqueSubdomain($order),
'status' => 'reserving',
]);
});
// Named, not just 'no_capacity'. An operator reading a failed run needs
// to know WHAT would have fitted — the difference between "buy a
// server" and "buy a server with 540 GB free" is the whole decision.
if ($instance === null) {
$largest = app(\App\Services\Provisioning\HostCapacity::class)
->largestPlaceableGb($order->datacenter);
Log::error('Placement failed: no host in '.$order->datacenter.' has room for '.$plan['disk_gb'].' GB (largest free: '.$largest.' GB). Order '.$order->id.' is paid.');
return StepResult::fail('no_capacity');
}
$this->putContext($run, $instance);
$this->linkToSubscription($run, $instance);
$this->recordResource($run, $instance->host, 'instance_id', (string) $instance->id);
return StepResult::advance();
}
/**
* Point the contract at the machine that fulfils it, so anything asking
* "what is this customer entitled to?" can get there from either end.
* instance_id is not part of the frozen snapshot, so this is allowed.
*/
private function linkToSubscription(ProvisioningRun $run, Instance $instance): void
{
$subscription = $this->subscription($run);
if ($subscription !== null && $subscription->instance_id !== $instance->id) {
$subscription->update(['instance_id' => $instance->id]);
}
}
private function putContext(ProvisioningRun $run, Instance $instance): void
{
$run->mergeContext([
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node ?? 'pve',
'subdomain' => $instance->subdomain,
'plan' => $instance->plan,
]);
}
private function uniqueSubdomain(Order $order): string
{
$base = Str::slug($order->customer->name) ?: 'cloud';
do {
$subdomain = $base.'-'.Str::lower(Str::random(5));
} while (Instance::query()->where('subdomain', $subdomain)->exists());
return $subdomain;
}
}