Let a failed measurement beat a green badge, and say which zone was missing
Three findings from the live readiness page, and the worst of them is the one
that looks like nothing.
Green "Erfüllt" sat directly above red "nicht in Ordnung", in the same row,
twice — for the DNS token and for the VM template. The badge came from
`satisfied` alone, the passive check that only establishes something IS
configured, and the measurement was rendered beside it without being allowed to
overrule it. Somebody scanning that list reads the badge, not the small print,
and walks away with "all green" while a measurement said it does not work. R19
names this exact shape — a call that reads as an assurance and is not one — as
worse than no check at all. The measurement wins now, for the badge, the icon
and the reason line.
zone_not_found was a dead end. It reads like "the token is wrong", so the
operator replaces the token — but a wrong token never gets that far: it comes
back as `rejected` from the 401 above. The token had just successfully listed
the zones. What is missing is the ZONE. The check now returns the zones it did
see, and the page puts them next to the one it wanted: looked for
clupilot.cloud, this account holds clupilot.com. The question answers itself.
An empty list says something else again, and gets its own sentence: the token
belongs to a different Hetzner project.
And two traffic tests were failing on main, unrelated to any of this, which is
why they were checked against a clean checkout before being touched. They build
"last month" as now()->subMonth()->format('Y-m'), and Carbon resolves that
calendrically: on 31 July it lands on 1 July, so the row meant to be last
period lands in the current one. Red on the 29th, 30th and 31st of every long
month, and today is the 31st. The production code does not have the trap —
currentPeriod() is now()->format('Y-m') with no arithmetic, and the two places
that do compute months already guard it — so this is the tests, and only the
tests.
2045 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/host-bootstrap
parent
d3f2533565
commit
0543a5a542
|
|
@ -47,10 +47,26 @@ final class DnsTokenCheck
|
|||
return ['ok' => false, 'reason' => 'rejected'];
|
||||
}
|
||||
|
||||
$verfuegbar = collect($zones->json('zones') ?? [])->pluck('name')->filter()->values();
|
||||
$zoneId = collect($zones->json('zones') ?? [])->firstWhere('name', $zone)['id'] ?? null;
|
||||
|
||||
if ($zoneId === null) {
|
||||
return ['ok' => false, 'reason' => 'zone_not_found', 'zone' => $zone];
|
||||
// Die gefundenen Zonen gehören in die Antwort.
|
||||
//
|
||||
// `zone_not_found` allein ist eine Sackgasse: der Betreiber liest
|
||||
// es als „der Token ist falsch" und tauscht ihn aus, obwohl der
|
||||
// Token gerade eben erfolgreich die Zonenliste geholt hat — ein
|
||||
// schlechter Token käme oben als `rejected` heraus. Was fehlt, ist
|
||||
// die ZONE. Nebeneinander gestellt beantwortet sich die Frage von
|
||||
// selbst: gesucht wurde `clupilot.cloud`, im Konto liegt
|
||||
// `clupilot.com` — oder gar nichts, dann gehört die Zone dort
|
||||
// erst angelegt.
|
||||
return [
|
||||
'ok' => false,
|
||||
'reason' => 'zone_not_found',
|
||||
'zone' => $zone,
|
||||
'available' => $verfuegbar->all(),
|
||||
];
|
||||
}
|
||||
|
||||
// The actual point of this whole check. A read-only token gets this
|
||||
|
|
|
|||
|
|
@ -114,4 +114,8 @@ return [
|
|||
'queue_provisioning' => 'Bereitstellungs-Worker läuft',
|
||||
'queue_provisioning_breaks' => 'Der Zeitplaner kann laufen und Aufträge auf die provisioning-Warteschlange stellen, ohne dass sie je jemand abholt. Eine bezahlte Bestellung bleibt dann liegen — kein Fehler, keine Meldung, einfach nichts, bis jemand von Hand nachsieht.',
|
||||
],
|
||||
|
||||
'zone_wanted' => 'Der Token ist gültig — er hat die Zonenliste geholt. Gesucht wurde die Zone :zone.',
|
||||
'zone_available' => 'In diesem Konto liegen: :zones. Entweder CLUPILOT_DNS_ZONE anpassen oder die Zone bei Hetzner anlegen.',
|
||||
'zone_none' => 'In diesem Konto liegt keine einzige Zone. Der Token gehört vermutlich zu einem anderen Hetzner-Projekt.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -109,4 +109,8 @@ return [
|
|||
'queue_provisioning' => 'Provisioning worker is running',
|
||||
'queue_provisioning_breaks' => 'The scheduler can be running and putting jobs on the provisioning queue without anyone ever picking them up. A paid order then just sits there — no error, no notice, simply nothing, until someone happens to look.',
|
||||
],
|
||||
|
||||
'zone_wanted' => 'The token is valid — it fetched the zone list. It looked for the zone :zone.',
|
||||
'zone_available' => 'This account holds: :zones. Either adjust CLUPILOT_DNS_ZONE or create the zone at Hetzner.',
|
||||
'zone_none' => 'This account holds no zones at all. The token probably belongs to a different Hetzner project.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -25,13 +25,30 @@
|
|||
<ul class="mt-3 divide-y divide-line">
|
||||
@foreach ($checks as $check)
|
||||
@php
|
||||
$badgeStatus = $check->satisfied ? 'active' : ($check->isBlocking() ? 'failed' : 'warning');
|
||||
$statusLabel = $check->satisfied
|
||||
$result = $results[$check->key] ?? null;
|
||||
|
||||
// DIE MESSUNG GEWINNT.
|
||||
//
|
||||
// Der Haken kam bisher allein aus `satisfied` — der
|
||||
// passiven Prüfung, die nur feststellt, dass etwas
|
||||
// eingetragen IST. Lief daneben eine echte Messung und
|
||||
// scheiterte, stand grünes „Erfüllt" über rotem „nicht
|
||||
// in Ordnung", in derselben Zeile. Auf dem Live-Server
|
||||
// gleich zweimal: beim DNS-Token und bei der
|
||||
// VM-Vorlage.
|
||||
//
|
||||
// Das ist die Attrappe aus R19 in ihrer teuersten Form.
|
||||
// Wer die Liste überfliegt, liest die Plakette, nicht
|
||||
// die Kleinschrift — und geht mit „alles grün" weiter,
|
||||
// während gemessen wurde, dass es nicht geht.
|
||||
$erfuellt = $result !== null ? (bool) $result['ok'] : $check->satisfied;
|
||||
|
||||
$badgeStatus = $erfuellt ? 'active' : ($check->isBlocking() ? 'failed' : 'warning');
|
||||
$statusLabel = $erfuellt
|
||||
? __('readiness.status.satisfied')
|
||||
: ($check->isBlocking() ? __('readiness.status.blocking') : __('readiness.status.warning'));
|
||||
$iconName = $check->satisfied ? 'check' : ($check->isBlocking() ? 'x' : 'alert-triangle');
|
||||
$iconTone = $check->satisfied ? 'text-success' : ($check->isBlocking() ? 'text-danger' : 'text-warning');
|
||||
$result = $results[$check->key] ?? null;
|
||||
$iconName = $erfuellt ? 'check' : ($check->isBlocking() ? 'x' : 'alert-triangle');
|
||||
$iconTone = $erfuellt ? 'text-success' : ($check->isBlocking() ? 'text-danger' : 'text-warning');
|
||||
$heartbeat = $heartbeats[$check->key] ?? null;
|
||||
@endphp
|
||||
<li class="flex flex-wrap items-start gap-3 py-3">
|
||||
|
|
@ -43,7 +60,7 @@
|
|||
<x-ui.badge :status="$badgeStatus">{{ $statusLabel }}</x-ui.badge>
|
||||
</div>
|
||||
|
||||
@unless ($check->satisfied)
|
||||
@unless ($erfuellt)
|
||||
<p class="mt-1 text-sm text-muted">{{ $check->breaks }}</p>
|
||||
@endunless
|
||||
|
||||
|
|
@ -67,6 +84,22 @@
|
|||
im Testplatz. Die Prüfung selbst blockiert dafür jetzt
|
||||
(BillingChecks); hier steht, was der Knopf gerade gesehen
|
||||
hat, damit er ihr nicht widerspricht. --}}
|
||||
{{-- Bei `zone_not_found` steht der Betreiber sonst vor einer
|
||||
Sackgasse: er liest es als „Token falsch" und tauscht ihn
|
||||
aus, obwohl der Token gerade eben die Zonenliste geholt hat.
|
||||
Gesucht und gefunden nebeneinander beantwortet die Frage
|
||||
selbst. --}}
|
||||
@if (($result['reason'] ?? null) === 'zone_not_found')
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ __('readiness.zone_wanted', ['zone' => $result['zone'] ?? '—']) }}
|
||||
@if (! empty($result['available']))
|
||||
{{ __('readiness.zone_available', ['zones' => implode(', ', $result['available'])]) }}
|
||||
@else
|
||||
{{ __('readiness.zone_none') }}
|
||||
@endif
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if (array_key_exists('live', $result))
|
||||
@php $keyMode = $result['live'] ? 'live' : 'test'; @endphp
|
||||
<p class="mt-1 text-xs {{ $keyMode === $mode->value ? 'text-muted' : 'text-danger' }}">
|
||||
|
|
|
|||
|
|
@ -386,3 +386,45 @@ it('is satisfied when no plan version is published yet', function () {
|
|||
expect($result['ok'])->toBeTrue()
|
||||
->and($result['reason'])->toBe('nothing_published');
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Fund vom Live-Server: grünes „Erfüllt" über rotem „nicht in Ordnung",
|
||||
* in derselben Zeile — beim DNS-Token und bei der VM-Vorlage.
|
||||
*
|
||||
* Die Plakette kam allein aus der passiven Prüfung („etwas ist eingetragen"),
|
||||
* die Messung stand daneben und wurde überstimmt. Wer eine Liste überfliegt,
|
||||
* liest die Plakette und nicht die Kleinschrift, und geht mit „alles grün"
|
||||
* weiter, obwohl gemessen wurde, dass es nicht geht. Das ist die Attrappe aus
|
||||
* R19 in ihrer teuersten Form.
|
||||
*/
|
||||
it('lets a failed measurement beat a green badge', function () {
|
||||
$operator = App\Models\Operator::factory()->role('Owner')->create();
|
||||
|
||||
$seite = Livewire::actingAs($operator, 'operator')->test(App\Livewire\Admin\Readiness::class);
|
||||
|
||||
// Ohne Messung entscheidet die passive Prüfung — wie bisher.
|
||||
$seite->assertOk();
|
||||
|
||||
// Mit einer gescheiterten Messung darf in dieser Zeile kein „Erfüllt"
|
||||
// mehr stehen.
|
||||
$seite->set('results', ['provisioning.dns_token' => ['ok' => false, 'reason' => 'zone_not_found', 'zone' => 'clupilot.cloud', 'available' => ['clupilot.com']]])
|
||||
->assertSee('clupilot.cloud')
|
||||
->assertSee('clupilot.com')
|
||||
->assertSee(__('readiness.check_failed'));
|
||||
});
|
||||
|
||||
/**
|
||||
* `zone_not_found` allein ist eine Sackgasse: es liest sich wie „Token
|
||||
* falsch", obwohl der Token gerade eben die Zonenliste geholt hat — ein
|
||||
* schlechter käme als `rejected` heraus. Was fehlt, ist die ZONE, und das
|
||||
* sagt erst der Vergleich.
|
||||
*/
|
||||
it('names the zone it wanted and the zones it found', function () {
|
||||
$ergebnis = ['ok' => false, 'reason' => 'zone_not_found', 'zone' => 'clupilot.cloud', 'available' => ['clupilot.com', 'example.org']];
|
||||
|
||||
Livewire::actingAs(App\Models\Operator::factory()->role('Owner')->create(), 'operator')
|
||||
->test(App\Livewire\Admin\Readiness::class)
|
||||
->set('results', ['provisioning.dns_token' => $ergebnis])
|
||||
->assertSee(__('readiness.zone_wanted', ['zone' => 'clupilot.cloud']))
|
||||
->assertSee('clupilot.com, example.org');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -193,13 +193,26 @@ it('shows allowances in the units they are sold in', function () {
|
|||
->and(App\Support\Bytes::human(512))->toBe('512 B');
|
||||
});
|
||||
|
||||
/*
|
||||
* `startOfMonth()` VOR `subMonth()`, und das ist keine Kosmetik.
|
||||
*
|
||||
* Carbon rechnet `subMonth()` kalendarisch: am 31. Juli ergibt das den 1. Juli,
|
||||
* weil der 31. Juni überläuft. `format('Y-m')` liefert dann DIESEN Monat statt
|
||||
* des Vormonats — die Zeile, die „letzte Periode" heißen soll, landet in der
|
||||
* aktuellen, und der Test scheitert. An drei Tagen im Monat, nachts entdeckt.
|
||||
*
|
||||
* Der Produktivcode hat die Falle nicht: `InstanceTraffic::currentPeriod()` ist
|
||||
* `now()->format('Y-m')` ohne Arithmetik, und wo gerechnet wird (Overview,
|
||||
* ConfirmCancelPackage) steht `startOfMonth()` bzw. eine Notiz dazu. Es waren
|
||||
* nur diese Tests.
|
||||
*/
|
||||
it('lets a throttled instance run again when the new month starts', function () {
|
||||
[$pve, , $instance] = trafficSetup();
|
||||
|
||||
// Last month ended with the allowance exhausted and the NIC limited.
|
||||
InstanceTraffic::create([
|
||||
'instance_id' => $instance->id,
|
||||
'period' => now()->subMonth()->format('Y-m'),
|
||||
'period' => now()->startOfMonth()->subMonth()->format('Y-m'),
|
||||
'tx_bytes' => 9_999_999_999_999,
|
||||
'throttled' => true,
|
||||
'throttled_at' => now()->subDays(3),
|
||||
|
|
@ -220,7 +233,7 @@ it('keeps trying to release a throttle when Proxmox refuses once', function () {
|
|||
|
||||
InstanceTraffic::create([
|
||||
'instance_id' => $instance->id,
|
||||
'period' => now()->subMonth()->format('Y-m'),
|
||||
'period' => now()->startOfMonth()->subMonth()->format('Y-m'),
|
||||
'tx_bytes' => 9_999_999_999_999,
|
||||
'throttled' => true,
|
||||
]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue