CluPilotCloud/app/Services/Provisioning/HostCapacity.php

267 lines
9.6 KiB
PHP

<?php
namespace App\Services\Provisioning;
use App\Models\Datacenter;
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,
];
}
/**
* Where a new order should be placed when nobody has said.
*
* The customer is not asked which datacenter they want — one location is
* what is sold, and a dropdown of internal site codes would be a question
* nobody outside this building can answer. So the shop picks: the site
* whose roomiest host has the most left, which is where the order will
* actually roll out fastest.
*
* Falls back to the first configured datacenter, and then to the same
* literal the Stripe webhook has always defaulted to, so a checkout is
* never blocked by an estate that has not been built yet.
*/
public function preferredDatacenter(): string
{
$best = Host::query()
->where('status', 'active')
->get()
->sortByDesc(fn (Host $host) => $host->availableGb())
->first();
return $best?->datacenter
?? Datacenter::query()->orderBy('code')->value('code')
?? 'fsn';
}
/**
* 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,
];
}
}