Release v1.3.93 — die Seite konnte den Host gar nicht fragen
tests / pest (push) Failing after 9m23s Details
tests / assets (push) Successful in 28s Details
tests / release (push) Has been skipped Details

Auf echter Hardware blieb jede Messkachel leer, während der Host sichtbar
online war und vor zwei Minuten geantwortet hatte. Kein Zufall und kein
Aussetzer: ein Entwurfsfehler.

Nur der queue-provisioning-Container hängt im WireGuard-Netz — NET_ADMIN,
/dev/net/tun, das wireguard-Volume. Der app-Container, der die Konsole rendert,
hat überhaupt keine Route zu 10.66.0.x. Ich hatte den Proxmox-Aufruf in
render() gelegt, also in den einen Container, der die Management-Adresse nicht
erreichen kann. Ein Aufruf von dort konnte nie etwas anderes sein als eine
Zeitüberschreitung.

PingHosts schreibt dieselbe Regel seit Langem in seinen Kopf: "runs on the
provisioning queue, which is where the Proxmox credentials are usable." Ich
habe sie gelesen und nicht angewendet.

Verschlimmert hat es mein eigenes catch (Throwable): der Grund wurde
verschluckt, und die Kachel sagte "keine Messwerte" — ununterscheidbar davon,
dass der Host schweigt. Auf dem Testhost fiel nichts auf, weil TEST-NET
ohnehin nie antwortet.

Jetzt zwei Hälften:

- HostLoadSeries::collect() holt und legt ab, aus Jobs\CollectHostLoad auf der
  provisioning-Warteschlange, minütlich — der Takt, in dem Proxmox einen
  frischen Messwert schreibt. Ein Fehlschlag wird protokolliert, mit Host,
  Node und Grund.
- HostLoadSeries::forHost() liest nur aus dem Zwischenspeicher und öffnet nie
  eine Verbindung. Ein Test hält das mit Http::assertNothingSent() fest.

Der Eintrag lebt fünf Minuten bei minütlichem Sammeln: länger als der Takt,
damit ein ausgefallener Lauf keine Seite leert, die eine Sekunde vorher in
Ordnung war — und kurz genug, dass ein stehengebliebener Sammler die Zahlen
mitnimmt, statt eine alte Stunde als aktuell stehenzulassen.

Der Sammler ist ShouldBeUnique (Codex-Befund, P1). Die provisioning-Warteschlange
ist DIESELBE, auf der Kunden-Bereitstellung läuft; ein stiller Host kostet den
vollen HTTP-Zeitablauf, und ohne diese Sperre stauten sich minütlich neue Läufe
hinter dem alten und verzögerten bezahlte Arbeit. Dasselbe Mittel, das
CollectInstanceTraffic nebenan schon benutzt.

Dazu: der Zustands-Punkt war mit 62 px so groß wie der Speicher-Ring nebenan.
Eine gefüllte Scheibe wiegt optisch weit mehr als ein dünner Ring und erschlug
die Kachel — jetzt 32 px.

Und ein Test, der aus Versehen recht behielt: die Kachel-Prüfung verglich mit
"50", was auch die 500 GB in der Instanzenliste darunter trifft. Sie prüft
jetzt Zahlen, die sonst nirgends auf der Seite vorkommen.

Noch offen, nicht hier angefasst: VmTemplateCheck fragt die Proxmox-API
ebenfalls aus dem app-Container heraus, von der Bereitschaftsseite aus. Selber
Fehler, Bestand, eigener Punkt.

Geprüft: 2299 Tests grün, Pint sauber, Codex ohne Befund. Im Browser mit
eingespielten Messwerten: sechs gefüllte Kacheln, Zustands-Scheibe in
Proportion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main v1.3.93
nexxo 2026-08-01 11:23:00 +02:00
parent 589489a9fc
commit dc35e5310f
9 changed files with 389 additions and 72 deletions

View File

