Take the order, park it, and say when it will be delivered
tests / pest (push) Failing after 7m44s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

A paid order that no host has room for used to FAIL at the reservation
step. The customer has paid by then, so that turned a sale into an
incident and left somebody holding money against nothing. The estate is
finite and booked thick, so "no host has room right now" is an ordinary
Tuesday — it means a machine has to be bought, which takes days.

So the order waits instead. poll(), not retry(): the wait is measured in
days and must not eat the run's attempt budget. It only fails after
PARK_DAYS (14) — long enough to buy, rack and onboard a server over a
weekend, short enough that a forgotten order surfaces instead of waiting
for ever.

Then say so, in the same words on both sides of the payment:

  - The price sheet marks each package "wird sofort ausgeliefert" or
    "Bereitstellung in 2-3 Werktagen", read from free capacity rather
    than written into the page.
  - The customer's own overview says their cloud is being prepared while
    it is parked, instead of a stepper standing still for two days under
    "wird eingerichtet" — which reads as broken.
  - The console front page shows the same per-package answer, so an
    operator can see what visitors are being promised.

And a capacity page for the decision that follows: who is waiting, what
the roomiest host still has, what the queue needs ("3x Start, 1x
Business" - the LARGEST parked package sets the minimum machine, because
an instance lives on one host), and what such a machine costs today from
the provider's live list. It orders nothing: buying a server is a
contract, and a bug in a capacity calculation must not sign one.

The operator can also say where each parked order goes. A pin is
honoured or it waits — never quietly redirected — because placing it
elsewhere answers a different question than the one they answered, and
they would find out from the finished instance.

Two things this shook out:

  - `current_step` is an INDEX into the pipeline, not the step's key, and
    it is cast to integer. `where('current_step', 'reserve_resources')`
    reads as correct and matches step 0 of EVERY pipeline instead. Both
    lookups go through HostCapacity::parkedRuns() now, which resolves the
    index and filters by pipeline.
  - The auction feed sends `hdd_hr` as a list, not a string. Casting it
    printed "Array" into the console and raised a warning that took the
    page down with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus v1.3.27
nexxo 2026-07-29 18:50:46 +02:00
parent 30b1d41c2e
commit ec8675861e
26 changed files with 1310 additions and 12 deletions

View File

@ -1 +1 @@
1.3.26
1.3.27

View File

@ -6,6 +6,7 @@ use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\PlanCatalogue;
use App\Services\Provisioning\HostCapacity;
use App\Support\ProvisioningSettings;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Log;
@ -461,11 +462,19 @@ class LandingController extends Controller
}
$plans = [];
$capacity = app(HostCapacity::class);
foreach ($sellable as $key => $plan) {
$plans[] = [
'key' => $key,
'name' => $plan['name'],
// How soon this package can actually be delivered, read from
// the estate rather than from a sentence somebody typed. Where
// a host has room it rolls out on its own within the hour;
// where none has, a machine has to be bought first — and
// saying "sofort" in that case is a promise the very next
// customer catches us breaking.
'delivery' => $capacity->deliveryFor((int) ($plan['disk_gb'] ?? 0)),
// Console-edited, from the family the plan belongs to — a
// family that has none yet (created but not written up) gets
// an empty string rather than a missing array key, and the

View File

@ -0,0 +1,112 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Services\Provisioning\HostCapacity;
use App\Services\Provisioning\ServerMarket;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Who is waiting for a machine, and what machine would clear them.
*
* A paid order that cannot be placed is parked rather than failed the
* customer has paid, and "no host has room" is a purchasing decision, not a
* fault. This is where that decision is taken: what is queued, what the
* roomiest host still has, what the queue needs, and what such a machine costs
* today.
*
* Nothing here buys anything. Ordering a server is a contract; a bug in a
* capacity calculation must not be able to sign one.
*/
#[Layout('layouts.admin')]
class Capacity extends Component
{
/**
* Look again now.
*
* The runner retries every couple of minutes on its own. This is for the
* operator who has just finished onboarding a host and does not want to
* wait for the tick to see the queue drain.
*/
public function recheck(): void
{
$this->authorize('hosts.manage');
// Through the same query that FINDS them, so "look again" can never
// mean a different set of runs than the table above it is showing.
app(HostCapacity::class)->parkedRuns()->update(['next_attempt_at' => now()]);
$this->dispatch('notify', message: __('capacity.rechecked'));
}
/**
* Decide which parked order goes on which host.
*
* The queue is read by somebody who knows things the placement rule does
* not that this customer belongs beside that one, that a machine is about
* to be taken out of service, that the big order should have the new box
* and the small ones can share. The rule stays in charge when nobody says
* otherwise; an empty choice hands it back.
*
* Only the preference is written. The reservation still happens in the
* provisioning step, under the placement lock, and still checks the host
* has room a console that created the instance itself would be a second
* placement path with no lock and its own arithmetic.
*/
public function pin(int $runId, string $hostId): void
{
$this->authorize('hosts.manage');
// Only a run that is actually parked. Writing a host preference onto a
// run that has moved on would sit in its context for ever and take
// effect on a re-run months later, on a host chosen for a queue that no
// longer exists.
$run = app(HostCapacity::class)->parkedRuns()->whereKey($runId)->first();
if ($run === null) {
return;
}
if ($hostId === '') {
$run->forgetContext('preferred_host_id');
$this->dispatch('notify', message: __('capacity.pin_cleared'));
return;
}
$host = Host::query()->where('status', 'active')->find((int) $hostId);
if ($host === null) {
return;
}
$run->mergeContext(['preferred_host_id' => $host->id]);
// Taken up now rather than at the next tick: an operator who has just
// said where it goes is watching for it to go there.
$run->forceFill(['next_attempt_at' => now()])->save();
$this->dispatch('notify', message: __('capacity.pinned', ['host' => $host->name]));
}
public function render()
{
$this->authorize('hosts.manage');
$capacity = app(HostCapacity::class);
$demand = $capacity->queueDemand();
return view('livewire.admin.capacity', [
'parked' => $capacity->parked(),
'demand' => $demand,
'unplaceable' => $capacity->unplaceablePlans(),
'hosts' => Host::query()->where('status', 'active')->orderBy('name')->get(),
// Only when something is actually waiting. A shopping list on an
// idle estate is an invitation to spend money for no reason.
'offers' => $demand['count'] > 0
? app(ServerMarket::class)->offers($demand['largest'])
: [],
]);
}
}

View File

@ -81,6 +81,7 @@ class Overview extends Component
'newInstances' => $this->newInstancesPerMonth($locale),
'hostLoad' => $this->hostLoad(),
'runs' => $this->openRuns(),
'delivery' => $this->delivery(),
'notices' => $notices = $this->notices(),
'noticeCount' => count($notices),
]);
@ -214,6 +215,45 @@ class Overview extends Component
->all();
}
/**
* What each package can be promised today, and who is waiting.
*
* The same question the price sheet and the customer's own overview ask,
* answered from the same place an operator must be able to see on the
* front page what a visitor is being told, without opening the shop in
* another tab. A package with no host big enough is not unsellable: the
* order is taken, parked, and rolled out once a machine arrives, which is
* exactly what "bereitgestellt in 2-3 Werktagen" means.
*
* @return array{plans: array<int, array{name: string, needs: int, state: string}>, queued: int}
*/
private function delivery(): array
{
$capacity = app(HostCapacity::class);
try {
$sellable = app(\App\Services\Billing\PlanCatalogue::class)->sellable();
} catch (\Throwable) {
// A catalogue that cannot be read has its own alarm. It does not
// get to take the console's front page down with it.
$sellable = [];
}
$plans = [];
foreach ($sellable as $key => $plan) {
$needs = (int) ($plan['disk_gb'] ?? 0);
$plans[] = [
'name' => (string) ($plan['name'] ?? $key),
'needs' => $needs,
'state' => $capacity->deliveryFor($needs),
];
}
return ['plans' => $plans, 'queued' => $capacity->queueDemand()['count']];
}
/**
* Provisioning runs that have not finished.
*

View File

@ -6,6 +6,7 @@ use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Customer;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Services\Provisioning\HostCapacity;
use Livewire\Component;
/**
@ -58,6 +59,12 @@ class CustomerProvisioning extends Component
'active' => $show,
'polling' => $polling,
'failed' => $run?->status === ProvisioningRun::STATUS_FAILED,
// Waiting for a MACHINE, not for a step to finish. A paid order
// that no host has room for is parked rather than failed — the
// customer has paid, and "we have to buy a server first" is a
// purchasing decision, not a fault of theirs. Recognised by where
// the run is standing, the same way the console's queue finds it.
'parked' => $run !== null && app(HostCapacity::class)->isParked($run),
'steps' => $show ? $this->buildRunSteps($run) : [],
]);
}

View File

@ -208,4 +208,10 @@ class Host extends Model implements ProvisioningSubject
->get()
->first(fn (self $host) => $host->availableGb() >= $quotaGb);
}
/** Could this particular host take an instance of this size right now? */
public function canTake(int $quotaGb): bool
{
return $this->status === 'active' && $this->availableGb() >= $quotaGb;
}
}

