Warn that a package cannot be placed, before somebody pays for it
The platform books thick: a package reserves its whole disk_gb — the sold quota plus the machine's own overhead — on ONE host at placement. That is the right choice for a small operator, because nothing can quietly overfill. It has one consequence nobody was being told about: the catalogue goes on offering a package no host has room for, and the first anyone hears of it is a PAID order failing at the reservation step. On the machine this was found on the numbers are stark. 2 x 512 GB NVMe in a mirror is about 400 GB usable; Start reserves 120, Team 540, Business 1050, Enterprise 2100. Three of the four packages on sale cannot be placed at all, and the fourth fits three times. So the console says so now, per package, with the shortfall — "Team needs 540 GB, the roomiest host has 260" — because "buy a server" is not the decision. "Buy a server big enough for what I am selling" is. Two things it deliberately does not do. It does not warn on an installation with no host at all: that is a machine nobody has onboarded yet, it has its own notice, and without the guard a fresh console greeted its operator with one warning per package before they had done anything. And it does not take the overview down when the catalogue cannot be read — a capacity warning does not get to break the page somebody opens when something is already wrong. The largest FREE HOST, never the sum: an instance goes on one machine, so two hosts with 300 GB each cannot take a 540 GB package between them. And when placement does fail, the log now names the shortfall instead of only 'no_capacity'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus v1.3.25
parent
8f2252e81b
commit
98d884727a
|
|
@ -5,6 +5,7 @@ namespace App\Livewire\Admin;
|
|||
use App\Livewire\Concerns\BuildsRunSteps;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Host;
|
||||
use App\Services\Provisioning\HostCapacity;
|
||||
use App\Models\Instance;
|
||||
use App\Models\MonitoringTarget;
|
||||
use App\Models\ProvisioningRun;
|
||||
|
|
@ -288,6 +289,24 @@ class Overview extends Component
|
|||
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.monitoring_down', ['n' => $down])];
|
||||
}
|
||||
|
||||
// Capacity, before an order finds out.
|
||||
//
|
||||
// The platform books thick: a package reserves its whole `disk_gb` on
|
||||
// one host at placement. So the catalogue can go on offering a package
|
||||
// no host has room for, and the first anyone hears of it is a PAID
|
||||
// order failing at the reservation step — after the money.
|
||||
//
|
||||
// Listed per package with the shortfall, because "buy a server" is not
|
||||
// the decision; "buy a server big enough for the package I am selling"
|
||||
// is.
|
||||
foreach (app(HostCapacity::class)->unplaceablePlans() as $plan) {
|
||||
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.no_capacity', [
|
||||
'plan' => $plan['name'],
|
||||
'needs' => $plan['needs'],
|
||||
'largest' => $plan['largest'],
|
||||
])];
|
||||
}
|
||||
|
||||
return $notices;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use App\Models\Order;
|
|||
use App\Models\ProvisioningRun;
|
||||
use App\Provisioning\StepResult;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ReserveResources extends CustomerStep
|
||||
|
|
@ -67,7 +68,15 @@ 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.
|
||||
if ($instance === null) {
|
||||
$largest = app(\App\Services\Provisioning\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.');
|
||||
|
||||
return StepResult::fail('no_capacity');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Provisioning;
|
||||
|
||||
use App\Models\Host;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +79,7 @@ return [
|
|||
],
|
||||
|
||||
'notice' => [
|
||||
'no_capacity' => "Kein Host hat noch Platz für „:plan“ — das Paket braucht :needs GB, der freieste Host hat :largest GB. Eine bezahlte Bestellung dafür würde bei der Reservierung scheitern.",
|
||||
'failed_runs' => ':n fehlgeschlagene Bereitstellung(en).',
|
||||
'host_error' => 'Host :host meldet einen Fehler.',
|
||||
'host_silent' => 'Host :host hat sich seit über :minutes Minuten nicht gemeldet.',
|
||||
|
|
@ -127,6 +128,7 @@ return [
|
|||
'reserving' => 'Reserviert',
|
||||
'failed' => 'Fehlgeschlagen',
|
||||
'cancellation_scheduled' => 'Kündigung vorgemerkt',
|
||||
'ended' => 'Beendet',
|
||||
'active' => 'Aktiv',
|
||||
'provisioning' => 'Bereitstellung',
|
||||
'suspended' => 'Ausgesetzt',
|
||||
|
|
@ -135,6 +137,14 @@ return [
|
|||
],
|
||||
|
||||
'retry' => 'Wiederholen',
|
||||
'restart' => 'Neu starten',
|
||||
'restart_title' => 'Instanz neu starten?',
|
||||
'restart_body' => ':address wird sauber heruntergefahren und wieder gestartet. Der Kunde ist dabei einige Minuten nicht erreichbar und alle angemeldeten Benutzer werden getrennt.',
|
||||
'restart_cancel' => 'Abbrechen',
|
||||
'restart_confirm' => 'Jetzt neu starten',
|
||||
'restart_started' => 'Neustart wurde gestartet.',
|
||||
'restart_busy' => 'Für diese Instanz läuft bereits ein Vorgang — der Neustart wurde nicht gestartet.',
|
||||
'restart_denied' => 'Für diese Aktion fehlt die Berechtigung.',
|
||||
'restart_pending' => 'Neustart offen',
|
||||
'restart_pending_hint' => 'Neue CPU/RAM sind hinterlegt und werden beim nächsten Neustart der VM wirksam.',
|
||||
'run_retried' => 'Lauf wird erneut gestartet.',
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ return [
|
|||
],
|
||||
|
||||
'notice' => [
|
||||
'no_capacity' => "No host has room for “:plan” any more — the package needs :needs GB and the roomiest host has :largest GB. A paid order for it would fail at reservation.",
|
||||
'failed_runs' => ':n failed provisioning run(s).',
|
||||
'host_error' => 'Host :host reports an error.',
|
||||
'host_silent' => 'Host :host has not checked in for over :minutes minutes.',
|
||||
|
|
@ -127,6 +128,7 @@ return [
|
|||
'reserving' => 'Reserving',
|
||||
'failed' => 'Failed',
|
||||
'cancellation_scheduled' => 'Cancellation scheduled',
|
||||
'ended' => 'Ended',
|
||||
'active' => 'Active',
|
||||
'provisioning' => 'Provisioning',
|
||||
'suspended' => 'Suspended',
|
||||
|
|
@ -135,6 +137,14 @@ return [
|
|||
],
|
||||
|
||||
'retry' => 'Retry',
|
||||
'restart' => 'Restart',
|
||||
'restart_title' => 'Restart this instance?',
|
||||
'restart_body' => ':address will be shut down cleanly and started again. The customer is unreachable for a few minutes and every signed-in user is disconnected.',
|
||||
'restart_cancel' => 'Cancel',
|
||||
'restart_confirm' => 'Restart now',
|
||||
'restart_started' => 'The restart has begun.',
|
||||
'restart_busy' => 'Work is already under way on this instance — no restart was started.',
|
||||
'restart_denied' => 'You are not allowed to do that.',
|
||||
'restart_pending' => 'Restart pending',
|
||||
'restart_pending_hint' => 'New CPU/RAM are configured and take effect at the VM’s next restart.',
|
||||
'run_retried' => 'Run is being retried.',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Overview;
|
||||
use App\Models\Host;
|
||||
use App\Models\Instance;
|
||||
use App\Services\Provisioning\HostCapacity;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Saying so before a paid order finds out.
|
||||
*
|
||||
* The platform books thick: a package reserves its whole `disk_gb` on ONE host
|
||||
* at placement. So the catalogue can go on offering something no machine has
|
||||
* room for, and the first anyone hears of it is a paid order failing at the
|
||||
* reservation step — after the money has moved.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
// hosts.datacenter is a foreign key onto datacenters.code, so the code has
|
||||
// to exist before a host can carry it.
|
||||
App\Models\Datacenter::factory()->create(['code' => 'fsn1']);
|
||||
});
|
||||
|
||||
it('measures the largest single reservation the estate could take', function () {
|
||||
// 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.
|
||||
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 300, 'reserve_pct' => 0]);
|
||||
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 300, 'reserve_pct' => 0]);
|
||||
|
||||
expect(app(HostCapacity::class)->largestPlaceableGb('fsn1'))->toBe(300)
|
||||
->and(app(HostCapacity::class)->canPlace('fsn1', 540))->toBeFalse();
|
||||
});
|
||||
|
||||
it('counts what is already committed, not what is on the disk', function () {
|
||||
// Thick booking: an instance holds its whole disk_gb whether the customer
|
||||
// has put a byte in it or not.
|
||||
$host = Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 0]);
|
||||
Instance::factory()->create(['host_id' => $host->id, 'status' => 'active', 'disk_gb' => 700]);
|
||||
|
||||
expect(app(HostCapacity::class)->largestPlaceableGb('fsn1'))->toBe(300);
|
||||
});
|
||||
|
||||
it('respects the reserve the operator set aside', function () {
|
||||
// reserve_pct is headroom for snapshots and for the host's own growth. A
|
||||
// capacity answer that spends it is not an answer.
|
||||
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 20]);
|
||||
|
||||
expect(app(HostCapacity::class)->largestPlaceableGb('fsn1'))->toBe(800);
|
||||
});
|
||||
|
||||
it('warns on the console about a package that can no longer be placed', function () {
|
||||
// The whole point of the feature: this is the sentence that has to appear
|
||||
// BEFORE somebody pays for the package it names.
|
||||
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 400, 'reserve_pct' => 0]);
|
||||
|
||||
$atRisk = app(HostCapacity::class)->unplaceablePlans();
|
||||
|
||||
expect($atRisk)->not->toBeEmpty();
|
||||
|
||||
$page = Livewire::actingAs(operator('Owner'), 'operator')->test(Overview::class);
|
||||
|
||||
$page->assertViewHas('notices', fn (array $notices) => collect($notices)
|
||||
->contains(fn (array $n) => str_contains($n['text'], 'Host')));
|
||||
});
|
||||
|
||||
it('says nothing while everything still fits', function () {
|
||||
// A warning that is always there is furniture. This one has to mean
|
||||
// something the moment it appears.
|
||||
Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 20000, 'reserve_pct' => 0]);
|
||||
|
||||
expect(app(HostCapacity::class)->unplaceablePlans())->toBeEmpty();
|
||||
});
|
||||
|
||||
it('does not take the overview down when the catalogue cannot be read', function () {
|
||||
// A capacity warning does not get to be the thing that breaks the page an
|
||||
// operator opens when something is already wrong.
|
||||
// PlanCatalogue is final — deliberately, it is the single reader of the
|
||||
// price tables — so it cannot be subclassed. The call site does not
|
||||
// type-hint what comes back from the container, which is enough to put a
|
||||
// throwing stand-in in its place.
|
||||
app()->bind(App\Services\Billing\PlanCatalogue::class, fn () => new class
|
||||
{
|
||||
public function sellable(): array
|
||||
{
|
||||
throw new RuntimeException('overlapping availability windows');
|
||||
}
|
||||
});
|
||||
|
||||
expect(app(HostCapacity::class)->unplaceablePlans())->toBe([]);
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')->test(Overview::class)->assertOk();
|
||||
});
|
||||
|
||||
it('does not shout at an installation that has no host yet', function () {
|
||||
// Zero hosts is not a capacity problem — it is a machine nobody has
|
||||
// onboarded, and that has its own notice. Without this guard a fresh
|
||||
// console greeted the operator with one warning per sellable package
|
||||
// before they had done anything at all.
|
||||
App\Models\Host::query()->delete();
|
||||
|
||||
expect(app(HostCapacity::class)->unplaceablePlans())->toBe([]);
|
||||
});
|
||||
Loading…
Reference in New Issue