@ -1 +1 @@
1.3.92
1.3.93

View File

@ -0,0 +1,73 @@
<?php
namespace App\Provisioning\Jobs;
use App\Models\Host;
use App\Services\Proxmox\HostLoadSeries;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Fetches the last hour of load from every host that is worth asking, and puts
* it where the console can read it.
*
* On the `provisioning` queue, and that is the whole reason this job exists:
* only that container is inside the WireGuard tunnel. The `app` container has
* no route to a host's management address, so the console cannot fetch this
* itself it reads what this job left behind. Same rule, same queue and the
* same reason as PingHosts next door.
*
* Every minute, because that is how often Proxmox writes a fresh sample. The
* cache entry lives five minutes, so one missed run does not blank a page,
* and a collector that has stopped takes the figures with it instead of
* leaving an old hour on screen looking current.
*/
class CollectHostLoad implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* One collector at a time, fleet-wide.
*
* The `provisioning` queue is the SAME queue that carries out customer
* provisioning. A host that has gone quiet costs this job the full HTTP
* timeout, so with enough of them a minutely run is not finished when the
* next one is dispatched and the backlog then delays work somebody has
* paid for. Same guard, same reason as CollectInstanceTraffic next door.
*/
public int $uniqueFor = 600;
/**
* A missed minute is fetched by the next minute, not by a retry of this
* one: the RRD is a rolling hour, so a repeat would ask for almost exactly
* what the following run asks for anyway.
*/
public int $tries = 1;
public function __construct()
{
$this->onQueue('provisioning');
}
public function handle(HostLoadSeries $load): void
{
// The same two states PingHosts asks about: a host still being onboarded
// has no token yet, and a retired one is nobody's dashboard. `error` is
// included deliberately — a host in trouble is exactly the one an
// operator opens the page for.
$hosts = Host::query()
->whereIn('status', ['active', 'error'])
->whereNotNull('api_token_ref')
->get();
foreach ($hosts as $host) {
// collect() logs its own failures and never throws: one unreachable
// host must not stop the collection for the rest of the fleet.
$load->collect($host);
}
}
}

View File