View File

@ -4,6 +4,7 @@ namespace App\Provisioning\Steps\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Services\Provisioning\HostCapacity;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
@ -13,6 +14,12 @@ 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';
@ -46,10 +53,24 @@ class ReserveResources extends CustomerStep
// 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) {
$host = Host::placeableIn($order->datacenter, (int) $plan['disk_gb']);
$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;
}
@ -71,15 +92,40 @@ class ReserveResources extends CustomerStep
// 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(\App\Services\Provisioning\HostCapacity::class)
->largestPlaceableGb($order->datacenter);
$largest = app(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.');
// 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);
@ -101,6 +147,32 @@ class ReserveResources extends CustomerStep
}
}
/**
* 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([

View File

@ -3,6 +3,11 @@
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;
@ -92,4 +97,143 @@ class HostCapacity
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,
];
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Services\Provisioning;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Throwable;
/**
* What a machine costs today, from the provider's own live list.
*
* So the question "what would clearing this queue cost me" is answered on the
* page that asks it, rather than in another tab against a list that has to be
* read and filtered by hand every time.
*
* Read-only and cached. This orders nothing: buying a server is a contract, and
* a bug in a capacity calculation must not be able to sign one.
*/
class ServerMarket
{
/** Hetzner's published live feed for the server auction. */
private const FEED = 'https://www.hetzner.com/_resources/app/data/app/live_data_sb_EUR.json';
/**
* Cached for a quarter of an hour.
*
* The list changes as machines sell, but not by the second, and an operator
* refreshing a page must not put a request on somebody else's server every
* time.
*/
private const TTL_MINUTES = 15;
/**
* Offers that could actually take the work, cheapest first.
*
* @param int $needsGb the largest single package that has to fit
* @return array<int, array{cpu: string, ram: int, disks: string, flash_gb: int, ecc: bool, datacenter: string, price: float}>
*/
public function offers(int $needsGb, int $limit = 6): array
{
$usable = (int) ceil($needsGb * 1.25); // RAID 1 headroom plus the host's own footprint
return collect($this->feed())
->map(function (array $s) {
$disks = $s['serverDiskData'] ?? [];
$flash = array_sum($disks['nvme'] ?? []) + array_sum($disks['sata'] ?? []);
return [
'cpu' => (string) ($s['cpu'] ?? ''),
'ram' => (int) ($s['ram_size'] ?? 0),
// The feed sends this as a LIST of disk descriptions
// ("2x SSD M.2 NVMe 512 GB"), not a string. Casting an
// array to string is a warning and the word "Array".
'disks' => trim(implode(', ', array_map(
fn ($d) => is_scalar($d) ? (string) $d : '',
(array) ($s['hdd_hr'] ?? [])
))),
// Half, because the pair is a mirror. A machine's usable
// space is not the sum of its disks on a platform that
// sells "your data survives a disk dying".
'flash_gb' => (int) floor($flash / 2),
'ecc' => (bool) ($s['is_ecc'] ?? false),
'datacenter' => (string) ($s['datacenter'] ?? ''),
'price' => (float) ($s['price'] ?? 0),
];
})
// All-flash only, and ECC only. Both are product decisions, not
// preferences: one VM per customer means every database lives on
// the same disks, and ECC is part of what the backups promise.
->filter(fn (array $s) => $s['flash_gb'] >= $usable && $s['ecc'] && $s['ram'] >= 32)
->sortBy('price')
->take($limit)
->values()
->all();
}
/** @return array<int, array<string, mixed>> */
private function feed(): array
{
return Cache::remember('server-market', now()->addMinutes(self::TTL_MINUTES), function () {
try {
$response = Http::timeout(8)->get(self::FEED);
return $response->successful() ? ($response->json('server') ?? []) : [];
} catch (Throwable) {
// An outside list being unreachable is not a reason for the
// capacity page to fail. It shows the queue either way; the
// prices are the convenience.
return [];
}
});
}
}

