222 lines
9.3 KiB
PHP
222 lines
9.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 App\Services\Provisioning\HostCapacity;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
|
|
class ReserveResources extends CustomerStep
|
|
{
|
|
/** How long a paid order waits for a machine before it is called failed. */
|
|
public const PARK_DAYS = 14;
|
|
|
|
/** How often it looks again. A host appears when somebody onboards one. */
|
|
public const PARK_POLL_SECONDS = 120;
|
|
|
|
public function key(): string
|
|
{
|
|
return 'reserve_resources';
|
|
}
|
|
|
|
/**
|
|
* The whole park, not the second the body takes.
|
|
*
|
|
* RunRunner measures this from `started_at` and deliberately does NOT reset
|
|
* `started_at` on a poll — that is what lets a polling step's own deadline
|
|
* accumulate across re-entries instead of restarting every time. So for a step
|
|
* that polls, maxDuration is its TOTAL budget, and it has to sit above the
|
|
* step's own deadline. Every other polling step in the repo already does:
|
|
* ConfigureDnsAndTls waits 840 s for a certificate inside 960 s, ConfigureNetwork
|
|
* 90 s inside 150 s.
|
|
*
|
|
* At 60 s against a 120 s poll this one could not survive its own first wait.
|
|
* The run came back due, the runner saw `started_at` two minutes old, turned that
|
|
* into a timeout retry before the body ran, and five timeouts later failRun()
|
|
* marked a paid order and its instance failed and released the instance — about
|
|
* six minutes for a wait that promises fourteen days. Everything built on that
|
|
* promise (the console's capacity queue, HostCapacity::parked(), queueDemand(),
|
|
* the operator going and buying a machine) was unreachable code, and a customer
|
|
* who paid when nothing had room got an incident instead of a delivery date.
|
|
*
|
|
* One poll interval of headroom, so the step's own `no_capacity` fail below is
|
|
* always what fires. That failure names what the operator has to do; a timeout
|
|
* names nothing.
|
|
*/
|
|
public function maxDuration(): int
|
|
{
|
|
return self::PARK_DAYS * 86400 + self::PARK_POLL_SECONDS;
|
|
}
|
|
|
|
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);
|
|
|
|
// The operator may have decided where this one goes. A parked queue is
|
|
// read by a human who knows things the placement rule does not — that
|
|
// one customer belongs beside another, that a machine is about to be
|
|
// taken out of service — and "dieser kommt auf Host x" has to survive
|
|
// the trip from the console to here.
|
|
$pinned = $this->pinnedHost($run, $order);
|
|
|
|
// 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, $pinned) {
|
|
// A pin is honoured or it waits — never quietly overridden. Placing
|
|
// it somewhere else because the chosen host happened to be full
|
|
// would answer a different question than the one the operator
|
|
// answered, and they would find out from the finished instance.
|
|
$host = $pinned !== null
|
|
? ($pinned->canTake((int) $plan['disk_gb']) ? $pinned : null)
|
|
: 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.
|
|
// No room: PARK the order, do not fail it.
|
|
//
|
|
// The customer has paid. Failing the run turns a sale into an incident
|
|
// and leaves an operator to work out what to do with the money; waiting
|
|
// turns it into a delivery date. The estate is finite and thick-booked,
|
|
// so "no host has room right now" is an ordinary Tuesday, not a fault —
|
|
// it means somebody has to order a machine, which takes hours, not
|
|
// seconds.
|
|
//
|
|
// poll(), not retry(): retry consumes the run's attempt budget and this
|
|
// wait is measured in days. The step owns its own deadline instead.
|
|
if ($instance === null) {
|
|
$largest = app(HostCapacity::class)->largestPlaceableGb($order->datacenter);
|
|
|
|
// Long enough for a server to be ordered, installed and onboarded
|
|
// over a weekend; short enough that a forgotten order surfaces as a
|
|
// failure instead of waiting silently for ever. The console shouts
|
|
// about it from the first minute either way.
|
|
if ($run->created_at->diffInDays(now()) >= self::PARK_DAYS) {
|
|
Log::error('Order '.$order->id.' waited '.self::PARK_DAYS.' days for capacity in '.$order->datacenter.' and is being failed. It needs '.$plan['disk_gb'].' GB; the roomiest host has '.$largest.' GB.');
|
|
|
|
return StepResult::fail('no_capacity');
|
|
}
|
|
|
|
// Two different waits, named differently: one is "nobody has room",
|
|
// the other is "the host YOU chose has no room". The second one is
|
|
// a decision to revisit, not a server to buy, and an operator
|
|
// reading 'awaiting_capacity' would go and buy the machine.
|
|
return StepResult::poll(
|
|
self::PARK_POLL_SECONDS,
|
|
$pinned !== null ? 'awaiting_pinned_host' : 'awaiting_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]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The host an operator assigned this order to, if it is still usable.
|
|
*
|
|
* A pin that names a host which has since been removed — or one in another
|
|
* datacenter — is no pin at all. Falling back to ordinary placement is
|
|
* right there: the operator's instruction has lost its subject rather than
|
|
* been overruled.
|
|
*/
|
|
private function pinnedHost(ProvisioningRun $run, Order $order): ?Host
|
|
{
|
|
$id = $run->context('preferred_host_id');
|
|
|
|
if ($id === null) {
|
|
return null;
|
|
}
|
|
|
|
// Same datacenter, or it is not a pin but a mistake. Placement is a
|
|
// per-datacenter question — the lock above is taken per datacenter, and
|
|
// an instance's network, DNS and backups all assume it stayed where it
|
|
// was ordered.
|
|
return Host::query()
|
|
->where('status', 'active')
|
|
->where('datacenter', $order->datacenter)
|
|
->find((int) $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;
|
|
}
|
|
}
|