@ -4,71 +4,124 @@ namespace App\Services\Proxmox;
use App\Models\Host;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* The last hour of a host's CPU and memory load, as two percentage series.
* The last hour of a host's CPU, memory and network load, as percentage and
* MiB/s series.
*
* Read from Proxmox's OWN recorded history (`/nodes/{node}/rrddata`) rather
* than sampled into a table of ours. PVE keeps hour/day/week/month/year for
* every node whether we ask or not, so a sampler would mean a new table, a
* minutely job, a pruning job and ~1440 rows per host per day to reproduce,
* less accurately, something already on disk. It also means the curve is full
* from the first second, including the hour before anyone opened the page, and
* that it cannot disagree with what Proxmox's own interface shows.
* ---------------------------------------------------------------------------
* Fetching and reading are separate on purpose
* ---------------------------------------------------------------------------
*
* The price is stated rather than hidden: a host that is not reachable has no
* curve. See `available` below.
* Only the `queue-provisioning` container is inside the WireGuard tunnel it
* is the hub (NET_ADMIN, /dev/net/tun, the wireguard volume). The `app`
* container that renders the console has no route to 10.66.0.x at all, so an
* API call from a Livewire render could never be anything but a timeout. That
* is exactly what happened on the first real host: every tile read "no
* measurements" while the host was plainly online, and the swallowed exception
* meant the page could not say why.
*
* So collect() runs on the provisioning queue and writes the cache; forHost()
* is a pure read and never opens a socket. PingHosts states the same rule in
* its header "runs on the provisioning queue, which is where the Proxmox
* credentials are usable" — and this is the second thing that had to learn it.
*
* ---------------------------------------------------------------------------
* Why Proxmox's own history and not a sampler of ours
* ---------------------------------------------------------------------------
*
* PVE keeps hour/day/week/month/year for every node whether we ask or not, so
* a sampler would mean a new table, a pruning job and ~1440 rows per host per
* day to reproduce, less accurately, something already on disk. The cache holds
* the hour we display; the history itself stays where it is written.
*/
final class HostLoadSeries
{
/** Just under the minute at which PVE writes a fresh sample. */
private const TTL_SECONDS = 55;
public function __construct(private ProxmoxClient $pve) {}
/**
* Five minutes, while the collector runs every minute.
*
* Longer than the interval so one missed run does not blank a page that was
* fine a second ago and short enough that a collector which has stopped
* takes the figures with it rather than leaving yesterday's load on screen
* looking current.
*/
private const TTL_SECONDS = 300;
/** Bytes per second → MiB/s. The tiles carry the unit, so nobody has to guess. */
private const MIB = 1048576;
public function __construct(private ProxmoxClient $pve) {}
/**
* What the console shows. A pure cache read never a request to the host.
*
* Nothing collected yet reads as "no measurements", which is the honest
* answer: it is the same thing an operator sees when the host is silent,
* and both are settled by the collector, not by the page.
*
* @return array{labels: array<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, netin: array<int, ?float>, netout: array<int, ?float>, available: bool}
*/
public function forHost(Host $host): array
{
// The `v2` is not decoration. A cache entry outlives a deploy, and an
// entry written by the version before the network series has no
// `netin`/`netout` — read as one of today's it kills the host page with
// an undefined key for the length of the TTL, which is exactly the
// minute somebody is looking to see whether the update went through.
// The key moves with the shape; the old entry is simply never read
// again and expires on its own.
return Cache::remember(
'host-load:v2:'.$host->id,
self::TTL_SECONDS,
fn () => $this->read($host),
);
return Cache::get($this->key($host), $this->empty());
}
/**
* @return array{labels: array<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, netin: array<int, ?float>, netout: array<int, ?float>, available: bool}
* Fetch the hour from Proxmox and put it where the console can read it.
*
* Runs ONLY where the tunnel is see the class docblock. Failure is logged
* rather than swallowed: an empty tile that cannot say whether the host is
* silent or unreachable costs an afternoon to diagnose, and did.
*/
private function read(Host $host): array
public function collect(Host $host): void
{
$empty = ['labels' => [], 'cpu' => [], 'ram' => [], 'netin' => [], 'netout' => [], 'available' => false];
try {
$rows = $this->pve->forHost($host)->nodeRrdData($host->node ?? 'pve', 'hour');
} catch (Throwable) {
// Deliberately not a series of zeros. A flat line at zero is a
// claim about the host — that it was idle — and this is the one
// case where we know nothing at all. The panel says so instead.
return $empty;
} catch (Throwable $e) {
Log::warning('host load: could not read the RRD history', [
'host' => $host->name,
'node' => $host->node,
'reason' => $e->getMessage(),
]);
return;
}
if ($rows === []) {
return $empty;
return;
}
Cache::put($this->key($host), $this->series($rows), self::TTL_SECONDS);
}
/**
* The cache key carries the shape's version.
*
* An entry outlives a deploy, and one written before the network series has
* no netin/netout read as one of today's it kills the host page with an
* undefined key for the length of the TTL, which is exactly the minute
* somebody is looking to see whether the update went through. The key moves
* with the shape; the old entry is never read again and expires on its own.
*/
private function key(Host $host): string
{
return 'host-load:v2:'.$host->id;
}
/** @return array{labels: array<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, netin: array<int, ?float>, netout: array<int, ?float>, available: bool} */
private function empty(): array
{
return ['labels' => [], 'cpu' => [], 'ram' => [], 'netin' => [], 'netout' => [], 'available' => false];
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array{labels: array<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, netin: array<int, ?float>, netout: array<int, ?float>, available: bool}
*/
private function series(array $rows): array
{
$labels = [];
$cpu = [];
$ram = [];
@ -93,16 +146,6 @@ final class HostLoadSeries
];
}
/** Bytes per second as MiB/s — or null, for the same reason percent() returns null. */
private function rate(mixed $bytesPerSecond): ?float
{
if (! is_numeric($bytesPerSecond)) {
return null;
}
return round((float) $bytesPerSecond / self::MIB, 2);
}
/**
* A share of a whole, in percent or null when either half is missing.
*
@ -120,4 +163,14 @@ final class HostLoadSeries
return round((float) $part / (float) $whole * 100, 2);
}
/** Bytes per second as MiB/s — or null, for the same reason percent() returns null. */
private function rate(mixed $bytesPerSecond): ?float
{
if (! is_numeric($bytesPerSecond)) {
return null;
}
return round((float) $bytesPerSecond / self::MIB, 2);
}
}

