Ein angehefteter Host umging die Reservierung
Heftet ein Betreiber eine geparkte Bestellung an einen Host, prüfte der Anheft-Zweig nur die Kapazität — nicht, ob die Maschine jemand anderem reserviert ist. Die frisch gebaute Reservierung ließ sich damit über einen ganz normalen Weg durch die Oberfläche aushebeln, denn das Auswahlfeld auf der Kapazitätsseite bot weiterhin alle aktiven Hosts an. Der Schritt stellt jetzt dieselbe Bedingung wie die freie Platzierung — unreserviert oder dem Kunden dieser Bestellung reserviert — über Host::scopeUnreserved(), statt sie ein zweites Mal zu formulieren. Ein Pin auf eine fremde Maschine wird nicht umgeleitet, sondern wartet (awaiting_pinned_host): zu korrigieren ist die Wahl, nicht die Maschine. Und die Ursache eine Ebene höher: das Auswahlfeld stellt fremd reservierte Hosts gar nicht mehr zur Wahl, pin() lehnt sie zusätzlich ab, und ein Pin, der seit dem Anheften reserviert wurde, sagt das in der Zeile — ein Auswahlfeld, das dann kommentarlos wieder "Automatisch" zeigte, verschwiege, warum die Bestellung weiter wartet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>main
parent
da11d70037
commit
1207182662
|
|
@ -76,7 +76,16 @@ class Capacity extends Component
|
|||
return;
|
||||
}
|
||||
|
||||
$host = Host::query()->where('status', 'active')->find((int) $hostId);
|
||||
// Nur ein Host, auf dem diese Bestellung auch landen dürfte:
|
||||
// unreserviert oder ihrem eigenen Kunden reserviert, über denselben
|
||||
// Scope, den die Platzierung im Schritt benutzt. Eine reservierte
|
||||
// Maschine gehört ihrem Kunden — sie hier anheften zu lassen hieße, die
|
||||
// Zusage über die Konsole wieder aufzumachen, und der Schritt lehnte
|
||||
// die Bestellung danach ohnehin ab.
|
||||
$host = Host::query()
|
||||
->where('status', 'active')
|
||||
->unreserved($run->subject?->customer_id)
|
||||
->find((int) $hostId);
|
||||
|
||||
if ($host === null) {
|
||||
return;
|
||||
|
|
@ -96,9 +105,36 @@ class Capacity extends Component
|
|||
|
||||
$capacity = app(HostCapacity::class);
|
||||
$demand = $capacity->queueDemand();
|
||||
$parked = $capacity->parked();
|
||||
|
||||
// Die Auswahl je Bestellung, nicht eine Liste für alle: welcher Host in
|
||||
// Frage kommt, hängt am Kunden dieser Bestellung. Gefragt wird über
|
||||
// denselben Scope wie in der Platzierung — im eigenen Rechenzentrum
|
||||
// (der Schritt lehnt einen Pin von anderswo ab) und nicht jemand
|
||||
// anderem reserviert. Ein Auswahlfeld, das anbietet, was die Aktion
|
||||
// danach ablehnt, ist eine Falle.
|
||||
//
|
||||
// Eine Abfrage je Rechenzentrum/Kunde-Paar statt je Zeile: die
|
||||
// Warteschlange ist die Liste bezahlter Bestellungen ohne Maschine, also
|
||||
// kurz, und mehrere davon gehören meist demselben Rechenzentrum.
|
||||
$choices = [];
|
||||
|
||||
foreach ($parked as $index => $entry) {
|
||||
$customerId = $entry['order']->customer_id;
|
||||
$key = $entry['datacenter'].'|'.$customerId;
|
||||
|
||||
$choices[$key] ??= Host::query()
|
||||
->where('status', 'active')
|
||||
->where('datacenter', $entry['datacenter'])
|
||||
->unreserved($customerId)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$parked[$index]['choices'] = $choices[$key];
|
||||
}
|
||||
|
||||
return view('livewire.admin.capacity', [
|
||||
'parked' => $capacity->parked(),
|
||||
'parked' => $parked,
|
||||
'demand' => $demand,
|
||||
'unplaceable' => $capacity->unplaceablePlans(),
|
||||
// reservedFor eager-loaded: die Tafel "was die Hosts noch
|
||||
|
|
|
|||
|
|
@ -91,8 +91,14 @@ class ReserveResources extends CustomerStep
|
|||
// it somewhere else because the chosen host happened to be full
|
||||
// would answer a different question than the one the operator
|
||||
// answered, and they would find out from the finished instance.
|
||||
//
|
||||
// Reservierung UND Platz, nicht nur Platz: ein Anheften ist eine
|
||||
// Wahl unter den erlaubten Hosts, keine Umgehung. Ohne die erste
|
||||
// Bedingung ließ sich die Zusage "diese Maschine gehört Ihnen"
|
||||
// über das Auswahlfeld der Kapazitätsseite aushebeln — dieselbe
|
||||
// Frage, die placeableIn() unten über denselben Scope stellt.
|
||||
$host = $pinned !== null
|
||||
? ($pinned->canTake((int) $plan['disk_gb']) ? $pinned : null)
|
||||
? ($this->mayPlaceOn($pinned, $order) && $pinned->canTake((int) $plan['disk_gb']) ? $pinned : null)
|
||||
: Host::placeableIn($order->datacenter, (int) $plan['disk_gb'], $order->customer_id);
|
||||
|
||||
if ($host === null) {
|
||||
|
|
@ -197,6 +203,27 @@ class ReserveResources extends CustomerStep
|
|||
->find((int) $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf diese Bestellung überhaupt auf diese Maschine?
|
||||
*
|
||||
* Unreserviert oder diesem Kunden reserviert — über denselben Scope, den
|
||||
* die freie Platzierung benutzt, und nicht als zweite Formulierung
|
||||
* derselben Bedingung: zwei Formulierungen wären zwei Grenzen, und die
|
||||
* zweite wäre die, die man beim nächsten Mal vergisst.
|
||||
*
|
||||
* Ein Pin auf eine fremde Maschine wird damit nicht umgeleitet, sondern
|
||||
* wartet (`awaiting_pinned_host`) — die Wahl ist zu korrigieren, nicht die
|
||||
* Maschine, und ein stiller Ausweichplatz wäre wieder eine Antwort auf eine
|
||||
* Frage, die niemand gestellt hat.
|
||||
*/
|
||||
private function mayPlaceOn(Host $host, Order $order): bool
|
||||
{
|
||||
return Host::query()
|
||||
->whereKey($host->getKey())
|
||||
->unreserved($order->customer_id)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function putContext(ProvisioningRun $run, Instance $instance): void
|
||||
{
|
||||
$run->mergeContext([
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ return [
|
|||
'col_target' => 'Ziel-Host',
|
||||
'target_auto' => 'Automatisch platzieren',
|
||||
'target_too_small' => 'Dieser Host hat derzeit zu wenig Platz — die Bestellung wartet, bis er ihn hat.',
|
||||
'target_reserved' => 'Dieser Host ist inzwischen einem anderen Kunden reserviert — bitte einen anderen wählen.',
|
||||
'pinned' => 'Wird auf :host ausgerollt, sobald dort Platz ist.',
|
||||
'pin_cleared' => 'Zuweisung aufgehoben — die Bestellung nimmt wieder den ersten passenden Host.',
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ return [
|
|||
'col_target' => 'Target host',
|
||||
'target_auto' => 'Place automatically',
|
||||
'target_too_small' => 'This host has too little room right now — the order waits until it has.',
|
||||
'target_reserved' => 'This host is now reserved for another customer — please choose a different one.',
|
||||
'pinned' => 'Will roll out on :host as soon as it has room.',
|
||||
'pin_cleared' => 'Assignment cleared — the order takes the first host that fits again.',
|
||||
|
||||
|
|
|
|||
|
|
@ -36,14 +36,26 @@
|
|||
<tbody>
|
||||
@foreach ($parked as $entry)
|
||||
@php
|
||||
// Only hosts in the order's own datacenter: placement
|
||||
// is a per-datacenter question, and the step refuses a
|
||||
// pin from anywhere else. Offering one here would be
|
||||
// offering a choice that silently does nothing.
|
||||
$choices = $hosts->where('datacenter', $entry['datacenter']);
|
||||
// Die Hosts, auf denen GENAU DIESE Bestellung landen
|
||||
// dürfte — eigenes Rechenzentrum und keine fremde
|
||||
// Reservierung. Zusammengestellt in Capacity::render()
|
||||
// über denselben Scope wie die Platzierung selbst,
|
||||
// statt hier ein zweites Mal formuliert.
|
||||
$choices = $entry['choices'];
|
||||
// Der angeheftete Host aus dem ganzen Bestand des
|
||||
// Rechenzentrums, nicht nur aus der Auswahl: eine
|
||||
// Maschine, die seit dem Anheften reserviert wurde,
|
||||
// steht nicht mehr zur Wahl — und ein Auswahlfeld,
|
||||
// das dann kommentarlos wieder „Automatisch" zeigt,
|
||||
// verschweigt, warum die Bestellung weiter wartet.
|
||||
// Ein Pin aus einem anderen Rechenzentrum bleibt
|
||||
// unbeachtet wie bisher; der Schritt ignoriert ihn
|
||||
// ebenfalls, und „reserviert" wäre dafür der
|
||||
// falsche Satz.
|
||||
$pinnedHost = $entry['pinned_host_id'] === null
|
||||
? null
|
||||
: $choices->firstWhere('id', $entry['pinned_host_id']);
|
||||
: $hosts->where('datacenter', $entry['datacenter'])->firstWhere('id', $entry['pinned_host_id']);
|
||||
$pinnable = $pinnedHost !== null && $choices->contains('id', $pinnedHost->id);
|
||||
@endphp
|
||||
<tr wire:key="parked-{{ $entry['run']->id }}" class="border-b border-line last:border-0">
|
||||
<td class="py-3 font-medium text-ink">{{ $entry['order']->customer?->name ?? '—' }}</td>
|
||||
|
|
@ -65,7 +77,16 @@
|
|||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@if ($pinnedHost !== null && ! $pinnedHost->canTake($entry['needs']))
|
||||
@if ($pinnedHost !== null && ! $pinnable)
|
||||
{{-- Die Wahl steht noch im Lauf, die Maschine
|
||||
gehört inzwischen jemandem: der Schritt
|
||||
lässt die Bestellung darauf nicht landen,
|
||||
und ohne diesen Satz sähe die Zeile aus
|
||||
wie jede andere. --}}
|
||||
<p class="mt-1 flex items-center gap-1.5 text-xs text-warning">
|
||||
<x-ui.icon name="alert-triangle" class="size-3.5 shrink-0" />{{ __('capacity.target_reserved') }}
|
||||
</p>
|
||||
@elseif ($pinnedHost !== null && ! $pinnedHost->canTake($entry['needs']))
|
||||
{{-- A pin is honoured or it waits — never
|
||||
quietly redirected. So say so, or the row
|
||||
sits there looking like every other one
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@
|
|||
|
||||
use App\Livewire\Admin\Capacity;
|
||||
use App\Livewire\Admin\Overview;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Datacenter;
|
||||
use App\Models\Host;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Order;
|
||||
use App\Models\ProvisioningRun;
|
||||
use App\Models\Subscription;
|
||||
use App\Provisioning\RunRunner;
|
||||
use App\Provisioning\Steps\Customer\ReserveResources;
|
||||
use App\Services\Provisioning\HostCapacity;
|
||||
|
|
@ -159,7 +161,7 @@ it('sizes the queue from the contract the customer signed, not today catalogue',
|
|||
// for the customer who bought the old one.
|
||||
$run = parkedOrder('start');
|
||||
|
||||
App\Models\Subscription::query()
|
||||
Subscription::query()
|
||||
->where('order_id', $run->subject_id)
|
||||
->update(['disk_gb' => 4000]);
|
||||
|
||||
|
|
@ -246,6 +248,97 @@ it('waits rather than quietly placing a pinned order somewhere else', function (
|
|||
->and(Instance::query()->where('order_id', $run->subject_id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
/**
|
||||
* Nachtrag aus der Durchsicht (Befund 2): ein Anheften ist eine Wahl unter den
|
||||
* erlaubten Hosts, keine Umgehung der Reservierung.
|
||||
*
|
||||
* Der Anheft-Zweig prüfte nur die Kapazität (`canTake`) — nicht, ob die
|
||||
* Maschine jemand anderem gehört. Die frisch gebaute Reservierung ließ sich
|
||||
* damit über einen ganz normalen Weg durch die Oberfläche aushebeln: das
|
||||
* Auswahlfeld auf der Kapazitätsseite bot alle aktiven Hosts an.
|
||||
*/
|
||||
it('platziert eine angeheftete Bestellung nicht auf einem fremd reservierten Host', function () {
|
||||
$run = parkedOrder('start');
|
||||
$fremder = Customer::factory()->create();
|
||||
|
||||
$reserviert = Host::factory()->active()->create([
|
||||
'datacenter' => 'fsn1', 'name' => 'pve-fremd', 'total_gb' => 1000, 'reserve_pct' => 0,
|
||||
'reserved_for_customer_id' => $fremder->id,
|
||||
]);
|
||||
|
||||
// Ein freier Host daneben: der Pin darf auch nicht still dorthin ausweichen
|
||||
// — eine Bestellung, die woanders landet, beantwortet eine andere Frage als
|
||||
// die, die der Betreiber beantwortet hat.
|
||||
$frei = Host::factory()->active()->create(['datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 0]);
|
||||
|
||||
$run->mergeContext(['preferred_host_id' => $reserviert->id]);
|
||||
$run->update(['status' => ProvisioningRun::STATUS_RUNNING]);
|
||||
|
||||
$result = app(ReserveResources::class)->execute($run->fresh());
|
||||
|
||||
expect($result->type)->toBe('poll')
|
||||
->and($result->reason)->toBe('awaiting_pinned_host')
|
||||
->and(Instance::query()->where('order_id', $run->subject_id)->exists())->toBeFalse()
|
||||
->and($frei->instances()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('platziert eine angeheftete Bestellung auf dem Host, der ihrem eigenen Kunden reserviert ist', function () {
|
||||
// Der ganze Sinn einer Reservierung: die Maschine gehört diesem Kunden, und
|
||||
// seine eigene Bestellung gehört genau dorthin.
|
||||
$run = parkedOrder('start');
|
||||
|
||||
$eigen = Host::factory()->active()->create([
|
||||
'datacenter' => 'fsn1', 'total_gb' => 1000, 'reserve_pct' => 0,
|
||||
'reserved_for_customer_id' => $run->subject->customer_id,
|
||||
]);
|
||||
|
||||
$run->mergeContext(['preferred_host_id' => $eigen->id]);
|
||||
$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($eigen->id);
|
||||
});
|
||||
|
||||
it('stellt einen fremd reservierten Host im Anheft-Auswahlfeld gar nicht erst zur Wahl', function () {
|
||||
// Die Ursache eine Ebene höher. Ein Knopf, der etwas anbietet, das die
|
||||
// Aktion danach ablehnt, ist eine Falle — und die Aktion lehnt jetzt ab.
|
||||
$run = parkedOrder('start');
|
||||
$fremder = Customer::factory()->create();
|
||||
|
||||
$reserviert = Host::factory()->active()->create([
|
||||
'datacenter' => 'fsn1', 'name' => 'pve-fremd', 'total_gb' => 1000, 'reserve_pct' => 0,
|
||||
'reserved_for_customer_id' => $fremder->id,
|
||||
]);
|
||||
$eigen = Host::factory()->active()->create([
|
||||
'datacenter' => 'fsn1', 'name' => 'pve-eigen', 'total_gb' => 1000, 'reserve_pct' => 0,
|
||||
'reserved_for_customer_id' => $run->subject->customer_id,
|
||||
]);
|
||||
|
||||
$page = Livewire::actingAs(operator('Owner'), 'operator')->test(Capacity::class);
|
||||
|
||||
$page->assertDontSeeHtml('<option value="'.$reserviert->id.'"')
|
||||
->assertSeeHtml('<option value="'.$eigen->id.'"')
|
||||
->call('pin', $run->id, (string) $reserviert->id);
|
||||
|
||||
expect($run->fresh()->context('preferred_host_id'))->toBeNull();
|
||||
});
|
||||
|
||||
it('sagt es, wenn die angeheftete Maschine inzwischen jemand anderem gehört', function () {
|
||||
// Anheften ging, danach wurde der Host reserviert. Er steht dann nicht mehr
|
||||
// zur Wahl — und ein Auswahlfeld, das kommentarlos wieder „Automatisch"
|
||||
// zeigt, verschweigt, warum die Bestellung weiter wartet.
|
||||
$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);
|
||||
|
||||
$host->update(['reserved_for_customer_id' => Customer::factory()->create()->id]);
|
||||
|
||||
$page->call('$refresh')->assertSee(__('capacity.target_reserved'));
|
||||
});
|
||||
|
||||
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]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue