CluPilotCloud/app/Support/Readiness/DeliveryChecks.php

171 lines
8.3 KiB
PHP

<?php
namespace App\Support\Readiness;
use App\Mail\Transport\MailboxTransport;
use App\Models\MailTemplate;
use App\Services\Mail\MailboxResolver;
use App\Services\Mail\MailLane;
use App\Services\Mail\MailPurpose;
use App\Services\Secrets\SecretVault;
use App\Support\MailDelivery;
use Illuminate\Support\Facades\Queue;
/**
* What has to be in place for the customer to actually find out any of this
* happened: a mail transport that leaves the server, an address to send
* from, and — for support — a way to answer and to hear back.
*/
final class DeliveryChecks
{
public const GROUP = 'delivery';
/**
* Länger als das her ist eine Spur, die nicht mehr fährt, sondern steht.
*
* Die langsamste Spur (mail-ruhig, Task 3) lässt standardmäßig 20
* Aufträge je 10 Minuten durch — 120 in der Stunde. Ein Lauf dieser
* Größe steht bei elf Kunden nicht eine ganze Stunde lang an. Schlägt die
* Prüfung trotzdem an, klemmt etwas: der Arbeiter läuft nicht mehr, oder
* die Spur ist aus der --queue-Liste in docker-compose.yml gefallen.
*/
private const STUCK_AFTER_MINUTES = 60;
/** @return array<int, Check> */
public static function all(): array
{
return [
new Check(
key: 'delivery.mailer_not_log',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.delivery.mailer_not_log'),
breaks: __('readiness.delivery.mailer_not_log_breaks'),
tab: 'mail',
// Reads the transport's own definition of "does not really
// send" rather than a second, narrower one. `!== 'log'` alone
// called 'array' — the MAIL_MAILER this whole suite runs
// under — delivered, which MailboxTransport::delegate() has
// never agreed with.
satisfied: MailDelivery::delivers(),
),
new Check(
key: 'delivery.mailbox',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.delivery.mailbox'),
breaks: __('readiness.delivery.mailbox_breaks'),
tab: 'mail',
// Nicht mehr `Mailbox::query()->exists()`. Dass IRGENDEIN
// Postfach angelegt ist, entscheidet nichts: gesendet wird
// ueber das Postfach, das dem Zweck `system` zugeordnet ist,
// und das muss aktiv sein und Zugangsdaten haben. Die alte
// Fassung stand gruen, waehrend jede Systemmail — Passwort
// vergessen, Adresse bestaetigen, neues Geraet — in der
// Warteschlange starb. `system` genuegt als Pruefgegenstand,
// weil MailboxResolver jeden anderen Zweck darauf zurueckfallen
// laesst.
satisfied: app(MailboxResolver::class)->usableFor(MailPurpose::SYSTEM) !== null,
),
new Check(
key: 'delivery.mail_templates',
group: self::GROUP,
severity: Check::SEVERITY_WARNING,
label: __('readiness.delivery.mail_templates'),
breaks: __('readiness.delivery.mail_templates_breaks'),
tab: 'templates',
satisfied: MailTemplate::query()->exists(),
),
new Check(
// Named after the vault entry it checks (inbound_mail.password),
// not a shorthand — see the identical reasoning on
// onboarding.ssh_private_key in OnboardingChecks.php.
key: 'delivery.inbound_mail_password',
group: self::GROUP,
severity: Check::SEVERITY_WARNING,
label: __('readiness.delivery.inbound_password'),
breaks: __('readiness.delivery.inbound_password_breaks'),
// The inbound-mail card (host/port/user/folder + this
// password) is on the 'services' tab — 'integrations' is not
// a member of Integrations::TABS at all (Fix-Runde, Befund 1).
tab: 'services',
satisfied: filled(app(SecretVault::class)->get('inbound_mail.password')),
),
new Check(
key: 'delivery.mail_lanes_moving',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.delivery.mail_lanes_moving'),
breaks: __('readiness.delivery.mail_lanes_moving_breaks'),
// Eigener Wert, keiner der bestehenden ('mail' zeigt auf
// admin/mail — Server, Postfächer, Zwecke; die Spuren liegen
// seit Task 5 auf der eigenen Seite admin/mail-pace). Der
// ReadinessPageTest hält jeden tab-Wert gegen die Stelle, an
// der sein Feld wirklich liegt — 'mail' wäre hier derselbe
// falsche Verweis, den jene Prüfung an neun anderen Stellen
// schon einmal gefangen hat.
tab: 'mail-pace',
// Eine Spur, die STEHT, sieht von außen aus wie eine, die
// LEER ist — `Queue::size()` (Task 5, admin/mail-pace) meldet
// in beiden Fällen dieselbe Art Zahl und unterscheidet sie
// nicht. Das Alter des ältesten wartenden Auftrags trennt die
// beiden: eine leere Spur hat keinen, eine verstopfte hat
// einen, der immer älter wird.
//
// `connection('redis')` ausdrücklich, nicht die
// Standardverbindung: dieselbe, mit der der Arbeiter in
// docker-compose.yml tatsächlich gestartet wird
// (`queue:work redis --queue=mail-direkt,mail-wichtig,
// mail-ruhig,default`) — unabhängig davon, worauf
// QUEUE_CONNECTION gerade zeigt. Unter der Testsuite zwingt
// phpunit.xml die STANDARDverbindung auf `sync`
// (SyncQueue::size() liefert dort immer 0, egal ob leer oder
// verstopft — siehe die Anmerkung auf admin/mail-pace); die
// NAMENTLICH angeforderte `redis`-Verbindung bleibt davon
// unberührt und bleibt echtes Redis, in beiden Umgebungen
// erreichbar (siehe tests/Feature/Admin/HostTerminalTest.php).
// `creationTimeOfOldestPendingJob()` ist Teil des
// Queue-Vertrags selbst (Illuminate\Contracts\Queue\Queue) —
// jeder Treiber implementiert es, `sync` eingeschlossen, und
// liefert dort ehrlich `null` statt einer erfundenen Zahl.
satisfied: self::laneMoving(MailLane::DIRECT)
&& self::laneMoving(MailLane::URGENT)
&& self::laneMoving(MailLane::CALM),
),
];
}
/**
* Ob der älteste wartende Auftrag einer Spur jünger als die Grenze ist.
*
* Zählt nur die unverzögerte Liste (`creationTimeOfOldestPendingJob`
* schließt `:delayed` ausdrücklich aus) — ein von der Drossel
* zurückgelegter Auftrag dort ist die Drossel bei der Arbeit, kein
* Klemmen. Was hier erkannt wird, ist der Fall, in dem niemand mehr
* abholt: der Arbeiter tot, oder die Spur aus seiner --queue-Liste
* gefallen — dann wächst die unverzögerte Liste, weil neue Mail dort
* ungedrosselt eintrifft (`RidesALane::queue()` reiht ohne Verzögerung
* ein) und nie wieder verschwindet.
*/
private static function laneMoving(string $lane): bool
{
try {
$oldest = Queue::connection('redis')->creationTimeOfOldestPendingJob($lane);
} catch (\Throwable) {
// Wie OperationChecks::isFresh(): eine Seite, deren einziger
// Zweck ist, ruhig zu melden, was fehlt, darf nicht selbst
// ausfallen, weil Redis gerade nicht erreichbar ist. Nicht
// erreichbar heißt nicht erfüllt — nicht "wirft".
return false;
}
if ($oldest === null) {
// Noch nie etwas eingereiht, oder gerade leer geräumt — beides
// ist gesund. Kein Auftrag heißt: nichts, das stehen könnte.
return true;
}
return $oldest > now()->subMinutes(self::STUCK_AFTER_MINUTES)->getTimestamp();
}
}