CluPilotCloud/tests/Feature/Admin/CapacityQueueTest.php

248 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?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();
});