View File

@ -40,10 +40,37 @@ Eine Null-Linie behauptet einen ruhigen Host; eine Lücke behauptet nichts.
Antwortet der Host gar nicht, ist die Reihe leer und die Tafel sagt das in einem
Satz.
**Abgefragt wird im Minutentakt**, weil die RRD selbst im Minutentakt
fortgeschrieben wird. Alles darunter wären Anfragen über den Tunnel für dieselbe
Antwort. Dazu `Cache::remember` für 55 s je Host, damit zwei offene Konsolen den
Host nicht doppelt fragen.
### Geholt wird, wo der Tunnel ist — gelesen, wo die Seite ist
**Der Entwurf lag hier zuerst falsch, und echte Hardware hat es gezeigt.** Die
erste Fassung rief die Proxmox-API in `render()` auf. Nur der
`queue-provisioning`-Container hängt aber im WireGuard-Netz (`NET_ADMIN`,
`/dev/net/tun`, das `wireguard`-Volume); der `app`-Container, der die Konsole
rendert, erreicht `10.66.0.x` überhaupt nicht. Auf dem ersten echten Host blieb
deshalb **jede Kachel leer, während der Host sichtbar online war** — und weil
die Ausnahme verschluckt wurde, konnte die Seite den Grund nicht nennen.
`PingHosts` schreibt dieselbe Regel seit Langem in seinen Kopf: *„runs on the
provisioning queue, which is where the Proxmox credentials are usable."*
Also zwei Hälften:
- **`HostLoadSeries::collect($host)`** holt und legt ab. Läuft nur im
`provisioning`-Container, über `Jobs\CollectHostLoad`, minütlich — der Takt,
in dem Proxmox einen frischen Messwert schreibt. Ein Fehlschlag wird
**protokolliert**, nicht verschluckt.
- **`HostLoadSeries::forHost($host)`** ist ein reines Lesen aus dem
Zwischenspeicher und öffnet nie eine Verbindung. Ein Test hält das fest
(`Http::assertNothingSent()`).
Der Eintrag lebt **fünf Minuten** bei minütlichem Sammeln: länger als der Takt,
damit ein ausgefallener Lauf keine Seite leert, die eine Sekunde vorher in
Ordnung war — und kurz genug, dass ein stehengebliebener Sammler die Zahlen
mitnimmt, statt eine alte Stunde als aktuell stehenzulassen.
**Derselbe Fehler steckt noch in `VmTemplateCheck`**, das aus der
Bereitschaftsseite heraus im `app`-Container die Proxmox-API fragt. Bestand,
nicht hier entstanden — eigener Punkt.
## 2. Die Kurve wirklich lebendig

View File

