Git-Deploy per Tag, Betriebsüberwachung und Release-Seeder für den Livegang

- dev/betrieb/deploy.sh: Deploy nur per Tag, mit --seed (Seeder im Wartungsmodus)
  und --initial (Vorschau/Live einmalig an Ort und Stelle auf Git umstellen)
- dev/betrieb/vorschau-auffrischen.sh: Live-DB und Live-Medien in die Vorschau (Generalprobe)
- InteriorReleaseSeeder: alle Inhaltsschritte des Relaunchs in einem Lauf, idempotent;
  Displays, Playlists und Mediathek bleiben unberührt
- DisplayInteriorBrandingSeeder überschreibt keinen inzwischen gepflegten Claim mehr
- Monitoring: GlitchTip (Server + Browser), /health mit Token, Scheduler-Herzschlag,
  Alarme per Mail und ntfy, Version in about und /health
- public/build versioniert; .gitignore: macOS-Muster Icon traf resources/views/flux/icon/
- Runbook: dev/betrieb/README.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-09 08:56:23 +00:00
parent fcb10be120
commit b5ed756d67
98 changed files with 3947 additions and 82 deletions

View file

@ -0,0 +1,159 @@
<?php
namespace App\Support\Monitoring;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Sentry\Event;
use Sentry\EventHint;
use Sentry\UserDataBag;
use Throwable;
/**
* Letzte Station vor GlitchTip (`before_send` in config/sentry.php).
*
* Personenbezug nur über die interne User-ID. Tokens und Signaturen stecken
* in Query-Strings (signierte Links) und in Pfaden
* (`/reset-password/{token}`, Magic Links) — beides wird ersetzt, bevor das
* Ereignis den Server verlässt. E-Mail-Adressen in Fehlermeldungen (z. B.
* QueryException mit eingesetzten Bindings) ebenso.
*/
class ErrorReportScrubber
{
public const Filtered = '[Filtered]';
/**
* Query-Parameter, die unverändert mitgehen dürfen (`utm_*` immer).
* Je Projekt anpassen — dieselbe Liste steht in error-reporting.js.
*
* @var list<string>
*/
public const AllowedQueryParameters = ['page', 'q', 'tab', 'sort'];
private const EmailPattern = '/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i';
/** Pfadsegmente, die wie ein Token aussehen (Magic Links, Reset-Links). */
private const TokenSegmentPattern = '#(?<=/)[A-Za-z0-9_\-]{32,}(?=/|$)#';
public static function beforeSend(Event $event, ?EventHint $hint = null): ?Event
{
$userId = self::authenticatedUserId();
$event->setUser($userId !== null ? UserDataBag::createFromUserIdentifier($userId) : null);
$event->setRequest(self::scrubRequest($event->getRequest()));
foreach ($event->getExceptions() as $exception) {
$exception->setValue(self::scrubText($exception->getValue()));
}
if ($event->getMessage() !== null) {
$formatted = $event->getMessageFormatted();
$event->setMessage(
self::scrubText($event->getMessage()),
$event->getMessageParams(),
$formatted !== null ? self::scrubText($formatted) : null,
);
}
return $event;
}
/**
* @param array<string, mixed> $request
* @return array<string, mixed>
*/
public static function scrubRequest(array $request): array
{
if (isset($request['url']) && is_string($request['url'])) {
$request['url'] = self::scrubUrl($request['url']);
}
if (isset($request['query_string']) && is_string($request['query_string'])) {
$request['query_string'] = self::scrubQuery($request['query_string']);
}
unset($request['data'], $request['cookies']);
foreach ($request['headers'] ?? [] as $name => $values) {
if (strtolower((string) $name) === 'referer') {
$request['headers'][$name] = array_map(
static fn (mixed $value): string => self::scrubUrl((string) $value),
(array) $values,
);
}
}
return $request;
}
public static function scrubUrl(string $url): string
{
$query = parse_url($url, PHP_URL_QUERY);
$withoutQuery = $query !== null && $query !== false ? Str::before($url, '?') : $url;
$scrubbedPath = self::scrubPath(self::scrubPathTokens($withoutQuery));
return $query !== null && $query !== false && $query !== ''
? $scrubbedPath.'?'.self::scrubQuery($query)
: $scrubbedPath;
}
public static function scrubQuery(string $query): string
{
parse_str($query, $parameters);
$scrubbed = [];
foreach ($parameters as $name => $value) {
$isAllowed = in_array((string) $name, self::AllowedQueryParameters, true)
|| str_starts_with((string) $name, 'utm_');
$scrubbed[$name] = $isAllowed && is_string($value) ? $value : self::Filtered;
}
return http_build_query($scrubbed, '', '&', PHP_QUERY_RFC3986);
}
public static function scrubPath(string $path): string
{
return self::scrubText((string) preg_replace(self::TokenSegmentPattern, self::Filtered, $path));
}
public static function scrubText(string $text): string
{
return (string) preg_replace(self::EmailPattern, '[email]', $text);
}
/**
* Ersetzt Routenparameter, deren Name auf ein Geheimnis hindeutet
* (`token`, `hash`, `signature`), im Pfad der aktuellen Route.
*/
private static function scrubPathTokens(string $url): string
{
try {
$route = request()->route();
} catch (Throwable) {
return $url;
}
if ($route === null) {
return $url;
}
foreach ($route->parameters() as $name => $value) {
if (is_string($value) && $value !== '' && Str::contains(strtolower((string) $name), ['token', 'hash', 'signature'])) {
$url = str_replace(rawurlencode($value), self::Filtered, str_replace($value, self::Filtered, $url));
}
}
return $url;
}
private static function authenticatedUserId(): int|string|null
{
try {
return Auth::id();
} catch (Throwable) {
return null;
}
}
}

View file

@ -0,0 +1,68 @@
<?php
namespace App\Support\Monitoring;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Sentry\Severity;
use Sentry\State\Scope;
use function Sentry\captureMessage;
use function Sentry\withScope;
/**
* Fehler ohne Exception als GlitchTip-Warnung: tote
* interne Links, verweigerte Zugriffe, abgelaufene Seiten. Gruppiert wird je
* Signal und Ort; je Gruppe gehen höchstens fünf Meldungen in zehn Minuten
* raus, damit ein Crawler mit gefälschtem Referrer GlitchTip nicht flutet.
*/
class ErrorSignal
{
public const DeadInternalLink = 'dead_internal_link';
public const AccessDenied = 'access_denied';
public const PageExpired = 'page_expired';
public const Throttled = 'throttled';
private const Labels = [
self::DeadInternalLink => 'Toter interner Link',
self::AccessDenied => 'Zugriff verweigert (angemeldet)',
self::PageExpired => 'Seite abgelaufen (419)',
self::Throttled => 'Drosselung trifft angemeldeten Nutzer',
];
private const MaxReportsPerWindow = 5;
private const WindowSeconds = 600;
/**
* @param array<string, mixed> $context
*/
public function report(string $signal, string $location, array $context = []): bool
{
$location = Str::limit($location, 200, '');
$reported = RateLimiter::attempt(
'error-signal:'.sha1($signal.'|'.$location),
self::MaxReportsPerWindow,
static fn (): bool => true,
self::WindowSeconds,
);
if ($reported === false) {
return false;
}
withScope(function (Scope $scope) use ($signal, $location, $context): void {
$scope->setFingerprint(['error-signal', $signal, $location]);
$scope->setTag('signal', $signal);
$scope->setContext('signal', $context);
captureMessage((self::Labels[$signal] ?? $signal).': '.$location, Severity::warning());
});
return true;
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Support\Monitoring;
/**
* Versionskennung des ausgelieferten Stands — `config('app.version')` und
* Release in GlitchTip. Vorrang hat der konfigurierte Wert (APP_VERSION bzw.
* SENTRY_RELEASE); sonst die Datei `RELEASE` im Projektwurzelverzeichnis, die
* beim Deploy entsteht (`git describe --tags --always --dirty > RELEASE`) —
* der Live-Server hat kein Git, solange per SFTP deployt wird.
*
* Versionen sind Git-Tags nach SemVer: Feature-Deploy erhöht die mittlere,
* Hotfix die letzte Stelle. Auf dem Tag ergibt das „v1.1.0“, mit späteren
* Commits „v1.1.0-2-gabc1234“, mit uncommitteten Änderungen „…-dirty“.
*/
class Release
{
public static function current(?string $configured, ?string $basePath = null): ?string
{
$configured = trim((string) $configured);
if ($configured !== '') {
return $configured;
}
$file = rtrim($basePath ?? base_path(), '/').'/RELEASE';
if (! is_file($file)) {
return null;
}
$release = trim((string) file_get_contents($file));
return preg_match('/^[A-Za-z0-9._\-]{1,64}$/', $release) === 1 ? $release : null;
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Throwable;
/**
* Zentrale Betriebs-Alerts: meldet fehlgeschlagene Scheduler-Tasks und
* Queue-Jobs (Verdrahtung: siehe 03-betriebsueberwachung.md). Der Log-Eintrag (`critical`) ist die verlässliche Ebene und läuft
* immer; Mail und ntfy-Push sind Best-Effort, unabhängig
* voneinander, und dürfen den auslösenden Prozess nie mit in den Abgrund
* reißen (Mailserver down, ntfy nicht erreichbar o. ä.).
*/
class OperationalAlert
{
/**
* @param array<string, mixed> $context
*/
public static function send(string $summary, array $context = []): void
{
Log::channel('monitoring')->critical('[monitoring] '.$summary, $context);
if (! config('monitoring.alerts.enabled')) {
return;
}
self::mail($summary, $context);
self::push($summary, $context);
}
/**
* @param array<string, mixed> $context
*/
private static function mail(string $summary, array $context): void
{
$recipient = config('monitoring.alerts.mail_to');
if (empty($recipient)) {
return;
}
try {
Mail::raw(
$summary."\n\n".json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
fn ($message) => $message->to($recipient)->subject('['.config('app.name').'] Betriebs-Alert: '.Str::limit($summary, 80)),
);
} catch (Throwable $e) {
Log::channel('monitoring')->error('[monitoring] Alert-Mail konnte nicht versendet werden.', [
'summary' => $summary,
'error' => $e->getMessage(),
]);
}
}
/**
* JSON-Publish an die ntfy-Basis-URL (UTF-8-sicher, anders als Header).
*
* @param array<string, mixed> $context
*/
private static function push(string $summary, array $context): void
{
$url = (string) config('monitoring.alerts.ntfy.url');
$topic = (string) config('monitoring.alerts.ntfy.topic');
if ($url === '' || $topic === '') {
return;
}
$details = collect($context)
->map(fn (mixed $value, string $key): string => $key.': '.(is_scalar($value) || $value === null ? (string) $value : json_encode($value)))
->implode("\n");
try {
Http::timeout(5)
->connectTimeout(3)
->when(filled(config('monitoring.alerts.ntfy.token')), fn ($request) => $request->withToken((string) config('monitoring.alerts.ntfy.token')))
->post(rtrim($url, '/'), [
'topic' => $topic,
'title' => config('app.name').' ('.app()->environment().'): Betriebs-Alert',
'message' => Str::limit(trim($summary."\n\n".$details), 3500),
'priority' => (int) config('monitoring.alerts.ntfy.priority'),
'tags' => ['rotating_light'],
])
->throw();
} catch (Throwable $e) {
Log::channel('monitoring')->error('[monitoring] ntfy-Push konnte nicht versendet werden.', [
'summary' => $summary,
'error' => $e->getMessage(),
]);
}
}
}