View File

@ -53,6 +53,7 @@ final class Navigation
['admin.provisioning', 'activity', 'provisioning', null],
['admin.maintenance', 'alert-triangle', 'maintenance', null],
['admin.incidents', 'bell', 'incidents', null],
['admin.capacity', 'database', 'capacity', 'hosts.manage'],
['admin.vpn', 'shield', 'vpn', null],
['admin.revenue', 'trending-up', 'revenue', null],
// Its own entry, not a section of Settings: what is set here

View File

@ -20,6 +20,7 @@ return [
'provisioning' => 'Provisioning',
'maintenance' => 'Wartungen',
'incidents' => 'Störungen',
'capacity' => 'Kapazität',
'vpn' => 'VPN',
'finance' => 'Finanzen',
'invoices' => 'Rechnungen',
@ -56,6 +57,11 @@ return [
// must be able to honour. Real disk usage is not collected, so it is not
// claimed here.
'host_load_sub' => 'Zugeteilter Speicher gegen Kapazität.',
// What the shop is promising right now, per package. Same source as the
// price sheet and the customer's own overview.
'delivery_title' => 'Lieferfähigkeit',
'delivery_sub' => 'Was ein Kunde beim Kauf zugesagt bekommt — aus der freien Kapazität, nicht aus einem festen Satz.',
'delivery_link' => 'Kapazität',
'host_no_capacity' => 'Kapazität unbekannt',
'no_hosts_yet' => 'Noch keine Hosts angebunden.',
'active_runs' => 'Offene Bereitstellungen',

37
lang/de/capacity.php Normal file
View File

@ -0,0 +1,37 @@
<?php
return [
'title' => 'Kapazität & Warteschlange',
'subtitle' => 'Eine bezahlte Bestellung, für die kein Host Platz hat, wird geparkt statt abgebrochen — der Kunde hat bezahlt, und „kein Platz“ ist eine Einkaufsentscheidung, kein Fehler. Hier steht, wer wartet und welche Maschine die Warteschlange auflöst.',
'queue_title' => 'Wartet auf eine Maschine',
'queue_count' => '{1} :count Bestellung|[2,*] :count Bestellungen',
'queue_empty_badge' => 'Nichts wartet',
'queue_empty' => 'Alle bezahlten Bestellungen sind platziert. Sobald eine nicht mehr passt, steht sie hier — und der Kunde sieht in seinem Bereich eine Lieferzusage statt eines Fehlers.',
'recheck' => 'Jetzt erneut prüfen',
'rechecked' => 'Warteschlange wird sofort erneut geprüft.',
'col_customer' => 'Kunde',
'col_plan' => 'Paket',
'col_needs' => 'Braucht',
'col_datacenter' => 'Rechenzentrum',
'col_waiting' => 'Wartet seit',
'col_target' => 'Ziel-Host',
'target_auto' => 'Automatisch platzieren',
'target_too_small' => 'Dieser Host hat derzeit zu wenig Platz — die Bestellung wartet, bis er ihn hat.',
'pinned' => 'Wird auf :host ausgerollt, sobald dort Platz ist.',
'pin_cleared' => 'Zuweisung aufgehoben — die Bestellung nimmt wieder den ersten passenden Host.',
'needed_title' => 'Was die Warteschlange auflöst',
'needed_body' => 'In der Schlange: :mix. Die größte Einzelbestellung braucht :largest GB — kleiner darf die Maschine nicht sein, denn eine Instanz liegt auf genau einem Host. Alle zusammen brauchen :total GB; so viel räumt die Schlange mit einem Kauf.',
'hosts_title' => 'Was die Hosts noch aufnehmen',
'hosts_empty' => 'Noch kein Host aufgenommen.',
'free' => 'frei',
'unsellable' => 'Für diese Pakete hat derzeit kein Host Platz: :plans. Bestellungen dafür werden geparkt, nicht abgelehnt.',
'market_title' => 'Passende Maschinen, aktueller Preis',
'market_body' => 'Aus der Serverbörse, gefiltert auf reines Flash mit ECC und mindestens :gb GB nutzbar nach Spiegelung. Preise netto pro Monat.',
'market_none' => 'Derzeit ist kein passendes Angebot gelistet, oder die Liste ist nicht erreichbar. Der Bestand wechselt ständig.',
'market_note' => 'Nur zur Ansicht — bestellt wird hier nichts. Nutzbar ist die Hälfte der Rohkapazität, weil gespiegelt wird.',
];

17
lang/de/delivery.php Normal file
View File

@ -0,0 +1,17 @@
<?php
// How soon a package can be handed over, in the words a customer reads.
//
// Deliberately without a duration on the immediate branch: it is ready when it
// is ready, and the mail says so. The two to three working days on the other
// branch is a promise about buying and preparing a machine — that one is ours
// to keep.
return [
'immediate' => 'Wird sofort ausgeliefert',
'scheduled' => 'Bereitstellung in 23 Werktagen',
// The longer form, where there is room for a sentence: the customer's own
// overview while their instance is waiting for a machine.
'parked_title' => 'Ihre Cloud wird vorbereitet',
'parked_body' => 'Ihre Bestellung ist bei uns eingegangen und bezahlt. Wir richten dafür gerade eine Maschine ein — das dauert in der Regel zwei bis drei Werktage. Sobald sie bereitsteht, wird Ihre Cloud automatisch aufgesetzt und Sie bekommen Ihre Zugangsdaten per E-Mail. Sie müssen nichts weiter tun.',
];

View File

@ -20,6 +20,7 @@ return [
'provisioning' => 'Provisioning',
'maintenance' => 'Maintenance',
'incidents' => 'Incidents',
'capacity' => 'Capacity',
'vpn' => 'VPN',
'finance' => 'Finance',
'invoices' => 'Invoices',
@ -56,6 +57,11 @@ return [
// must be able to honour. Real disk usage is not collected, so it is not
// claimed here.
'host_load_sub' => 'Committed storage against capacity.',
// What the shop is promising right now, per package. Same source as the
// price sheet and the customer's own overview.
'delivery_title' => 'Delivery',
'delivery_sub' => 'What a customer is promised at checkout — from free capacity, not from a fixed sentence.',
'delivery_link' => 'Capacity',
'host_no_capacity' => 'Capacity unknown',
'no_hosts_yet' => 'No hosts onboarded yet.',
'active_runs' => 'Open provisioning',

37
lang/en/capacity.php Normal file
View File

@ -0,0 +1,37 @@
<?php
return [
'title' => 'Capacity & queue',
'subtitle' => 'A paid order no host has room for is parked rather than failed — the customer has paid, and "no room" is a purchasing decision, not a fault. This is who is waiting and what machine would clear them.',
'queue_title' => 'Waiting for a machine',
'queue_count' => '{1} :count order|[2,*] :count orders',
'queue_empty_badge' => 'Nothing waiting',
'queue_empty' => 'Every paid order is placed. The moment one no longer fits it appears here — and the customer sees a delivery date in their area rather than an error.',
'recheck' => 'Check again now',
'rechecked' => 'The queue is being checked again immediately.',
'col_customer' => 'Customer',
'col_plan' => 'Package',
'col_needs' => 'Needs',
'col_datacenter' => 'Datacentre',
'col_waiting' => 'Waiting since',
'col_target' => 'Target host',
'target_auto' => 'Place automatically',
'target_too_small' => 'This host has too little room right now — the order waits until it has.',
'pinned' => 'Will roll out on :host as soon as it has room.',
'pin_cleared' => 'Assignment cleared — the order takes the first host that fits again.',
'needed_title' => 'What clears the queue',
'needed_body' => 'In the queue: :mix. The largest single order needs :largest GB — the machine cannot be smaller, because an instance lives on exactly one host. All of them together need :total GB, which is what clears the queue in one purchase.',
'hosts_title' => 'What the hosts can still take',
'hosts_empty' => 'No host onboarded yet.',
'free' => 'free',
'unsellable' => 'No host currently has room for these packages: :plans. Orders for them are parked, not refused.',
'market_title' => 'Matching machines, current price',
'market_body' => 'From the server auction, filtered to all-flash with ECC and at least :gb GB usable after mirroring. Prices net per month.',
'market_none' => 'No matching offer is listed right now, or the list is unreachable. Stock rotates constantly.',
'market_note' => 'For information only — nothing is ordered here. Usable is half the raw capacity, because it is mirrored.',
];

17
lang/en/delivery.php Normal file
View File

@ -0,0 +1,17 @@
<?php
// How soon a package can be handed over, in the words a customer reads.
//
// Deliberately without a duration on the immediate branch: it is ready when it
// is ready, and the mail says so. The two to three working days on the other
// branch is a promise about buying and preparing a machine — that one is ours
// to keep.
return [
'immediate' => 'Delivered right away',
'scheduled' => 'Ready within 23 working days',
// The longer form, where there is room for a sentence: the customer's own
// overview while their instance is waiting for a machine.
'parked_title' => 'Your cloud is being prepared',
'parked_body' => 'Your order has arrived and is paid for. We are setting up a machine for it — that usually takes two to three working days. As soon as it is ready your cloud is built automatically and your credentials arrive by mail. There is nothing else for you to do.',
];

View File

@ -0,0 +1,26 @@
@props(['state' => 'scheduled'])
{{-- How soon this package can be handed over.
One component for both readers the public price sheet and the customer's
own overview because the promise is the same promise, and a visitor who
reads "wird sofort ausgeliefert" before paying must not find a different
sentence on the page they land on afterwards. The state is derived from the
estate (HostCapacity::deliveryFor); nothing here decides anything.
No duration on the immediate branch on purpose: "in 20 Minuten" is a
promise about a machine we do not control. It is ready when it is ready,
and the customer is told by mail. The two to three working days on the
other branch is a promise about US the time it takes to buy, rack and
onboard a server and that one we can keep. --}}
@php
$immediate = $state === 'immediate';
@endphp
<p {{ $attributes->class(['flex items-center gap-2 text-xs font-medium']) }}>
<x-ui.icon :name="$immediate ? 'check' : 'calendar'"
@class(['size-4 shrink-0', 'text-success' => $immediate, 'text-muted' => ! $immediate]) />
<span @class(['text-success' => $immediate, 'text-muted' => ! $immediate])>
{{ __('delivery.'.($immediate ? 'immediate' : 'scheduled')) }}
</span>
</p>

View File

@ -514,6 +514,13 @@
</p>
<p class="mt-1.5 text-xs text-muted">zzgl. einmaliger Einrichtung</p>
{{-- Read from the estate, not written down: where a host has
room this rolls out on its own, and where none has, a
machine has to be bought first. Saying "sofort" in that
second case is a promise the buyer catches us breaking
on day one. --}}
<x-ui.delivery :state="$plan['delivery']" class="mt-4" />
<dl class="mt-6 space-y-2 border-y border-line py-5 text-sm">
@foreach ([
'Speicher' => $plan['storage'],

View File

@ -0,0 +1,166 @@
<div class="space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('capacity.title') }}</h1>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('capacity.subtitle') }}</p>
</div>
{{-- ── The queue ────────────────────────────────────────────────────── --}}
<x-ui.card class="animate-rise [animation-delay:60ms]">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<h2 class="font-semibold text-ink">{{ __('capacity.queue_title') }}</h2>
@if ($demand['count'] > 0)
<x-ui.badge status="warning">{{ trans_choice('capacity.queue_count', $demand['count'], ['count' => $demand['count']]) }}</x-ui.badge>
@else
<x-ui.badge status="active">{{ __('capacity.queue_empty_badge') }}</x-ui.badge>
@endif
<x-ui.button wire:click="recheck" variant="secondary" size="sm" class="ml-auto"
wire:loading.attr="disabled" wire:target="recheck">
<x-ui.icon name="refresh" class="size-4" />{{ __('capacity.recheck') }}
</x-ui.button>
</div>
@if ($demand['count'] === 0)
<p class="mt-4 text-sm text-muted">{{ __('capacity.queue_empty') }}</p>
@else
<table class="mt-5 w-full text-left text-sm">
<thead>
<tr class="border-b border-line">
<th class="lbl pb-2 font-normal">{{ __('capacity.col_customer') }}</th>
<th class="lbl pb-2 font-normal">{{ __('capacity.col_plan') }}</th>
<th class="lbl pb-2 text-right font-normal">{{ __('capacity.col_needs') }}</th>
<th class="lbl pb-2 font-normal">{{ __('capacity.col_datacenter') }}</th>
<th class="lbl pb-2 text-right font-normal">{{ __('capacity.col_waiting') }}</th>
<th class="lbl pb-2 font-normal">{{ __('capacity.col_target') }}</th>
</tr>
</thead>
<tbody>
@foreach ($parked as $entry)
@php
// Only hosts in the order's own datacenter: placement
// is a per-datacenter question, and the step refuses a
// pin from anywhere else. Offering one here would be
// offering a choice that silently does nothing.
$choices = $hosts->where('datacenter', $entry['datacenter']);
$pinnedHost = $entry['pinned_host_id'] === null
? null
: $choices->firstWhere('id', $entry['pinned_host_id']);
@endphp
<tr wire:key="parked-{{ $entry['run']->id }}" class="border-b border-line last:border-0">
<td class="py-3 font-medium text-ink">{{ $entry['order']->customer?->name ?? '—' }}</td>
<td class="py-3 text-body">{{ $entry['name'] }}</td>
<td class="py-3 text-right font-mono tabular-nums text-ink">{{ $entry['needs'] }} GB</td>
<td class="py-3 font-mono text-xs text-muted">{{ $entry['datacenter'] }}</td>
<td class="py-3 text-right text-xs text-muted">{{ $entry['waiting_since']->local()->diffForHumans() }}</td>
{{-- One select, one value, no height change: the row
stays a row (R20's exception), and the decision
is the operator's the placement rule only
takes over again when nothing is chosen. --}}
<td class="py-3">
<select wire:change="pin({{ $entry['run']->id }}, $event.target.value)"
class="rounded-md border border-line-strong bg-surface px-2 py-1 text-xs text-body">
<option value="">{{ __('capacity.target_auto') }}</option>
@foreach ($choices as $host)
<option value="{{ $host->id }}" @selected($entry['pinned_host_id'] === $host->id)>
{{ $host->name }} · {{ $host->availableGb() }} GB
</option>
@endforeach
</select>
@if ($pinnedHost !== null && ! $pinnedHost->canTake($entry['needs']))
{{-- A pin is honoured or it waits never
quietly redirected. So say so, or the row
sits there looking like every other one
while it is the choice, not the estate,
that is holding it up. --}}
<p class="mt-1 flex items-center gap-1.5 text-xs text-warning">
<x-ui.icon name="alert-triangle" class="size-3.5 shrink-0" />{{ __('capacity.target_too_small') }}
</p>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
{{-- What to buy, in numbers. The largest single package decides the
MINIMUM machine an instance lives on one host, so a server
that cannot take the biggest one leaves it parked however much
total space it has. --}}
<div class="mt-6 rounded-lg border border-accent-border bg-accent-subtle p-5">
<p class="lbl !text-accent-text">{{ __('capacity.needed_title') }}</p>
<p class="mt-3 text-sm leading-relaxed text-body">
{{ __('capacity.needed_body', [
'mix' => collect($demand['by_plan'])->map(fn ($n, $plan) => $n.'× '.$plan)->join(', '),
'largest' => $demand['largest'],
'total' => $demand['total'],
]) }}
</p>
</div>
@endif
</x-ui.card>
{{-- ── What the estate can still take ───────────────────────────────── --}}
<x-ui.card :title="__('capacity.hosts_title')" class="animate-rise [animation-delay:120ms]">
@if ($hosts->isEmpty())
<p class="text-sm text-muted">{{ __('capacity.hosts_empty') }}</p>
@else
<table class="w-full text-left text-sm">
<tbody>
@foreach ($hosts as $host)
<tr class="border-b border-line last:border-0">
<td class="py-3 font-medium text-ink">{{ $host->name }}</td>
<td class="py-3 font-mono text-xs text-muted">{{ $host->datacenter }}</td>
<td class="py-3 text-right font-mono tabular-nums text-body">
{{ $host->availableGb() }} / {{ $host->freeGb() }} GB {{ __('capacity.free') }}
</td>
</tr>
@endforeach
</tbody>
</table>
@endif
@if ($unplaceable !== [])
<x-ui.alert variant="warning" class="mt-5">
{{ __('capacity.unsellable', [
'plans' => collect($unplaceable)->pluck('name')->join(', '),
]) }}
</x-ui.alert>
@endif
</x-ui.card>
{{-- ── What such a machine costs today ──────────────────────────────── --}}
@if ($demand['count'] > 0)
<x-ui.card :title="__('capacity.market_title')" class="animate-rise [animation-delay:180ms]">
<p class="text-sm leading-relaxed text-muted">{{ __('capacity.market_body', ['gb' => $demand['largest']]) }}</p>
@if ($offers === [])
<p class="mt-4 text-sm text-muted">{{ __('capacity.market_none') }}</p>
@else
<table class="mt-4 w-full text-left text-sm">
<thead>
<tr class="border-b border-line">
<th class="lbl pb-2 font-normal">CPU</th>
<th class="lbl pb-2 text-right font-normal">RAM</th>
<th class="lbl pb-2 font-normal">{{ __('capacity.col_disks') }}</th>
<th class="lbl pb-2 text-right font-normal">{{ __('capacity.col_usable') }}</th>
<th class="lbl pb-2 font-normal">{{ __('capacity.col_location') }}</th>
<th class="lbl pb-2 text-right font-normal">{{ __('capacity.col_price') }}</th>
</tr>
</thead>
<tbody>
@foreach ($offers as $offer)
<tr class="border-b border-line last:border-0">
<td class="py-3 text-body">{{ $offer['cpu'] }}</td>
<td class="py-3 text-right font-mono tabular-nums text-body">{{ $offer['ram'] }} GB</td>
<td class="py-3 font-mono text-xs text-muted">{{ $offer['disks'] }}</td>
<td class="py-3 text-right font-mono tabular-nums text-ink">{{ number_format($offer['flash_gb'], 0, ',', '.') }} GB</td>
<td class="py-3 font-mono text-xs text-muted">{{ $offer['datacenter'] }}</td>
<td class="py-3 text-right font-mono tabular-nums font-semibold text-ink">{{ number_format($offer['price'], 2, ',', '.') }} </td>
</tr>
@endforeach
</tbody>
</table>
<p class="mt-3 text-xs text-muted">{{ __('capacity.market_note') }}</p>
@endif
</x-ui.card>
@endif
</div>

View File

@ -64,6 +64,37 @@
</div>
</div>
{{-- What we can promise per package, and who is waiting for a machine.
The same answer the price sheet gives a visitor, from the same source,
so the operator can see on the front page what is being promised out
there. "Bereitstellung in 23 Werktagen" is not a failure state the
order is taken and parked, and the queue is the shopping list. --}}
@if ($delivery['plans'] !== [])
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<h2 class="font-semibold text-ink">{{ __('admin.delivery_title') }}</h2>
@if ($delivery['queued'] > 0)
<x-ui.badge status="warning">{{ trans_choice('capacity.queue_count', $delivery['queued'], ['count' => $delivery['queued']]) }}</x-ui.badge>
@endif
<a href="{{ route('admin.capacity') }}" class="ml-auto text-xs font-semibold text-accent-text hover:underline">{{ __('admin.delivery_link') }} </a>
</div>
<p class="text-sm text-muted">{{ __('admin.delivery_sub') }}</p>
<div class="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@foreach ($delivery['plans'] as $plan)
<div class="rounded-md border border-line bg-surface-2 px-4 py-3">
<div class="flex items-baseline justify-between gap-3">
<span class="text-sm font-medium text-ink">{{ $plan['name'] }}</span>
<span class="font-mono text-xs tabular-nums text-muted">{{ $plan['needs'] }} GB</span>
</div>
<x-ui.delivery :state="$plan['state']" class="mt-2" />
</div>
@endforeach
</div>
</div>
@endif
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
{{-- Open provisioning runs --}}
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">

View File

@ -3,10 +3,22 @@
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise">
<div class="flex items-center gap-2">
<span class="size-2 rounded-pill {{ $failed ? 'bg-danger' : 'bg-info animate-pulse' }}" aria-hidden="true"></span>
<h2 class="font-semibold text-ink">{{ __('dashboard.provisioning_title') }}</h2>
<h2 class="font-semibold text-ink">
{{ $parked ? __('delivery.parked_title') : __('dashboard.provisioning_title') }}
</h2>
</div>
<p class="mb-4 mt-1 text-sm text-muted">
{{ $failed ? __('dashboard.provisioning_failed') : __('dashboard.provisioning_sub') }}
{{-- Parked is not failed and not slow: the order is paid and a
machine is being bought for it. Left under the generic "wird
eingerichtet" wording, a customer watching a stepper stand
still for two days has every reason to think something broke. --}}
<p class="mb-4 mt-1 max-w-[70ch] text-sm text-muted">
@if ($failed)
{{ __('dashboard.provisioning_failed') }}
@elseif ($parked)
{{ __('delivery.parked_body') }}
@else
{{ __('dashboard.provisioning_sub') }}
@endif
</p>
<x-ui.progress-stepper :steps="$steps" />
</div>

View File

@ -37,6 +37,7 @@ Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start']
Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning');
Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance');
Route::get('/incidents', Admin\Incidents::class)->name('incidents');
Route::get('/capacity', Admin\Capacity::class)->name('capacity');
Route::get('/vpn', Admin\Vpn::class)->name('vpn');
Route::get('/revenue', Admin\Revenue::class)->name('revenue');
Route::get('/finance', Admin\Finance::class)->name('finance');

View File

@ -0,0 +1,247 @@
<?php
use App\Livewire\Admin\Capacity;
use App\Livewire\Admin\Overview;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Steps\Customer\ReserveResources;
use App\Services\Provisioning\HostCapacity;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
/**
* A paid order waits for a machine; it does not fail for want of one.
*
* The estate is finite and booked thick, so "no host has room right now" is an
* ordinary Tuesday: somebody buys a server, which takes days, not seconds.
* Failing the run in the meantime turns a sale into an incident and leaves an
* operator holding money against nothing.
*/
beforeEach(function () {
// hosts.datacenter is a foreign key onto datacenters.code.
Datacenter::factory()->create(['code' => 'fsn1']);
// The capacity page quotes live server prices. A test suite that reaches
// the internet is a test suite that fails when somebody else's site is
// down, and it puts requests on that site on every run.
Http::fake([
'*' => Http::response(['server' => []]),
]);
});
/** A paid order with its contract frozen, standing at the reservation step. */
function parkedOrder(string $plan = 'start', string $datacenter = 'fsn1'): ProvisioningRun
{
$order = Order::factory()->withSubscription()->create([
'plan' => $plan,
'datacenter' => $datacenter,
'status' => 'paid',
]);
$pipeline = (array) config('provisioning.pipelines.customer');
return ProvisioningRun::factory()->create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'customer',
'current_step' => array_search(ReserveResources::class, $pipeline, true),
'status' => ProvisioningRun::STATUS_WAITING,
]);
}
it('parks a paid order instead of failing it when nothing has room', function () {
// No host at all: the customer has paid and there is nowhere to put them.
$run = parkedOrder();
$run->update(['status' => ProvisioningRun::STATUS_RUNNING]);
$result = app(ReserveResources::class)->execute($run->fresh());
expect($result->type)->toBe('poll')
->and($result->reason)->toBe('awaiting_capacity')
// poll(), not retry(): the wait is measured in days and must not eat
// the run's attempt budget.
->and($result->afterSeconds)->toBe(ReserveResources::PARK_POLL_SECONDS);
});
it('fails only once the order has waited longer than a machine can take', function () {
// Long enough to buy, rack and onboard a server over a weekend; short
// enough that a forgotten order surfaces instead of waiting for ever.
$run = parkedOrder();
// forceFill, because created_at is not fillable — update() drops it
// silently and the run stays a second old, which is a green test for a
// deadline that never fires.
$run->forceFill([
'status' => ProvisioningRun::STATUS_RUNNING,
'created_at' => now()->subDays(ReserveResources::PARK_DAYS + 1),
])->save();
expect(app(ReserveResources::class)->execute($run->fresh())->type)->toBe('fail');
});
it('finds parked orders by where they stand, not by a string that casts to zero', function () {
// current_step is an INDEX. Comparing it to 'reserve_resources' reads as
// correct and matches step 0 of every pipeline instead — including a host
// onboarding that is merely waiting on SSH.
$parked = parkedOrder();
ProvisioningRun::factory()->create([
'pipeline' => 'host',
'current_step' => 0,
'status' => ProvisioningRun::STATUS_WAITING,
]);
$found = app(HostCapacity::class)->parked();
expect($found)->toHaveCount(1)
->and($found[0]['run']->id)->toBe($parked->id);
});
it('recommends what to buy from the mix that is actually waiting', function () {
// 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.
parkedOrder('start');
parkedOrder('start');
parkedOrder('business');
$demand = app(HostCapacity::class)->queueDemand();
expect($demand['count'])->toBe(3)
->and($demand['largest'])->toBe(1050) // business
->and($demand['total'])->toBe(1290) // 2 × 120 + 1050
->and($demand['by_plan'])->toHaveCount(2);
});
it('sizes the queue from the contract the customer signed, not today catalogue', function () {
// A plan edited after the sale must not change the machine we go and buy
// for the customer who bought the old one.
$run = parkedOrder('start');
App\Models\Subscription::query()
->where('order_id', $run->subject_id)
->update(['disk_gb' => 4000]);
expect(app(HostCapacity::class)->queueDemand()['largest'])->toBe(4000);
});
it('promises immediate delivery only where a host could actually take it', function () {
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 500, 'reserve_pct' => 0]);
$capacity = app(HostCapacity::class);
expect($capacity->deliveryFor(120))->toBe('immediate') // start fits
->and($capacity->deliveryFor(1050))->toBe('scheduled') // business does not
// Nothing sized is nothing promised.
->and($capacity->deliveryFor(0))->toBe('scheduled');
});
it('stops promising immediate delivery once the room is committed', function () {
// Thick booking: the instance holds its whole disk whether the customer has
// put a byte in it or not, and the promise has to follow the booking.
$host = Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 500, 'reserve_pct' => 0]);
expect(app(HostCapacity::class)->deliveryFor(120))->toBe('immediate');
Instance::factory()->create(['host_id' => $host->id, 'status' => 'active', 'disk_gb' => 450]);
expect(app(HostCapacity::class)->deliveryFor(120))->toBe('scheduled');
});
it('shows the delivery promise for every package on the console front page', function () {
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 500, 'reserve_pct' => 0]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(Overview::class)
->assertViewHas('delivery', fn (array $d) => count($d['plans']) > 0
&& collect($d['plans'])->contains(fn (array $p) => $p['state'] === 'immediate')
&& collect($d['plans'])->contains(fn (array $p) => $p['state'] === 'scheduled'));
});
it('lets the operator decide which parked order goes on which host', function () {
$run = parkedOrder('start');
$small = Host::factory()->active()->create(['datacenter' => 'fsn1', 'name' => 'pve-a', 'total_gb' => 1000, 'reserve_pct' => 0]);
$chosen = Host::factory()->active()->create(['datacenter' => 'fsn1', 'name' => 'pve-b', 'total_gb' => 1000, 'reserve_pct' => 0]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(Capacity::class)
->call('pin', $run->id, (string) $chosen->id);
expect($run->fresh()->context('preferred_host_id'))->toBe($chosen->id);
// And the step honours it — placeableIn() would have taken pve-a, which
// sorts first by name and has just as much room.
$run->update(['status' => ProvisioningRun::STATUS_RUNNING]);
app(ReserveResources::class)->execute($run->fresh());
expect(Instance::query()->where('order_id', $run->subject_id)->first()?->host_id)->toBe($chosen->id)
->and($small->instances()->count())->toBe(0);
});
it('waits rather than quietly placing a pinned order somewhere else', function () {
// A pin is honoured or it waits. Placing it elsewhere because the chosen
// host happened to be full answers a different question than the one the
// operator answered — and they would find out from the finished instance.
$run = parkedOrder('start');
$full = Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 100, 'reserve_pct' => 0]);
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 0]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(Capacity::class)
->call('pin', $run->id, (string) $full->id);
$run->update(['status' => ProvisioningRun::STATUS_RUNNING]);
$result = app(ReserveResources::class)->execute($run->fresh());
expect($result->type)->toBe('poll')
// Named apart from 'awaiting_capacity': this one is a decision to
// revisit, not a server to buy.
->and($result->reason)->toBe('awaiting_pinned_host')
->and(Instance::query()->where('order_id', $run->subject_id)->exists())->toBeFalse();
});
it('hands placement back when the assignment is cleared', function () {
$run = parkedOrder('start');
$host = Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 0]);
$page = Livewire::actingAs(operator('Owner'), 'operator')->test(Capacity::class);
$page->call('pin', $run->id, (string) $host->id);
$page->call('pin', $run->id, '');
expect($run->fresh()->context('preferred_host_id'))->toBeNull();
});
it('refuses to pin a run that is no longer waiting for a machine', function () {
// A preference written onto a run that has moved on would sit in its
// context for ever and take effect on a re-run months later.
$run = parkedOrder('start');
$run->update(['status' => ProvisioningRun::STATUS_RUNNING]);
$host = Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 0]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(Capacity::class)
->call('pin', $run->id, (string) $host->id);
expect($run->fresh()->context('preferred_host_id'))->toBeNull();
});
it('rechecks exactly the runs the queue is showing', function () {
$parked = parkedOrder();
$parked->update(['next_attempt_at' => now()->addHour()]);
$other = ProvisioningRun::factory()->create([
'pipeline' => 'host',
'current_step' => 0,
'status' => ProvisioningRun::STATUS_WAITING,
'next_attempt_at' => now()->addHour(),
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(Capacity::class)
->call('recheck');
expect($parked->fresh()->next_attempt_at->isBefore(now()->addMinute()))->toBeTrue()
->and($other->fresh()->next_attempt_at->isAfter(now()->addMinutes(30)))->toBeTrue();
});

View File

@ -0,0 +1,101 @@
<?php
use App\Livewire\CustomerProvisioning;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\User;
use App\Provisioning\Steps\Customer\ReserveResources;
use Livewire\Livewire;
/**
* The same promise, before the money and after it.
*
* A visitor reading "wird sofort ausgeliefert" on the price sheet must not find
* a different sentence on the page they land on once they have paid and where
* a machine has to be bought first, both pages have to say so rather than one
* of them promising and the other apologising.
*/
beforeEach(function () {
Datacenter::factory()->create(['code' => 'fsn1']);
});
it('promises immediate delivery on the price sheet only where a host has room', function () {
// Room for the small package, nowhere near enough for the big one.
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 500, 'reserve_pct' => 0]);
$response = $this->get('/');
$response->assertOk()
->assertSee(__('delivery.immediate'))
->assertSee(__('delivery.scheduled'));
});
it('promises nothing immediate before a single machine has been onboarded', function () {
// A fresh installation sells nothing on the spot. Saying otherwise is a
// promise the very first customer catches us breaking.
Host::query()->delete();
$this->get('/')
->assertOk()
->assertSee(__('delivery.scheduled'))
->assertDontSee(__('delivery.immediate'));
});
it('tells a waiting customer their cloud is being prepared, not that it broke', function () {
// Parked is not failed and not slow. Left under the generic "wird
// eingerichtet" wording, a customer watching a stepper stand still for two
// days has every reason to think something went wrong.
$user = User::factory()->create();
$customer = Customer::factory()->create(['email' => $user->email]);
$order = Order::factory()->withSubscription()->create([
'customer_id' => $customer->id,
'plan' => 'start',
'datacenter' => 'fsn1',
'status' => 'paid',
]);
$pipeline = (array) config('provisioning.pipelines.customer');
ProvisioningRun::factory()->create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'customer',
'current_step' => array_search(ReserveResources::class, $pipeline, true),
'status' => ProvisioningRun::STATUS_WAITING,
]);
Livewire::actingAs($user)
->test(CustomerProvisioning::class)
->assertViewHas('parked', true)
->assertSee(__('delivery.parked_title'))
->assertDontSee(__('dashboard.provisioning_failed'));
});
it('does not call an ordinary step in flight a delivery delay', function () {
// Only the reservation step means "waiting for a machine". Every other
// wait is provisioning doing its job.
$user = User::factory()->create();
$customer = Customer::factory()->create(['email' => $user->email]);
$order = Order::factory()->withSubscription()->create([
'customer_id' => $customer->id,
'plan' => 'start',
'datacenter' => 'fsn1',
'status' => 'paid',
]);
ProvisioningRun::factory()->create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'customer',
'current_step' => 5,
'status' => ProvisioningRun::STATUS_WAITING,
]);
Livewire::actingAs($user)
->test(CustomerProvisioning::class)
->assertViewHas('parked', false)
->assertDontSee(__('delivery.parked_title'));
});

