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