@ -121,8 +121,11 @@
:value="__('hosts.health.'.$health)"
:foot="$host->last_seen_at ? __('hosts.detail.last_seen', ['time' => $host->last_seen_at->diffForHumans()]) : __('hosts.detail.never_seen')">
<x-slot:visual>
<span class="grid size-[62px] place-items-center rounded-pill {{ $hdisc }}">
<span class="size-3.5 rounded-pill {{ $hdot }} {{ $health === 'online' ? 'animate-pulse' : '' }}" aria-hidden="true"></span>
{{-- Halb so groß wie der Ring nebenan, nicht gleich groß: eine
gefüllte Scheibe wiegt optisch weit mehr als ein dünner
Ring, und bei 62 px erschlug sie die Kachel. --}}
<span class="grid size-8 place-items-center rounded-pill {{ $hdisc }}">
<span class="size-2.5 rounded-pill {{ $hdot }} {{ $health === 'online' ? 'animate-pulse' : '' }}" aria-hidden="true"></span>
</span>
</x-slot:visual>
</x-ui.metric>

View File

@ -2,6 +2,7 @@
use App\Console\TickProvisioning;
use App\Models\StripePendingEvent;
use App\Provisioning\Jobs\CollectHostLoad;
use App\Provisioning\Jobs\CollectInstanceTraffic;
use App\Provisioning\Jobs\PingHosts;
use App\Provisioning\Jobs\RecordProvisioningHeartbeat;
@ -177,6 +178,17 @@ Schedule::job(new PingHosts)
->everyMinute()
->name('host-ping');
// Die Stundenkurve auf der Host-Seite holen.
//
// Hier und nicht in der Seite, weil nur dieser Container im WireGuard-Tunnel
// hängt: der `app`-Container erreicht die Management-Adresse eines Hosts gar
// nicht, und ein API-Aufruf aus render() konnte deshalb nie etwas anderes sein
// als eine Zeitüberschreitung. Minütlich, weil Proxmox in diesem Takt einen
// frischen Messwert schreibt.
Schedule::job(new CollectHostLoad)
->everyMinute()
->name('host-load');
// Carry out the downgrades whose term has run out.
//
// An upgrade lands the moment it is paid for; a downgrade waits, because

View File

