From 98d884727a007c2a9219008b7dd223be82958e04 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 18:20:51 +0200 Subject: [PATCH] Warn that a package cannot be placed, before somebody pays for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- VERSION | 2 +- app/Livewire/Admin/Overview.php | 19 ++++ .../Steps/Customer/ReserveResources.php | 9 ++ app/Services/Provisioning/HostCapacity.php | 95 ++++++++++++++++ lang/de/admin.php | 10 ++ lang/en/admin.php | 10 ++ tests/Feature/Admin/HostCapacityTest.php | 101 ++++++++++++++++++ 7 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 app/Services/Provisioning/HostCapacity.php create mode 100644 tests/Feature/Admin/HostCapacityTest.php diff --git a/VERSION b/VERSION index 98390b6..4d9579a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.24 +1.3.25 diff --git a/app/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php index 58a2556..68823f0 100644 --- a/app/Livewire/Admin/Overview.php +++ b/app/Livewire/Admin/Overview.php @@ -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; } diff --git a/app/Provisioning/Steps/Customer/ReserveResources.php b/app/Provisioning/Steps/Customer/ReserveResources.php index 43443f8..2369dcb 100644 --- a/app/Provisioning/Steps/Customer/ReserveResources.php +++ b/app/Provisioning/Steps/Customer/ReserveResources.php @@ -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'); } diff --git a/app/Services/Provisioning/HostCapacity.php b/app/Services/Provisioning/HostCapacity.php new file mode 100644 index 0000000..b0d3c27 --- /dev/null +++ b/app/Services/Provisioning/HostCapacity.php @@ -0,0 +1,95 @@ +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 + */ + 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; + } +} diff --git a/lang/de/admin.php b/lang/de/admin.php index 9c80cbd..a267030 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -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.', diff --git a/lang/en/admin.php b/lang/en/admin.php index f3c9597..544612d 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -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.', diff --git a/tests/Feature/Admin/HostCapacityTest.php b/tests/Feature/Admin/HostCapacityTest.php new file mode 100644 index 0000000..1963892 --- /dev/null +++ b/tests/Feature/Admin/HostCapacityTest.php @@ -0,0 +1,101 @@ +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([]); +});