View File

@ -88,9 +88,18 @@ it('reserves an instance on a datacenter host and records it once', function ()
->and(RunResource::where('run_id', $run->id)->where('kind', 'instance_id')->count())->toBe(1);
});
it('fails reservation when no host has capacity', function () {
it('parks reservation when no host has capacity, rather than failing a paid order', function () {
// This used to fail the run. The customer has paid by the time it runs, and
// "no host has room" is a purchasing decision, not a fault of theirs —
// failing it turned a sale into an incident and left somebody holding money
// against nothing. It waits for a machine now and only fails after
// PARK_DAYS. The queue and the deadline are covered in
// tests/Feature/Admin/CapacityQueueTest.php.
$run = orderRun(['status' => 'provisioning', 'plan' => 'start', 'datacenter' => 'nowhere']);
expect(app(ReserveResources::class)->execute($run)->type)->toBe('fail');
$result = app(ReserveResources::class)->execute($run);
expect($result->type)->toBe('poll')
->and($result->reason)->toBe('awaiting_capacity');
});
// 3. CloneVirtualMachine

View File

@ -0,0 +1,94 @@
<?php
use App\Services\Provisioning\ServerMarket;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* What a machine costs today, answered on the page that asks the question.
*
* Read-only by construction. This orders nothing: buying a server is a
* contract, and a bug in a capacity calculation must not be able to sign one.
*/
beforeEach(fn () => Cache::forget('server-market'));
/** One offer in the feed's own shape. */
function offer(array $overrides = []): array
{
return array_merge([
'cpu' => 'Ryzen 5 3600',
'ram_size' => 64,
'is_ecc' => true,
'hdd_hr' => ['2x SSD M.2 NVMe 1 TB'],
'serverDiskData' => ['nvme' => [1024, 1024], 'sata' => [], 'hdd' => []],
'datacenter' => 'FSN1-DC1',
'price' => 42.0,
], $overrides);
}
it('counts a mirrored pair once, not twice', function () {
// A machine's usable space is not the sum of its disks on a platform whose
// whole promise is "your data survives a disk dying".
Http::fake(['*' => Http::response(['server' => [offer()]])]);
expect(app(ServerMarket::class)->offers(500)[0]['flash_gb'])->toBe(1024);
});
it('leaves out machines that could not take the largest parked order', function () {
// 1.25× the largest single package: the mirror plus the host's own
// footprint. An offer that only just fits is not an offer.
Http::fake(['*' => Http::response(['server' => [offer()]])]);
expect(app(ServerMarket::class)->offers(900))->toBe([]);
});
it('leaves out spinning disks and non-ECC memory', function () {
// Both are product decisions, not preferences: one VM per customer puts
// every database on the same disks, and ECC is part of what the nightly
// backup promise rests on.
Http::fake(['*' => Http::response(['server' => [
offer(['is_ecc' => false]),
offer(['serverDiskData' => ['nvme' => [], 'sata' => [], 'hdd' => [4096, 4096]]]),
offer(['ram_size' => 16]),
]])]);
expect(app(ServerMarket::class)->offers(100))->toBe([]);
});
it('lists the cheapest first', function () {
Http::fake(['*' => Http::response(['server' => [
offer(['price' => 80.0]),
offer(['price' => 30.0]),
offer(['price' => 55.0]),
]])]);
expect(collect(app(ServerMarket::class)->offers(100))->pluck('price')->all())
->toBe([30.0, 55.0, 80.0]);
});
it('reads the disk description the feed actually sends', function () {
// The feed sends a LIST of disk descriptions, not a string. Casting that to
// string printed the word "Array" into the console and raised a warning
// that took the whole page down with it.
Http::fake(['*' => Http::response(['server' => [offer(['hdd_hr' => ['2x SSD M.2 NVMe 1 TB']])]])]);
expect(app(ServerMarket::class)->offers(100)[0]['disks'])->toBe('2x SSD M.2 NVMe 1 TB');
});
it('says nothing rather than failing when the list cannot be reached', function () {
// An outside list being down is not a reason for the capacity page to fail.
// It shows the queue either way; the prices are the convenience.
Http::fake(['*' => fn () => throw new RuntimeException('connection refused')]);
expect(app(ServerMarket::class)->offers(100))->toBe([]);
});
it('asks the provider once per quarter hour, not once per page view', function () {
Http::fake(['*' => Http::response(['server' => [offer()]])]);
app(ServerMarket::class)->offers(100);
app(ServerMarket::class)->offers(100);
app(ServerMarket::class)->offers(100);
Http::assertSentCount(1);
});