240 lines
8.6 KiB
PHP
240 lines
8.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Provisioning;
|
|
|
|
use App\Models\Host;
|
|
use App\Models\Order;
|
|
use App\Models\ProvisioningRun;
|
|
use App\Models\Subscription;
|
|
use App\Provisioning\Steps\Customer\ReserveResources;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use App\Services\Billing\PlanCatalogue;
|
|
use Throwable;
|
|
|
|
/**
|
|
* What the estate can still take, and which packages it can no longer deliver.
|
|
*
|
|
* The platform books thick: `disk_gb` — the sold quota plus the machine's own
|
|
* overhead — is reserved against a host at placement, not the customer's actual
|
|
* usage. That is the safe choice for a small operator (nothing can quietly
|
|
* overfill), and it has one consequence worth surfacing: the catalogue can go
|
|
* on offering a package that no host has room for, and nobody finds out until a
|
|
* paid order fails at the reservation step.
|
|
*
|
|
* So this asks the question BEFORE the order does.
|
|
*/
|
|
class HostCapacity
|
|
{
|
|
/** Is there a host in this datacenter that could take this much? */
|
|
public function canPlace(string $datacenter, int $diskGb): bool
|
|
{
|
|
return Host::placeableIn($datacenter, $diskGb) !== null;
|
|
}
|
|
|
|
/**
|
|
* The largest single reservation the estate could still accept.
|
|
*
|
|
* The largest FREE HOST, not the sum: a package goes on one machine, so two
|
|
* hosts with 300 GB each cannot take a 540 GB instance between them.
|
|
*/
|
|
public function largestPlaceableGb(?string $datacenter = null): int
|
|
{
|
|
return (int) Host::query()
|
|
->where('status', 'active')
|
|
->when($datacenter !== null, fn ($q) => $q->where('datacenter', $datacenter))
|
|
->get()
|
|
->map(fn (Host $host) => $host->availableGb())
|
|
->max() ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Sellable packages that no host could currently take.
|
|
*
|
|
* Keyed by plan key, valued by what it needs and what the roomiest host has
|
|
* left — an operator deciding whether to buy a machine needs the shortfall,
|
|
* not a yes/no.
|
|
*
|
|
* @return array<string, array{name: string, needs: int, largest: int}>
|
|
*/
|
|
public function unplaceablePlans(?string $datacenter = null): array
|
|
{
|
|
try {
|
|
$sellable = app(PlanCatalogue::class)->sellable();
|
|
} catch (Throwable) {
|
|
// A catalogue that cannot be read is its own alarm elsewhere. This
|
|
// is a capacity warning; it does not get to be the thing that takes
|
|
// the console's overview page down.
|
|
return [];
|
|
}
|
|
|
|
// No host at all is not a capacity problem — it is an installation
|
|
// nobody has onboarded a machine into yet, and it has its own notices.
|
|
// Without this, a fresh console greeted the operator with one warning
|
|
// per sellable package before they had done anything at all.
|
|
$hasHost = Host::query()
|
|
->where('status', 'active')
|
|
->when($datacenter !== null, fn ($q) => $q->where('datacenter', $datacenter))
|
|
->exists();
|
|
|
|
if (! $hasHost) {
|
|
return [];
|
|
}
|
|
|
|
$largest = $this->largestPlaceableGb($datacenter);
|
|
$atRisk = [];
|
|
|
|
foreach ($sellable as $key => $plan) {
|
|
$needs = (int) ($plan['disk_gb'] ?? 0);
|
|
|
|
if ($needs > 0 && $needs > $largest) {
|
|
$atRisk[$key] = [
|
|
'name' => (string) ($plan['name'] ?? $key),
|
|
'needs' => $needs,
|
|
'largest' => $largest,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $atRisk;
|
|
}
|
|
|
|
/**
|
|
* The runs parked for want of a machine, as a query.
|
|
*
|
|
* Recognised by where they are standing, not by a flag: a run WAITING at
|
|
* the reservation step is a run that could not be placed. A second column
|
|
* saying the same thing is a second thing to keep in step with the first.
|
|
*
|
|
* `current_step` is an INDEX into the pipeline, not the step's key — it is
|
|
* cast to integer on the model. Comparing it to 'reserve_resources' looks
|
|
* right and is not: the database casts the string to 0 and quietly matches
|
|
* the FIRST step of every pipeline instead. Hence the lookup, and hence the
|
|
* pipeline filter next to it — index 1 of the host pipeline is a different
|
|
* step entirely, and an onboarding waiting on SSH is not a parked order.
|
|
*
|
|
* @return \Illuminate\Database\Eloquent\Builder<ProvisioningRun>
|
|
*/
|
|
public function parkedRuns(): Builder
|
|
{
|
|
$pipeline = (array) config('provisioning.pipelines.customer', []);
|
|
$index = array_search(ReserveResources::class, $pipeline, true);
|
|
|
|
return ProvisioningRun::query()
|
|
->where('pipeline', 'customer')
|
|
->where('status', ProvisioningRun::STATUS_WAITING)
|
|
// -1 matches nothing, which is the honest answer for a pipeline
|
|
// that does not contain the step at all.
|
|
->where('current_step', $index === false ? -1 : (int) $index);
|
|
}
|
|
|
|
/** Is this particular run waiting for a machine rather than for a step? */
|
|
public function isParked(ProvisioningRun $run): bool
|
|
{
|
|
return $run->exists && $this->parkedRuns()->whereKey($run->getKey())->exists();
|
|
}
|
|
|
|
/**
|
|
* Paid orders parked for want of a machine.
|
|
*
|
|
* @return array<int, array{run: ProvisioningRun, plan: string, needs: int, waiting_since: \Illuminate\Support\Carbon, datacenter: string, pinned_host_id: int|null}>
|
|
*/
|
|
public function parked(): array
|
|
{
|
|
$runs = $this->parkedRuns()
|
|
->with('subject')
|
|
->orderBy('created_at')
|
|
->get();
|
|
|
|
$parked = [];
|
|
|
|
foreach ($runs as $run) {
|
|
$order = $run->subject;
|
|
|
|
if (! $order instanceof Order) {
|
|
continue;
|
|
}
|
|
|
|
$plan = $this->planOf($order);
|
|
|
|
$parked[] = [
|
|
'run' => $run,
|
|
'order' => $order,
|
|
'plan' => (string) $order->plan,
|
|
'name' => (string) ($plan['name'] ?? $order->plan),
|
|
'needs' => (int) ($plan['disk_gb'] ?? 0),
|
|
'waiting_since' => $run->created_at,
|
|
'datacenter' => (string) $order->datacenter,
|
|
// Where an operator has said this one goes, if anywhere. The
|
|
// step is what honours it; this is only so the console can show
|
|
// the choice it is displaying a dropdown for.
|
|
'pinned_host_id' => ($id = $run->context('preferred_host_id')) === null ? null : (int) $id,
|
|
];
|
|
}
|
|
|
|
return $parked;
|
|
}
|
|
|
|
/**
|
|
* What to buy, in one sentence's worth of numbers.
|
|
*
|
|
* The BIGGEST parked package decides the minimum machine — an instance
|
|
* lives on one host, so a server that cannot take the largest one leaves it
|
|
* parked however much total space it has. The sum is what clears the queue
|
|
* in one purchase.
|
|
*
|
|
* @return array{count: int, largest: int, total: int, by_plan: array<string, int>}
|
|
*/
|
|
public function queueDemand(): array
|
|
{
|
|
$parked = $this->parked();
|
|
$byPlan = [];
|
|
|
|
foreach ($parked as $entry) {
|
|
$byPlan[$entry['name']] = ($byPlan[$entry['name']] ?? 0) + 1;
|
|
}
|
|
|
|
arsort($byPlan);
|
|
|
|
return [
|
|
'count' => count($parked),
|
|
'largest' => (int) (collect($parked)->max('needs') ?? 0),
|
|
'total' => (int) collect($parked)->sum('needs'),
|
|
'by_plan' => $byPlan,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* How soon a package of this size can be delivered.
|
|
*
|
|
* Two answers, and the second one is a promise about US, not about a
|
|
* machine: if there is room it rolls out on its own; if there is not,
|
|
* somebody has to order a server, and two to three working days is what
|
|
* that honestly takes including a weekend.
|
|
*/
|
|
public function deliveryFor(int $diskGb, ?string $datacenter = null): string
|
|
{
|
|
$largest = $this->largestPlaceableGb($datacenter);
|
|
|
|
return $diskGb > 0 && $diskGb <= $largest ? 'immediate' : 'scheduled';
|
|
}
|
|
|
|
/**
|
|
* The sizes this customer actually bought.
|
|
*
|
|
* From their frozen contract, never from the catalogue — the same rule the
|
|
* provisioning steps follow. Sizing the queue from today's catalogue would
|
|
* recommend a machine for a package the waiting customer did not buy.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function planOf(Order $order): array
|
|
{
|
|
$subscription = Subscription::query()->where('order_id', $order->id)->first();
|
|
|
|
return $subscription === null ? [] : [
|
|
'name' => __('billing.plan.'.$subscription->plan),
|
|
'disk_gb' => (int) $subscription->disk_gb,
|
|
];
|
|
}
|
|
}
|