Warn that a package cannot be placed, before somebody pays for it
tests / pest (push) Failing after 7m55s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details

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
nexxo 2026-07-29 18:20:51 +02:00
parent 8f2252e81b
commit 98d884727a
7 changed files with 245 additions and 1 deletions

View File

@ -1 +1 @@
1.3.24 1.3.25

View File

@ -5,6 +5,7 @@ namespace App\Livewire\Admin;
use App\Livewire\Concerns\BuildsRunSteps; use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Customer; use App\Models\Customer;
use App\Models\Host; use App\Models\Host;
use App\Services\Provisioning\HostCapacity;
use App\Models\Instance; use App\Models\Instance;
use App\Models\MonitoringTarget; use App\Models\MonitoringTarget;
use App\Models\ProvisioningRun; use App\Models\ProvisioningRun;
@ -288,6 +289,24 @@ class Overview extends Component
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.monitoring_down', ['n' => $down])]; $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; return $notices;
} }

View File

@ -8,6 +8,7 @@ use App\Models\Order;
use App\Models\ProvisioningRun; use App\Models\ProvisioningRun;
use App\Provisioning\StepResult; use App\Provisioning\StepResult;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class ReserveResources extends CustomerStep 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) { 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'); return StepResult::fail('no_capacity');
} }

View File

@ -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;
}
}

View File

@ -79,6 +79,7 @@ return [
], ],
'notice' => [ '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).', 'failed_runs' => ':n fehlgeschlagene Bereitstellung(en).',
'host_error' => 'Host :host meldet einen Fehler.', 'host_error' => 'Host :host meldet einen Fehler.',
'host_silent' => 'Host :host hat sich seit über :minutes Minuten nicht gemeldet.', 'host_silent' => 'Host :host hat sich seit über :minutes Minuten nicht gemeldet.',
@ -127,6 +128,7 @@ return [
'reserving' => 'Reserviert', 'reserving' => 'Reserviert',
'failed' => 'Fehlgeschlagen', 'failed' => 'Fehlgeschlagen',
'cancellation_scheduled' => 'Kündigung vorgemerkt', 'cancellation_scheduled' => 'Kündigung vorgemerkt',
'ended' => 'Beendet',
'active' => 'Aktiv', 'active' => 'Aktiv',
'provisioning' => 'Bereitstellung', 'provisioning' => 'Bereitstellung',
'suspended' => 'Ausgesetzt', 'suspended' => 'Ausgesetzt',
@ -135,6 +137,14 @@ return [
], ],
'retry' => 'Wiederholen', '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' => 'Neustart offen',
'restart_pending_hint' => 'Neue CPU/RAM sind hinterlegt und werden beim nächsten Neustart der VM wirksam.', 'restart_pending_hint' => 'Neue CPU/RAM sind hinterlegt und werden beim nächsten Neustart der VM wirksam.',
'run_retried' => 'Lauf wird erneut gestartet.', 'run_retried' => 'Lauf wird erneut gestartet.',

View File

@ -79,6 +79,7 @@ return [
], ],
'notice' => [ '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).', 'failed_runs' => ':n failed provisioning run(s).',
'host_error' => 'Host :host reports an error.', 'host_error' => 'Host :host reports an error.',
'host_silent' => 'Host :host has not checked in for over :minutes minutes.', 'host_silent' => 'Host :host has not checked in for over :minutes minutes.',
@ -127,6 +128,7 @@ return [
'reserving' => 'Reserving', 'reserving' => 'Reserving',
'failed' => 'Failed', 'failed' => 'Failed',
'cancellation_scheduled' => 'Cancellation scheduled', 'cancellation_scheduled' => 'Cancellation scheduled',
'ended' => 'Ended',
'active' => 'Active', 'active' => 'Active',
'provisioning' => 'Provisioning', 'provisioning' => 'Provisioning',
'suspended' => 'Suspended', 'suspended' => 'Suspended',
@ -135,6 +137,14 @@ return [
], ],
'retry' => 'Retry', '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' => 'Restart pending',
'restart_pending_hint' => 'New CPU/RAM are configured and take effect at the VMs next restart.', 'restart_pending_hint' => 'New CPU/RAM are configured and take effect at the VMs next restart.',
'run_retried' => 'Run is being retried.', 'run_retried' => 'Run is being retried.',

View File

@ -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([]);
});