@ -12,6 +12,7 @@ use App\Models\User;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Provisioning\Jobs\PurgeHost;
use App\Provisioning\Jobs\RemoveWireguardPeer;
use App\Services\Proxmox\HostLoadSeries;
use App\Support\HostEnrolment;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Queue;
@ -327,20 +328,44 @@ it('gives every measurement its own tile, so none of them needs a legend', funct
// Eine Reihe je Kachel: die Beschriftung benennt sie, und damit entfällt
// die Legende, die vorher unter der gemeinsamen Kurve stand.
$s = fakeServices();
// Bewusst unverwechselbare Zahlen: „50" träfe auch die 500 GB in der
// Instanzenliste darunter, und ein Test, der aus Versehen recht behält,
// prüft nichts. Siehe die lose Zahlen-Falle, die das Repo schon kennt.
$s['pve']->rrd = [
['time' => 1754035200, 'cpu' => 0.25, 'memused' => 34_359_738_368, 'memtotal' => 68_719_476_736, 'netin' => 1_048_576, 'netout' => 2_097_152],
['time' => 1754035260, 'cpu' => 0.5, 'memused' => 17_179_869_184, 'memtotal' => 68_719_476_736, 'netin' => 2_097_152, 'netout' => 1_048_576],
['time' => 1754035200, 'cpu' => 0.11, 'memused' => 11, 'memtotal' => 100, 'netin' => 1_048_576, 'netout' => 1_048_576],
['time' => 1754035260, 'cpu' => 0.37, 'memused' => 63, 'memtotal' => 100, 'netin' => 3_145_728, 'netout' => 5_242_880],
];
$host = Host::factory()->active()->create(['node' => 'pve-fns-1']);
$host = Host::factory()->active()->create(['node' => 'pve-fns-1', 'api_token_ref' => 'ref']);
// Der Sammler läuft im provisioning-Container, die Seite liest nur. Genau
// diese Reihenfolge, sonst prüft der Test einen Weg, den es nicht gibt.
app(HostLoadSeries::class)->collect($host);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertSee(__('hosts.detail.cpu_load'))
->assertSee(__('hosts.detail.ram_load'))
->assertSee(__('hosts.detail.net_in'))
->assertSee(__('hosts.detail.net_out'))
// Der jüngste Messwert je Kachel, nicht die Ausstattung.
->assertSee('50')
->assertSee('25');
// Der jüngste Messwert je Kachel, jeder eine Zahl, die sonst nirgends
// auf dieser Seite vorkommt.
->assertSee('37') // CPU
->assertSee('63') // RAM
->assertSee('3,00') // eingehend, MiB/s
->assertSee('5,00') // ausgehend
->assertDontSee(__('hosts.detail.no_load_short'));
});
it('shows nothing until the collector has run, because the page cannot fetch', function () {
// Der Fehler auf echter Hardware: der `app`-Container hängt nicht im
// WireGuard-Tunnel. Eine Seite, die selbst holt, zeigt dort für immer
// Leerstellen — und behauptet damit etwas über den Host, das nur über den
// Weg dorthin stimmt.
$s = fakeServices();
$s['pve']->rrd = [['time' => 1754035200, 'cpu' => 0.25, 'memused' => 1, 'memtotal' => 2]];
$host = Host::factory()->active()->create(['api_token_ref' => 'ref']);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertSee(__('hosts.detail.no_load_short'));
});
it('shows the public ip on the page, not only in the small print', function () {

View File

@ -0,0 +1,86 @@
<?php
use App\Models\Host;
use App\Provisioning\Jobs\CollectHostLoad;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\HostLoadSeries;
use App\Services\Proxmox\ProxmoxClient;
use Illuminate\Contracts\Queue\ShouldBeUnique;
/**
* Der Sammler holt die Stundenkurve dort, wo der Tunnel ist.
*
* Er existiert genau deshalb: nur der `provisioning`-Container hängt im
* WireGuard-Netz. Der `app`-Container, der die Konsole rendert, erreicht die
* Management-Adresse eines Hosts gar nicht auf echter Hardware blieben
* deshalb alle Kacheln leer, während der Host sichtbar online war.
*/
it('runs on the queue that can actually reach a host', function () {
// Kein Detail: dieselbe Warteschlange wie PingHosts, aus demselben Grund.
expect((new CollectHostLoad)->queue)->toBe('provisioning');
});
it('leaves a series behind for every host worth asking', function () {
$s = fakeServices();
$s['pve']->rrd = [['time' => 1754035200, 'cpu' => 0.5, 'memused' => 1, 'memtotal' => 2]];
$host = Host::factory()->active()->create(['api_token_ref' => 'ref']);
(new CollectHostLoad)->handle(app(HostLoadSeries::class));
expect(app(HostLoadSeries::class)->forHost($host)['available'])->toBeTrue();
});
it('skips a host that has no token to ask with', function () {
// Ein Host mitten in der Übernahme hat noch keinen — ihn zu fragen wäre ein
// sicherer Fehlschlag je Minute, und eine Zeile im Protokoll dazu.
$s = fakeServices();
$s['pve']->rrd = [['time' => 1754035200, 'cpu' => 0.5, 'memused' => 1, 'memtotal' => 2]];
$host = Host::factory()->create(['status' => 'onboarding', 'api_token_ref' => null]);
(new CollectHostLoad)->handle(app(HostLoadSeries::class));
expect(app(HostLoadSeries::class)->forHost($host)['available'])->toBeFalse();
});
it('keeps collecting after one host refuses to answer', function () {
// Eine Flotte darf nicht daran hängen, dass der erste Host schweigt.
$s = fakeServices();
$s['pve']->rrd = [['time' => 1754035200, 'cpu' => 0.5, 'memused' => 1, 'memtotal' => 2]];
$silent = Host::factory()->active()->create(['api_token_ref' => 'ref-1', 'name' => 'aaa-still']);
$healthy = Host::factory()->active()->create(['api_token_ref' => 'ref-2', 'name' => 'zzz-antwortet']);
// Der Fake wirft für den ersten Host und antwortet für den zweiten.
app()->instance(ProxmoxClient::class, new class($silent->id) extends FakeProxmoxClient
{
public function __construct(private int $silentId)
{
$this->rrd = [['time' => 1754035200, 'cpu' => 0.5, 'memused' => 1, 'memtotal' => 2]];
}
public function nodeRrdData(string $node, string $timeframe = 'hour'): array
{
if ($this->host?->id === $this->silentId) {
throw new RuntimeException('proxmox unreachable');
}
return $this->rrd;
}
});
(new CollectHostLoad)->handle(app(HostLoadSeries::class));
expect(app(HostLoadSeries::class)->forHost($silent)['available'])->toBeFalse()
->and(app(HostLoadSeries::class)->forHost($healthy)['available'])->toBeTrue();
});
it('never lets a second collector queue up behind a slow one', function () {
// Die provisioning-Warteschlange ist DIESELBE, auf der Kunden-Bereitstellung
// läuft. Ein Host, der in die Zeitüberschreitung läuft, kostet 15 Sekunden;
// bei genug Hosts ist der minütliche Lauf noch nicht fertig, wenn der
// nächste kommt — und der Rückstau verzögert dann bezahlte Arbeit.
// Dasselbe Mittel wie bei CollectInstanceTraffic nebenan.
expect(new CollectHostLoad)->toBeInstanceOf(ShouldBeUnique::class)
// Und nur ein Versuch: eine verpasste Minute wird von der nächsten
// Minute geholt, nicht von einer Wiederholung derselben.
->and((new CollectHostLoad)->tries)->toBe(1);
});

View File

@ -16,6 +16,7 @@ use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
it('scripts remote command output and records calls (FakeRemoteShell)', function () {
$shell = new FakeRemoteShell;
@ -169,6 +170,14 @@ it('reports an ordinary virtual machine carrying the vmid as no template', funct
// ohnehin auf; hier wird daraus nur gerechnet — und die eine Regel eingehalten,
// die eine Messreihe ehrlich hält: eine Lücke ist keine Null.
/** Holen (dort, wo der Tunnel ist) und lesen (dort, wo die Seite ist). */
function collectAndRead(Host $host): array
{
app(HostLoadSeries::class)->collect($host);
return app(HostLoadSeries::class)->forHost($host);
}
function rrdRow(int $time, ?float $cpu, ?int $memused, ?int $memtotal): array
{
return array_filter([
@ -185,7 +194,7 @@ it('turns proxmox rrd rows into cpu and ram percentages', function () {
rrdRow(1754035260, 0.5, 34_359_738_368, 68_719_476_736), // 50 % CPU, 50 % RAM
]])]);
$series = app(HostLoadSeries::class)->forHost(proxmoxHost());
$series = collectAndRead(proxmoxHost());
expect($series['cpu'])->toBe([1.25, 50.0])
->and($series['ram'])->toBe([12.5, 50.0])
@ -199,7 +208,7 @@ it('turns the recorded network rates into mebibytes per second', function () {
['time' => 1754035200, 'netin' => 2_097_152, 'netout' => 524_288],
]])]);
$series = app(HostLoadSeries::class)->forHost(proxmoxHost());
$series = collectAndRead(proxmoxHost());
expect($series['netin'])->toBe([2.0])
->and($series['netout'])->toBe([0.5]);
@ -211,7 +220,7 @@ it('leaves a missing network sample as a gap too', function () {
['time' => 1754035260], // Proxmox lässt die Felder weg
]])]);
$series = app(HostLoadSeries::class)->forHost(proxmoxHost());
$series = collectAndRead(proxmoxHost());
expect($series['netin'])->toBe([1.0, null])
->and($series['netout'])->toBe([1.0, null]);
@ -226,7 +235,7 @@ it('leaves a missing sample as a gap, never as zero', function () {
rrdRow(1754035260, null, null, null), // Proxmox lässt die Felder weg
]])]);
$series = app(HostLoadSeries::class)->forHost(proxmoxHost());
$series = collectAndRead(proxmoxHost());
expect($series['cpu'])->toBe([25.0, null])
->and($series['ram'])->toBe([50.0, null]);
@ -237,7 +246,7 @@ it('reports no series at all when the host does not answer', function () {
// sagen dürfen statt eine ruhige Stunde zu zeichnen.
Http::fake(['*' => Http::response('gateway timeout', 502)]);
$series = app(HostLoadSeries::class)->forHost(proxmoxHost());
$series = collectAndRead(proxmoxHost());
expect($series['cpu'])->toBe([])
->and($series['ram'])->toBe([])
@ -254,22 +263,51 @@ it('ignores a cached series written by an older version of itself', function ()
Cache::put('host-load:'.$host->id, ['labels' => [1], 'cpu' => [1.0], 'ram' => [2.0], 'available' => true], 600);
Http::fake(['*/rrddata*' => Http::response(['data' => [rrdRow(1754035200, 0.1, 1_048_576, 2_097_152)]])]);
$series = app(HostLoadSeries::class)->forHost($host);
$series = collectAndRead($host);
expect($series)->toHaveKeys(['netin', 'netout'])
->and($series['cpu'])->toBe([10.0]); // frisch geholt, nicht der alte Eintrag
});
it('does not ask the host twice while the samples are still fresh', function () {
// Die RRD wird einmal pro Minute fortgeschrieben. Zwei offene Konsolen
// dürfen daraus nicht zwei Anfragen über den Tunnel machen.
Http::fake(['*/rrddata*' => Http::response(['data' => [rrdRow(1754035200, 0.1, 1, 2)]])]);
it('never reaches for the host when the page asks', function () {
// DER Fehler, der die Kacheln auf echter Hardware leer ließ: nur der
// provisioning-Container hängt im WireGuard-Tunnel (NET_ADMIN, /dev/net/tun).
// Der `app`-Container, der die Seite rendert, erreicht 10.66.0.x gar nicht —
// ein API-Aufruf aus render() konnte deshalb nie etwas anderes als eine
// Zeitüberschreitung sein. Gelesen wird ab jetzt ausschließlich, was der
// Sammler hinterlegt hat.
Http::fake();
$series = app(HostLoadSeries::class)->forHost(proxmoxHost());
Http::assertNothingSent();
expect($series['available'])->toBeFalse();
});
it('serves the page from what the collector left behind', function () {
Http::fake(['*/rrddata*' => Http::response(['data' => [rrdRow(1754035200, 0.4, 1_048_576, 2_097_152)]])]);
$host = proxmoxHost();
app(HostLoadSeries::class)->forHost($host);
app(HostLoadSeries::class)->forHost($host);
app(HostLoadSeries::class)->collect($host); // im Tunnel
Http::fake(); // ab hier darf nichts mehr raus
$series = app(HostLoadSeries::class)->forHost($host);
Http::assertSentCount(1);
Http::assertNothingSent();
expect($series['cpu'])->toBe([40.0])
->and($series['available'])->toBeTrue();
});
it('leaves no measurements behind when the host cannot be reached', function () {
// Und schreibt den Grund ins Protokoll, statt ihn zu verschlucken: eine
// leere Kachel sagt nicht, ob der Host schweigt oder der Weg fehlt.
Log::spy();
Http::fake(['*' => Http::response('gateway timeout', 502)]);
$host = proxmoxHost();
app(HostLoadSeries::class)->collect($host);
expect(app(HostLoadSeries::class)->forHost($host)['available'])->toBeFalse();
Log::shouldHaveReceived('warning')->once();
});
// --- PveVersion -------------------------------------------------------------