287 lines
12 KiB
PHP
287 lines
12 KiB
PHP
<?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\RunRunner;
|
||
use App\Provisioning\Steps\Customer\ReserveResources;
|
||
use App\Services\Provisioning\HostCapacity;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Queue;
|
||
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('keeps a park alive through the runner, not merely through the step', function () {
|
||
// Both park tests around this one call execute() directly, and that is exactly
|
||
// why this stayed invisible for the whole life of the feature. The step
|
||
// returned poll(120) correctly; the RUNNER then measured the step's own
|
||
// maxDuration from a started_at it deliberately does not reset on a poll, and
|
||
// maxDuration was 60. So every single re-entry was ruled timed out BEFORE the
|
||
// body ran, a timeout consumes an attempt, and five of them had failRun()
|
||
// marking a paid order and its instance failed and releasing the instance —
|
||
// about six minutes for a wait that promises fourteen days. The console's
|
||
// capacity queue, HostCapacity::parked(), queueDemand() and the whole "go and
|
||
// buy a server" workflow were unreachable code.
|
||
Queue::fake();
|
||
$run = parkedOrder();
|
||
$run->update(['status' => ProvisioningRun::STATUS_RUNNING]);
|
||
|
||
$runner = app(RunRunner::class);
|
||
|
||
// Four full poll intervals: further than the old sixty-second budget, and
|
||
// further than five attempts would ever have carried it.
|
||
for ($i = 0; $i < 4; $i++) {
|
||
$runner->advance($run->fresh());
|
||
|
||
$polled = $run->fresh();
|
||
expect($polled->status)->toBe(ProvisioningRun::STATUS_WAITING)
|
||
->and($polled->attempt)->toBe(0) // a poll costs nothing
|
||
->and($polled->error)->toBeNull();
|
||
|
||
$this->travel(ReserveResources::PARK_POLL_SECONDS + 1)->seconds();
|
||
}
|
||
|
||
// Still parked, still one of the runs the console is asking somebody to buy a
|
||
// machine for, and nothing has been declared failed along the way.
|
||
expect(app(HostCapacity::class)->isParked($run->fresh()))->toBeTrue()
|
||
->and($run->fresh()->events()->where('outcome', 'failed')->exists())->toBeFalse()
|
||
->and($run->fresh()->events()->where('message', 'like', '%timed out%')->exists())->toBeFalse();
|
||
});
|
||
|
||
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();
|
||
});
|