feat(setup): Erstinstallation ueber den Update-Agent, Installationskonto, Downloads
Bisher gab es nur den Update-Weg: eine Anwendung musste bereits installiert und eingerichtet sein, damit sich etwas aktualisieren liess. Die Erstinstallation auf einem neuen System war Handarbeit - Paket kopieren, Konfiguration abtippen, Token besorgen. Setup-API (neu) - POST /api/setup/v1/login tauscht Benutzername und Passwort gegen ein Token mit 30 Minuten Gueltigkeit und ausschliesslich setup:install. Es wird nicht mitgeschrieben und lebt im Installer nur im Speicher. - GET /api/setup/v1/catalog zeigt nur, was zur Laufzeitkennung des anfragenden Systems passt. Ein Projekt mit ausschliesslich Windows-Paket taucht auf einem Linux-Rechner gar nicht erst auf. - POST /api/setup/v1/token stellt das Dauertoken der Anwendung aus. Welche Rechte vergeben werden, entscheidet der Server; die Anfrage kann nur einschraenken. Sonst waere der Umweg ueber ein kurzlebiges Token wirkungslos. Rollentrennung (Migration 012) - dc_users bekommt role, disabled und last_login_at. Die Rolle "installer" darf sich ueber den Setup-Weg anmelden und nicht am WebUI. Die Zugangsdaten werden auf jedem Zielsystem eingetippt; mit einem Administratorkonto verteilte man damit den Zugang zu Tokens, Lizenzen und Monitoren auf jeden Rechner, auf dem je etwas installiert wurde. - Auth::verifyCredentials() prueft sessionfrei, damit Setup- und WebUI-Login nicht zwei verschiedene Haertungsgrade haben (Drosselung, Timing-Angleichung, Rehash gelten fuer beide). - Konten mit hinterlegtem TOTP-Geheimnis werden am Setup-Weg mit 501 abgewiesen. Eine TOTP-Pruefung gibt es im Deploymentcenter noch nicht; sie stillschweigend zu uebergehen waere ein Rueckschritt. - Benutzerverwaltung im WebUI - es gab bisher gar keine, nur den einen von install_db.php angelegten Admin. Das letzte aktive Administratorkonto laesst sich weder deaktivieren noch loeschen. Installer - update-agent --action install fuehrt durch Anmeldung, Auswahl, Zielverzeichnis, Installation und Einrichtung. Die Dateien kommen ueber denselben Pfad wie ein Update - mit Pruefsumme, Signatur, Staging und Rollback. Ein zweiter Download-Weg waere ein zweiter Ort fuer dieselben Fehler. - --action configure holt die Einrichtung nachtraeglich. - setup.json im Paket beschreibt die benoetigten Werte. Bewusst im Paket und nicht zentral: so ist sie mit der Anwendung versioniert. - Gefragt wird nur, was uebrig bleibt: bereits gesetzt -> detect:... -> provision -> fragen. Platzhalter wie changeme oder <dein-wert> gelten dabei nicht als eingerichtet, sonst liefe die Anwendung mit der Vorlage los. - SetupWriter erhaelt vorhandene Inhalte. Eine appsettings.json fuehrt neben den abgefragten Werten meist Logging und anderes; sie neu zu erzeugen waere bequemer und verloere das - bei einer Neuinstallation ohne Backup. int und bool landen als JSON-Typ, nicht als Zeichenkette. Downloads - scripts/build_installer.ps1 baut selbstenthaltende Einzeldateien fuer win-x64, linux-x64 und linux-arm64 (rund 34 MB, .NET-Laufzeit inbegriffen). Ohne NativeAOT und ohne Trimming: Spectre.Console loest ueber Reflexion auf und braeche sonst erst beim Anwender. - scripts/upload_installer.py laedt sie nach /installer/. Getrennt von deploy.py, das client-dotnet bewusst ausklammert. - Bereich "Installer" auf der UpdateService-Seite mit Groessen, Pruefsummen und den wget-Befehlen; die Angaben stammen aus installer.json statt aus fest eingetragenem Text. - install.sh und install.ps1 laden, pruefen die Pruefsumme und legen ab - sie richten bewusst nichts selbst ein. Das Manifest wird BOM-frei geschrieben, sonst scheitert json_decode() daran. Enthaelt ausserdem die bislang nicht committete Arbeit an den RocketChat-Benachrichtigungen (Migrationen 010 und 011) sowie die Loesch- und Editierfunktion des UpdateService; die betroffenen Dateien liessen sich nicht getrennt stagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2388b5abe1
commit
c8f3e78635
+82
-9
@@ -84,20 +84,26 @@ final class Auth
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft die Zugangsdaten und startet bei Erfolg eine frische Session.
|
||||
* Prueft Zugangsdaten, ohne eine Session anzufassen.
|
||||
*
|
||||
* Getrennt von login(), weil der Setup-Weg dieselbe Pruefung braucht,
|
||||
* aber ein kurzlebiges Token statt eines Session-Cookies ausstellt.
|
||||
* Drosselung, Timing-Angleichung und Rehash gelten dort genauso - sie
|
||||
* hier zu wiederholen hiesse, zwei Anmeldewege mit zwei Haertungsgraden
|
||||
* zu haben.
|
||||
*
|
||||
* @return array<string,mixed>|null Der Benutzerdatensatz, oder null
|
||||
*/
|
||||
public static function login(PDO $db, string $username, string $password): bool
|
||||
public static function verifyCredentials(PDO $db, string $username, string $password): ?array
|
||||
{
|
||||
self::startSession();
|
||||
|
||||
$ip = Http::clientIp();
|
||||
|
||||
if (self::isLockedOut($db, $ip)) {
|
||||
Logger::warning('Anmeldung gesperrt (zu viele Fehlversuche)', ['ip' => $ip, 'username' => $username]);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('SELECT id, username, password_hash FROM dc_users WHERE username = :u LIMIT 1');
|
||||
$stmt = $db->prepare('SELECT * FROM dc_users WHERE username = :u LIMIT 1');
|
||||
$stmt->execute([':u' => $username]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
@@ -110,7 +116,18 @@ final class Auth
|
||||
|
||||
if (!$verified || !$found) {
|
||||
self::recordAttempt($db, $ip, $username, false);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ein deaktiviertes Konto bleibt bestehen, damit Protokolle weiter
|
||||
// darauf verweisen koennen - anmelden darf es sich nicht.
|
||||
if ((int)($user['disabled'] ?? 0) === 1) {
|
||||
self::recordAttempt($db, $ip, $username, false);
|
||||
Logger::warning('Anmeldung eines deaktivierten Kontos abgelehnt', [
|
||||
'username' => $username,
|
||||
'ip' => $ip,
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Passwort-Hash bei Bedarf auf das aktuelle Verfahren heben.
|
||||
@@ -119,19 +136,75 @@ final class Auth
|
||||
$upd->execute([':h' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['id']]);
|
||||
}
|
||||
|
||||
self::recordAttempt($db, $ip, $username, true);
|
||||
self::touchLastLogin($db, (int)$user['id']);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolle eines Datensatzes aus dc_users.
|
||||
*
|
||||
* Faellt auf 'admin' zurueck, solange Migration 012 nicht gelaufen ist -
|
||||
* sonst waere nach dem Einspielen des Codes und vor der Migration niemand
|
||||
* mehr anmeldeberechtigt.
|
||||
*/
|
||||
public static function roleOf(?array $user): string
|
||||
{
|
||||
$role = is_array($user) ? (string)($user['role'] ?? 'admin') : 'admin';
|
||||
return $role === 'installer' ? 'installer' : 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft die Zugangsdaten und startet bei Erfolg eine frische Session.
|
||||
*/
|
||||
public static function login(PDO $db, string $username, string $password): bool
|
||||
{
|
||||
self::startSession();
|
||||
|
||||
$user = self::verifyCredentials($db, $username, $password);
|
||||
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ein Installationskonto hat in der Verwaltungsoberflaeche nichts zu
|
||||
// suchen. Waere die Anmeldung hier erlaubt, brauchte man das Konto
|
||||
// gar nicht zu trennen: wer es auf einem Zielsystem eingibt, haette
|
||||
// damit auch Zugriff auf Tokens, Lizenzen und Monitore.
|
||||
if (self::roleOf($user) === 'installer') {
|
||||
Logger::warning('WebUI-Anmeldung eines Installationskontos abgelehnt', [
|
||||
'username' => $username,
|
||||
'ip' => Http::clientIp(),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
session_regenerate_id(true);
|
||||
|
||||
$_SESSION['dc_user_id'] = (int)$user['id'];
|
||||
$_SESSION['dc_username'] = (string)$user['username'];
|
||||
$_SESSION['dc_role'] = self::roleOf($user);
|
||||
$_SESSION['dc_login_at'] = time();
|
||||
$_SESSION['dc_last_seen'] = time();
|
||||
|
||||
self::recordAttempt($db, $ip, $username, true);
|
||||
Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => $ip]);
|
||||
Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => Http::clientIp()]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function touchLastLogin(PDO $db, int $userId): void
|
||||
{
|
||||
try {
|
||||
$stmt = $db->prepare('UPDATE dc_users SET last_login_at = UTC_TIMESTAMP() WHERE id = :id');
|
||||
$stmt->execute([':id' => $userId]);
|
||||
} catch (\Throwable $e) {
|
||||
// Spalte fehlt (Migration 012 noch nicht gelaufen) - kein Grund,
|
||||
// die Anmeldung scheitern zu lassen.
|
||||
Logger::warning('last_login_at nicht gesetzt', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function logout(): void
|
||||
{
|
||||
self::startSession();
|
||||
|
||||
@@ -40,6 +40,11 @@ final class TokenManager
|
||||
'updateservice:read',
|
||||
'updateservice:publish',
|
||||
'tokens:provision',
|
||||
// Erstinstallation: Katalog lesen und eine Anwendung einrichten.
|
||||
// Diese Rechte traegt ausschliesslich das kurzlebige Token aus
|
||||
// /api/setup/v1/login - sie gehoeren nicht auf ein Dauertoken.
|
||||
'setup:catalog',
|
||||
'setup:install',
|
||||
];
|
||||
|
||||
private PDO $db;
|
||||
@@ -111,6 +116,82 @@ final class TokenManager
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt ein kurzlebiges Token ohne Elterntoken.
|
||||
*
|
||||
* Gedacht fuer die Erstinstallation: Der Installer meldet sich mit
|
||||
* Benutzername und Passwort an und bekommt dafuer ein Token, das nach
|
||||
* wenigen Minuten verfaellt. Bewusst kein Master-Token - es soll nichts
|
||||
* weitervererben koennen - und bewusst mit Ablauf, weil es auf einem
|
||||
* fremden Zielsystem im Speicher liegt.
|
||||
*
|
||||
* @param list<string> $scopes
|
||||
*/
|
||||
public function createEphemeralToken(
|
||||
string $name,
|
||||
array $scopes,
|
||||
int $ttlSeconds,
|
||||
?string $ownerIdentity = null
|
||||
): array {
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
throw new InvalidArgumentException('Token-Bezeichnung darf nicht leer sein.');
|
||||
}
|
||||
|
||||
$effectiveScopes = self::normalizeScopes($scopes, []);
|
||||
if ($effectiveScopes === []) {
|
||||
throw new InvalidArgumentException('Ein kurzlebiges Token ohne Rechte waere wirkungslos.');
|
||||
}
|
||||
|
||||
// Eine Obergrenze verhindert, dass aus einem Setup-Token durch einen
|
||||
// grosszuegigen Aufrufer ein Dauertoken wird.
|
||||
$ttlSeconds = max(60, min($ttlSeconds, 3600));
|
||||
|
||||
$tokenId = 'tok_s_' . bin2hex(random_bytes(8));
|
||||
$rawToken = 'dc_setup_' . bin2hex(random_bytes(24));
|
||||
$expires = gmdate('Y-m-d H:i:s', time() + $ttlSeconds);
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO dc_tokens (
|
||||
token_id, parent_token_id, token_hash, raw_token, name,
|
||||
project_slug, license_key, owner_type, owner_identity,
|
||||
type, scopes, environment, expires_at, created_at
|
||||
) VALUES (
|
||||
:id, NULL, :hash, NULL, :name,
|
||||
NULL, NULL, "host", :identity,
|
||||
"sub", :scopes, "all", :expires, UTC_TIMESTAMP()
|
||||
)
|
||||
');
|
||||
|
||||
// raw_token bleibt hier immer NULL, auch wenn die Konfiguration das
|
||||
// Mitschreiben erlaubt: ein Setup-Token wird einmal ausgeliefert und
|
||||
// muss nirgends nachschlagbar sein.
|
||||
$stmt->execute([
|
||||
':id' => $tokenId,
|
||||
':hash' => hash('sha256', $rawToken),
|
||||
':name' => $name,
|
||||
':identity' => self::nullIfEmpty($ownerIdentity),
|
||||
':scopes' => json_encode($effectiveScopes),
|
||||
':expires' => $expires,
|
||||
]);
|
||||
|
||||
Logger::info('Kurzlebiges Token erstellt', [
|
||||
'token_id' => $tokenId,
|
||||
'name' => $name,
|
||||
'scopes' => $effectiveScopes,
|
||||
'expires_at' => $expires,
|
||||
]);
|
||||
|
||||
return [
|
||||
'token_id' => $tokenId,
|
||||
'raw_token' => $rawToken,
|
||||
'name' => $name,
|
||||
'type' => 'sub',
|
||||
'scopes' => $effectiveScopes,
|
||||
'expires_at' => $expires,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt ein Sub-Token aus einem gueltigen Master-Token.
|
||||
* Rechte und Umgebung koennen dabei nur eingeschraenkt, nie erweitert werden.
|
||||
@@ -353,6 +434,10 @@ final class TokenManager
|
||||
'updateservice:publish' => ['updateservice:read'],
|
||||
'watchdog:evaluate' => ['watchdog:read'],
|
||||
'watchdog:ping' => ['watchdog:read'],
|
||||
// Wer einrichten darf, muss den Katalog sehen und Releases lesen
|
||||
// koennen - sonst gibt es nichts zu installieren.
|
||||
'setup:install' => ['setup:catalog', 'updateservice:read'],
|
||||
'setup:catalog' => ['updateservice:read'],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Modules\Notify;
|
||||
|
||||
use Deploymentcenter\Core\Config;
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Rocket.Chat Notifier.
|
||||
*
|
||||
* Verwaltet die Authentifizierung am Rocket.Chat-Server und versendet:
|
||||
* 1. Regelmaessige System-Statusberichte (standardmaessig alle 12 Stunden)
|
||||
* 2. Sofortige Warnmeldungen bei Ausfaellen oder kritischen Bugs (eigener Alarm-Kanal)
|
||||
*/
|
||||
final class RocketChatNotifier
|
||||
{
|
||||
private const TIMEOUT_SECONDS = 5;
|
||||
|
||||
/**
|
||||
* Ermittelt die aktuelle Rocket.Chat-Konfiguration (Dateivorlage + Datenbank-Overrides).
|
||||
*
|
||||
* @return array{
|
||||
* enabled: bool,
|
||||
* url: string,
|
||||
* username: string,
|
||||
* password: string,
|
||||
* status_channel: string,
|
||||
* alert_channel: string,
|
||||
* verify_ssl: bool,
|
||||
* report_interval: int
|
||||
* }
|
||||
*/
|
||||
public static function getConfig(PDO $db): array
|
||||
{
|
||||
$fileConfig = (array)Config::get('rocketchat', []);
|
||||
|
||||
$dbSettings = [];
|
||||
try {
|
||||
$stmt = $db->query("
|
||||
SELECT skey, svalue FROM dc_settings
|
||||
WHERE skey LIKE 'rocketchat_%'
|
||||
");
|
||||
if ($stmt !== false) {
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$dbSettings[$row['skey']] = $row['svalue'];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Tabelle fehlt evtl. vor Migration
|
||||
}
|
||||
|
||||
$enabled = isset($dbSettings['rocketchat_enabled'])
|
||||
? (bool)(int)$dbSettings['rocketchat_enabled']
|
||||
: (bool)($fileConfig['enabled'] ?? true);
|
||||
|
||||
$url = trim((string)($dbSettings['rocketchat_url'] ?? $fileConfig['url'] ?? 'https://chat.wh1.mhdf.de'));
|
||||
$url = rtrim($url, '/');
|
||||
if (str_ends_with($url, '/home')) {
|
||||
$url = substr($url, 0, -5);
|
||||
}
|
||||
|
||||
$username = trim((string)($dbSettings['rocketchat_username'] ?? $fileConfig['username'] ?? 'deploymentcenter'));
|
||||
$password = (string)($dbSettings['rocketchat_password'] ?? $fileConfig['password'] ?? 'cNt.m.KcWHrb8_X9Tv8-');
|
||||
$statusChannel = trim((string)($dbSettings['rocketchat_status_channel'] ?? $fileConfig['status_channel'] ?? '#DC-Systemstatus'));
|
||||
$alertChannel = trim((string)($dbSettings['rocketchat_alert_channel'] ?? $fileConfig['alert_channel'] ?? '#DC-Alerts'));
|
||||
|
||||
$verifySsl = isset($dbSettings['rocketchat_verify_ssl'])
|
||||
? (bool)(int)$dbSettings['rocketchat_verify_ssl']
|
||||
: (bool)($fileConfig['verify_ssl'] ?? false);
|
||||
|
||||
$reportInterval = isset($dbSettings['rocketchat_report_interval'])
|
||||
? (int)$dbSettings['rocketchat_report_interval']
|
||||
: (int)($fileConfig['report_interval'] ?? 43200);
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'url' => $url,
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
'status_channel' => $statusChannel !== '' ? $statusChannel : '#general',
|
||||
'alert_channel' => $alertChannel !== '' ? $alertChannel : '#general',
|
||||
'verify_ssl' => $verifySsl,
|
||||
'report_interval' => max(300, $reportInterval),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuehrt den Login am Rocket.Chat-Server aus und liefert Auth-Token und User-ID zurück.
|
||||
*
|
||||
* @param array<string,mixed> $config
|
||||
* @return array{authToken: string, userId: string}|null
|
||||
*/
|
||||
public static function authenticate(array $config): ?array
|
||||
{
|
||||
$url = rtrim((string)($config['url'] ?? ''), '/') . '/api/v1/login';
|
||||
$payload = json_encode([
|
||||
'user' => (string)($config['username'] ?? ''),
|
||||
'password' => (string)($config['password'] ?? ''),
|
||||
]);
|
||||
|
||||
[$ok, $status, $response, $error] = self::httpPost($url, $payload, ['Content-Type: application/json'], (bool)($config['verify_ssl'] ?? false));
|
||||
|
||||
if (!$ok || $status !== 200 || $response === null) {
|
||||
Logger::warning('Rocket.Chat Login fehlgeschlagen', [
|
||||
'status' => $status,
|
||||
'error' => $error ?? 'Keine Antwort',
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_decode($response, true);
|
||||
if (!is_array($json) || empty($json['success'])) {
|
||||
Logger::warning('Rocket.Chat Login ungueltige Antwort', ['json' => $json]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$authToken = (string)($json['data']['authToken'] ?? '');
|
||||
$userId = (string)($json['data']['userId'] ?? '');
|
||||
|
||||
if ($authToken === '' || $userId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'authToken' => $authToken,
|
||||
'userId' => $userId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet eine Nachricht (optional mit Attachments) an einen Rocket.Chat-Kanal.
|
||||
*
|
||||
* @param array<string,mixed> $overrideConfig
|
||||
* @param list<array<string,mixed>> $attachments
|
||||
*/
|
||||
public static function send(PDO $db, string $channel, string $text, array $attachments = [], array $overrideConfig = []): bool
|
||||
{
|
||||
$config = array_merge(self::getConfig($db), $overrideConfig);
|
||||
|
||||
if (empty($config['enabled']) && empty($overrideConfig['ignore_enabled'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$auth = self::authenticate($config);
|
||||
if ($auth === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$postUrl = rtrim((string)$config['url'], '/') . '/api/v1/chat.postMessage';
|
||||
|
||||
$body = [
|
||||
'channel' => $channel,
|
||||
'text' => $text,
|
||||
];
|
||||
if ($attachments !== []) {
|
||||
$body['attachments'] = $attachments;
|
||||
}
|
||||
|
||||
$jsonPayload = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($jsonPayload === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'X-Auth-Token: ' . $auth['authToken'],
|
||||
'X-User-Id: ' . $auth['userId'],
|
||||
];
|
||||
|
||||
[$ok, $status, $response, $error] = self::httpPost($postUrl, $jsonPayload, $headers, (bool)$config['verify_ssl']);
|
||||
|
||||
if (!$ok || $status !== 200) {
|
||||
Logger::error('Rocket.Chat Nachrichten-Versand fehlgeschlagen', [
|
||||
'channel' => $channel,
|
||||
'status' => $status,
|
||||
'error' => $error,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt und versendet den 12-Stunden-Systemstatusbericht an Rocket.Chat.
|
||||
*
|
||||
* @return array{sent: bool, reason: string}
|
||||
*/
|
||||
public static function sendStatusReport(PDO $db, bool $force = false): array
|
||||
{
|
||||
$config = self::getConfig($db);
|
||||
if (!$force && empty($config['enabled'])) {
|
||||
return ['sent' => false, 'reason' => 'Rocket.Chat Benachrichtigungen sind deaktiviert.'];
|
||||
}
|
||||
|
||||
$intervalSec = $config['report_interval'];
|
||||
|
||||
if (!$force) {
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT last_run_utc FROM watchdog_cron_jobs WHERE name = 'rocketchat_status_report'");
|
||||
$stmt->execute();
|
||||
$lastRun = $stmt->fetchColumn();
|
||||
|
||||
if ($lastRun !== false && $lastRun !== null) {
|
||||
$lastRunTs = strtotime((string)$lastRun . ' UTC');
|
||||
if ($lastRunTs !== false && (time() - $lastRunTs) < $intervalSec) {
|
||||
return ['sent' => false, 'reason' => 'Statusbericht noch nicht faellig.'];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Bei Tabellenfehler fortfahren
|
||||
}
|
||||
}
|
||||
|
||||
// Monitore ermitteln
|
||||
$totalMonitors = 0;
|
||||
$upCount = 0;
|
||||
$warningCount = 0;
|
||||
$downCount = 0;
|
||||
$stoppedCount = 0;
|
||||
$problemMonitors = [];
|
||||
|
||||
try {
|
||||
$stmt = $db->query('SELECT source, instance, state, last_message, updated_utc FROM watchdog_monitors');
|
||||
if ($stmt !== false) {
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
$totalMonitors = count($rows);
|
||||
foreach ($rows as $m) {
|
||||
$st = (string)($m['state'] ?? 'unknown');
|
||||
if ($st === 'up') {
|
||||
$upCount++;
|
||||
} elseif ($st === 'warning') {
|
||||
$warningCount++;
|
||||
$problemMonitors[] = "⚠️ **{$m['source']}** ({$m['instance']}): Warning - " . ($m['last_message'] ?: 'Intervall ueberschritten');
|
||||
} elseif ($st === 'down' || $st === 'error') {
|
||||
$downCount++;
|
||||
$problemMonitors[] = "🔴 **{$m['source']}** ({$m['instance']}): DOWN - " . ($m['last_message'] ?: 'Kein Heartbeat');
|
||||
} elseif ($st === 'stopped') {
|
||||
$stoppedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Logger::warning('Fehler beim Abrufen der Monitore fuer RocketChat-Bericht', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// Bugtracker Statistiken
|
||||
$openBugs = 0;
|
||||
$criticalBugs = 0;
|
||||
try {
|
||||
$stmt = $db->query("SELECT COUNT(*) FROM bugtracker_items WHERE status IN ('open', 'planned', 'in_progress')");
|
||||
if ($stmt !== false) {
|
||||
$openBugs = (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
$stmt = $db->query("SELECT COUNT(*) FROM bugtracker_items WHERE status IN ('open', 'planned', 'in_progress') AND severity = 'critical'");
|
||||
if ($stmt !== false) {
|
||||
$criticalBugs = (int)$stmt->fetchColumn();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignorieren falls nicht verfuegbar
|
||||
}
|
||||
|
||||
// Farbe und Titel bestimmen
|
||||
$color = '#28a745'; // Gruen
|
||||
$statusHeader = '🟢 **System-Statusbericht: Alle Systeme betriebsbereit**';
|
||||
|
||||
if ($downCount > 0) {
|
||||
$color = '#dc3545'; // Rot
|
||||
$statusHeader = "🔴 **System-Statusbericht: {$downCount} System(e) AUSGEFALLEN**";
|
||||
} elseif ($warningCount > 0 || $criticalBugs > 0) {
|
||||
$color = '#ffc107'; // Gelb
|
||||
$statusHeader = "⚠️ **System-Statusbericht: Warnungen vorhanden**";
|
||||
}
|
||||
|
||||
$appUrl = (string)Config::get('app.url', '');
|
||||
|
||||
$fields = [
|
||||
['title' => 'Monitore Gesamt', 'value' => (string)$totalMonitors, 'short' => true],
|
||||
['title' => 'Status UP', 'value' => (string)$upCount, 'short' => true],
|
||||
['title' => 'Status WARNING', 'value' => (string)$warningCount, 'short' => true],
|
||||
['title' => 'Status DOWN', 'value' => (string)$downCount, 'short' => true],
|
||||
['title' => 'Offene Bugtracker-Items', 'value' => (string)$openBugs, 'short' => true],
|
||||
['title' => 'Kritische Bugs', 'value' => (string)$criticalBugs, 'short' => true],
|
||||
];
|
||||
|
||||
$detailText = '';
|
||||
if ($problemMonitors !== []) {
|
||||
$detailText .= "\n\n**Auffaellige Systeme:**\n" . implode("\n", array_slice($problemMonitors, 0, 10));
|
||||
}
|
||||
if ($appUrl !== '') {
|
||||
$detailText .= "\n\n🔗 [Zum Deploymentcenter Dashboard]({$appUrl})";
|
||||
}
|
||||
|
||||
$attachments = [
|
||||
[
|
||||
'color' => $color,
|
||||
'title' => 'Deploymentcenter Statusübersicht (12-Stunden-Intervall)',
|
||||
'text' => $statusHeader . $detailText,
|
||||
'fields' => $fields,
|
||||
'ts' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
]
|
||||
];
|
||||
|
||||
$sent = self::send($db, $config['status_channel'], '📊 **Deploymentcenter Statusbericht**', $attachments);
|
||||
|
||||
if ($sent) {
|
||||
self::recordReportRun($db, 'ok');
|
||||
return ['sent' => true, 'reason' => 'Statusbericht erfolgreich versendet.'];
|
||||
}
|
||||
|
||||
self::recordReportRun($db, 'failed');
|
||||
return ['sent' => false, 'reason' => 'Versand des Statusberichts an Rocket.Chat fehlgeschlagen.'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet eine sofortige kritische Warnmeldung an den Alarm-Kanal.
|
||||
*
|
||||
* @param array<string,mixed> $details
|
||||
*/
|
||||
public static function sendAlert(PDO $db, string $title, string $message, string $severity = 'alarm', array $details = []): bool
|
||||
{
|
||||
$config = self::getConfig($db);
|
||||
if (empty($config['enabled'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$color = $severity === 'alarm' ? '#dc3545' : '#ffc107';
|
||||
$icon = $severity === 'alarm' ? '🚨' : '⚠️';
|
||||
|
||||
$fields = [];
|
||||
foreach ($details as $k => $v) {
|
||||
if (is_scalar($v)) {
|
||||
$fields[] = [
|
||||
'title' => ucfirst((string)$k),
|
||||
'value' => (string)$v,
|
||||
'short' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$appUrl = (string)Config::get('app.url', '');
|
||||
$text = "{$icon} **[ALARM] {$title}**\n{$message}";
|
||||
if ($appUrl !== '') {
|
||||
$text .= "\n🔗 [Deploymentcenter Öffnen]({$appUrl})";
|
||||
}
|
||||
|
||||
$attachments = [
|
||||
[
|
||||
'color' => $color,
|
||||
'title' => "System-Warnung: {$title}",
|
||||
'text' => $text,
|
||||
'fields' => $fields,
|
||||
'ts' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
]
|
||||
];
|
||||
|
||||
return self::send($db, $config['alert_channel'], "{$icon} **Kritische Benachrichtigung vom Deploymentcenter**", $attachments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Testet die Rocket.Chat Verbindungsdaten und schickt eine Testnachricht an beide Kanaele.
|
||||
*
|
||||
* @param array<string,mixed> $config
|
||||
* @return array{success: bool, message: string}
|
||||
*/
|
||||
public static function testConnection(array $config): array
|
||||
{
|
||||
$config['ignore_enabled'] = true;
|
||||
$auth = self::authenticate($config);
|
||||
if ($auth === null) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Login fehlgeschlagen: Die Anmeldedaten oder die Server-URL sind ungueltig.',
|
||||
];
|
||||
}
|
||||
|
||||
$statusChannel = (string)($config['status_channel'] ?? '#systemstatus');
|
||||
$alertChannel = (string)($config['alert_channel'] ?? '#alerts');
|
||||
|
||||
$dbMock = new class extends PDO {
|
||||
public function __construct() {}
|
||||
};
|
||||
|
||||
// Standard-Dummy-PDO fuer den Testaufruf ohne DB-Abhangigkeit
|
||||
$sentStatus = self::sendDirect($config, $auth, $statusChannel, '✅ **Rocket.Chat Verbindungstest**: Status-Kanal erreichbar.');
|
||||
$sentAlert = self::sendDirect($config, $auth, $alertChannel, '🚨 **Rocket.Chat Verbindungstest**: Alarm-Kanal erreichbar.');
|
||||
|
||||
if ($sentStatus && $sentAlert) {
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "Verbindung erfolgreich! Testnachrichten wurden an {$statusChannel} und {$alertChannel} gesendet.",
|
||||
];
|
||||
}
|
||||
|
||||
if ($sentStatus || $sentAlert) {
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "Teilweise erfolgreich: Login klappte, aber mindestens ein Kanal war nicht erreichbar.",
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Login erfolgreich, aber Nachrichten konnten in den angegebenen Kanaelen nicht gepostet werden.',
|
||||
];
|
||||
}
|
||||
|
||||
private static function sendDirect(array $config, array $auth, string $channel, string $text): bool
|
||||
{
|
||||
$postUrl = rtrim((string)$config['url'], '/') . '/api/v1/chat.postMessage';
|
||||
$body = json_encode([
|
||||
'channel' => $channel,
|
||||
'text' => $text,
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'X-Auth-Token: ' . $auth['authToken'],
|
||||
'X-User-Id: ' . $auth['userId'],
|
||||
];
|
||||
|
||||
[$ok, $status] = self::httpPost($postUrl, (string)$body, $headers, (bool)($config['verify_ssl'] ?? false));
|
||||
return $ok && $status === 200;
|
||||
}
|
||||
|
||||
private static function recordReportRun(PDO $db, string $status): void
|
||||
{
|
||||
try {
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO watchdog_cron_jobs (name, interval_sec, last_run_utc, running, last_status, enabled)
|
||||
VALUES ("rocketchat_status_report", 43200, UTC_TIMESTAMP(), 0, :status, 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_run_utc = UTC_TIMESTAMP(),
|
||||
running = 0,
|
||||
last_status = VALUES(last_status)
|
||||
');
|
||||
$stmt->execute([':status' => $status]);
|
||||
} catch (\Throwable $e) {
|
||||
// Ignorieren falls DB unvollstaendig
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $headers
|
||||
* @return array{0:bool, 1:int, 2:?string, 3:?string}
|
||||
*/
|
||||
private static function httpPost(string $url, string $payload, array $headers, bool $verifySsl): array
|
||||
{
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return [false, 0, null, 'curl_init fehlgeschlagen'];
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => self::TIMEOUT_SECONDS,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_SSL_VERIFYPEER => $verifySsl,
|
||||
CURLOPT_SSL_VERIFYHOST => $verifySsl ? 2 : 0,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$error = $response === false ? curl_error($ch) : null;
|
||||
curl_close($ch);
|
||||
|
||||
return [$status >= 200 && $status < 300, $status, is_string($response) ? $response : null, $error];
|
||||
}
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => implode("\r\n", $headers),
|
||||
'content' => $payload,
|
||||
'timeout' => self::TIMEOUT_SECONDS,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => $verifySsl,
|
||||
'verify_peer_name' => $verifySsl,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
$status = 0;
|
||||
if (isset($http_response_header[0]) && preg_match('#\s(\d{3})\s#', $http_response_header[0], $m) === 1) {
|
||||
$status = (int)$m[1];
|
||||
}
|
||||
|
||||
return [
|
||||
$response !== false && $status >= 200 && $status < 300,
|
||||
$status,
|
||||
$response !== false ? $response : null,
|
||||
$response === false ? 'HTTP-Anfrage fehlgeschlagen' : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,34 @@ final class WebhookDispatcher
|
||||
|
||||
public static function dispatch(PDO $db, string $event, array $payload): void
|
||||
{
|
||||
// Kritische Ereignisse direkt an Rocket.Chat spiegeln
|
||||
if ($event === 'bug.critical') {
|
||||
RocketChatNotifier::sendAlert(
|
||||
$db,
|
||||
'Kritischer Bug gemeldet',
|
||||
(string)($payload['title'] ?? $payload['error_message'] ?? 'Neuer kritischer Fehler'),
|
||||
'alarm',
|
||||
[
|
||||
'Projekt' => $payload['project_slug'] ?? 'Unbekannt',
|
||||
'Umgebung' => $payload['environment'] ?? 'production',
|
||||
'Item-ID' => $payload['item_id'] ?? 'neu',
|
||||
]
|
||||
);
|
||||
} elseif ($event === 'monitor.down') {
|
||||
RocketChatNotifier::sendAlert(
|
||||
$db,
|
||||
'System-Ausfall erkannt (Watchdog)',
|
||||
(string)($payload['source'] ?? 'Monitor') . ' (' . ($payload['instance'] ?? 'default') . '): ' . ($payload['reason'] ?? 'Heartbeat ausgeblieben'),
|
||||
'alarm',
|
||||
[
|
||||
'Quelle' => $payload['source'] ?? 'unbekannt',
|
||||
'Instanz' => $payload['instance'] ?? 'default',
|
||||
'Von Zustand' => $payload['from_state'] ?? 'up',
|
||||
'Nach Zustand' => $payload['to_state'] ?? 'down',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if (self::$dispatchedThisRequest >= self::MAX_TARGETS) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Modules\Setup;
|
||||
|
||||
use Deploymentcenter\Modules\UpdateService\UpdateManager;
|
||||
use Deploymentcenter\Modules\UpdateService\Version;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Stellt zusammen, was auf einem bestimmten System installierbar ist.
|
||||
*
|
||||
* Der Installer fragt diese Liste ab, nachdem er sich angemeldet hat, und
|
||||
* zeigt sie zur Auswahl. Massgeblich ist dabei die Laufzeitkennung des
|
||||
* Zielsystems: ein Projekt, von dem es nur ein Windows-Paket gibt, taucht auf
|
||||
* einem Linux-Rechner gar nicht erst auf. Alles andere waere eine Auswahl,
|
||||
* die beim Anklicken fehlschlaegt.
|
||||
*/
|
||||
final class SetupCatalog
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
public function __construct(PDO $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function forPlatform(?string $platform, ?string $projectFilter = null): array
|
||||
{
|
||||
$requested = UpdateManager::normalizePlatform($platform);
|
||||
|
||||
$candidates = $requested === UpdateManager::PLATFORM_ANY
|
||||
? [UpdateManager::PLATFORM_ANY]
|
||||
: [$requested, UpdateManager::PLATFORM_ANY];
|
||||
|
||||
$placeholders = implode(', ', array_map(
|
||||
static fn(int $i): string => ':platform' . $i,
|
||||
array_keys($candidates)
|
||||
));
|
||||
|
||||
$sql = '
|
||||
SELECT r.*, p.name AS project_name, p.notes AS project_notes
|
||||
FROM updateservice_releases r
|
||||
LEFT JOIN dc_projects p ON p.slug = r.product_slug
|
||||
WHERE r.platform IN (' . $placeholders . ')
|
||||
';
|
||||
|
||||
$params = [];
|
||||
foreach ($candidates as $i => $candidate) {
|
||||
$params[':platform' . $i] = $candidate;
|
||||
}
|
||||
|
||||
if ($projectFilter !== null && $projectFilter !== '') {
|
||||
$sql .= ' AND r.product_slug = :slug';
|
||||
$params[':slug'] = $projectFilter;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll() ?: [];
|
||||
|
||||
// Nach Projekt und Kanal buendeln; je Version gewinnt - wie im
|
||||
// UpdateManager - das plattformgenaue Paket vor dem generischen.
|
||||
$grouped = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$slug = (string)$row['product_slug'];
|
||||
$channel = (string)$row['channel'];
|
||||
$version = (string)$row['version'];
|
||||
|
||||
$key = $slug . "\0" . $channel . "\0" . $version;
|
||||
$existing = $grouped[$key] ?? null;
|
||||
|
||||
if ($existing === null) {
|
||||
$grouped[$key] = $row;
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingPlatform = (string)($existing['platform'] ?? UpdateManager::PLATFORM_ANY);
|
||||
$rowPlatform = (string)($row['platform'] ?? UpdateManager::PLATFORM_ANY);
|
||||
|
||||
if ($existingPlatform === UpdateManager::PLATFORM_ANY
|
||||
&& $rowPlatform !== UpdateManager::PLATFORM_ANY) {
|
||||
$grouped[$key] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
// Je Projekt und Kanal das hoechste Release ermitteln.
|
||||
$byProject = [];
|
||||
|
||||
foreach ($grouped as $row) {
|
||||
$slug = (string)$row['product_slug'];
|
||||
$channel = (string)$row['channel'];
|
||||
|
||||
if (!isset($byProject[$slug])) {
|
||||
$byProject[$slug] = [
|
||||
'slug' => $slug,
|
||||
'name' => (string)($row['project_name'] ?? $slug),
|
||||
'notes' => $row['project_notes'] !== null ? (string)$row['project_notes'] : null,
|
||||
'channels' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$current = $byProject[$slug]['channels'][$channel] ?? null;
|
||||
|
||||
if ($current === null
|
||||
|| Version::isNewer((string)$row['version'], (string)$current['version'])) {
|
||||
$byProject[$slug]['channels'][$channel] = [
|
||||
'channel' => $channel,
|
||||
'version' => (string)$row['version'],
|
||||
'platform' => (string)($row['platform'] ?? UpdateManager::PLATFORM_ANY),
|
||||
'size_bytes' => (int)$row['size_bytes'],
|
||||
'is_critical' => (bool)$row['is_critical'],
|
||||
'signed' => !empty($row['manifest_signature']),
|
||||
'release_notes'=> $row['release_notes'] !== null ? (string)$row['release_notes'] : null,
|
||||
'released_at' => (string)$row['created_at'],
|
||||
'download_url' => (string)$row['download_url'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Kanaele in eine verlaessliche Reihenfolge bringen: was am ehesten
|
||||
// gewaehlt werden soll, steht vorn.
|
||||
$order = ['prod' => 0, 'beta' => 1, 'dev' => 2];
|
||||
|
||||
$catalog = [];
|
||||
foreach ($byProject as $entry) {
|
||||
$channels = array_values($entry['channels']);
|
||||
usort($channels, static function (array $a, array $b) use ($order): int {
|
||||
$rankA = $order[$a['channel']] ?? 99;
|
||||
$rankB = $order[$b['channel']] ?? 99;
|
||||
return $rankA === $rankB ? strcmp($a['channel'], $b['channel']) : $rankA <=> $rankB;
|
||||
});
|
||||
|
||||
$entry['channels'] = $channels;
|
||||
$catalog[] = $entry;
|
||||
}
|
||||
|
||||
usort($catalog, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name']));
|
||||
|
||||
return $catalog;
|
||||
}
|
||||
}
|
||||
@@ -268,11 +268,99 @@ final class UpdateManager
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
public function getReleaseById(int $id): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT * FROM updateservice_releases
|
||||
WHERE id = :id
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':id' => $id]);
|
||||
$row = $stmt->fetch();
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
public function updateRelease(
|
||||
int $id,
|
||||
string $productSlug,
|
||||
string $version,
|
||||
string $channel = 'prod',
|
||||
?string $releaseNotes = null,
|
||||
string $downloadUrl = '',
|
||||
?string $sha256Hash = null,
|
||||
?string $gitCommit = null,
|
||||
int $sizeBytes = 0,
|
||||
?string $manifestJson = null,
|
||||
bool $isCritical = false,
|
||||
string $author = 'admin',
|
||||
?string $platform = null,
|
||||
?string $manifestSignature = null
|
||||
): bool {
|
||||
$platform = self::normalizePlatform($platform);
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
UPDATE updateservice_releases SET
|
||||
product_slug = :slug,
|
||||
version = :version,
|
||||
channel = :channel,
|
||||
platform = :platform,
|
||||
release_notes = :notes,
|
||||
download_url = :url,
|
||||
sha256_hash = :hash,
|
||||
git_commit = :git,
|
||||
size_bytes = :size,
|
||||
manifest_json = :manifest,
|
||||
manifest_signature = :signature,
|
||||
is_critical = :critical
|
||||
WHERE id = :id
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
':id' => $id,
|
||||
':slug' => $productSlug,
|
||||
':version' => $version,
|
||||
':channel' => $channel,
|
||||
':platform' => $platform,
|
||||
':notes' => $releaseNotes,
|
||||
':url' => $downloadUrl,
|
||||
':hash' => $sha256Hash !== null && $sha256Hash !== '' ? $sha256Hash : null,
|
||||
':git' => $gitCommit !== null && $gitCommit !== '' ? $gitCommit : null,
|
||||
':size' => $sizeBytes,
|
||||
':manifest' => $manifestJson,
|
||||
':signature' => $manifestSignature !== null && $manifestSignature !== '' ? $manifestSignature : null,
|
||||
':critical' => $isCritical ? 1 : 0,
|
||||
]);
|
||||
|
||||
Logger::info('Release aktualisiert', [
|
||||
'id' => $id,
|
||||
'product' => $productSlug,
|
||||
'version' => $version,
|
||||
'channel' => $channel,
|
||||
'platform' => $platform,
|
||||
'author' => $author,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function deleteRelease(int $id): bool
|
||||
{
|
||||
$existing = $this->getReleaseById($id);
|
||||
$stmt = $this->db->prepare('DELETE FROM updateservice_releases WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
$deleted = $stmt->rowCount() > 0;
|
||||
|
||||
if ($deleted && $existing !== null) {
|
||||
Logger::info('Release geloescht', [
|
||||
'id' => $id,
|
||||
'product' => $existing['product_slug'],
|
||||
'version' => $existing['version'],
|
||||
'channel' => $existing['channel'],
|
||||
'platform' => $existing['platform'] ?? self::PLATFORM_ANY,
|
||||
]);
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Deploymentcenter\Modules\Watchdog;
|
||||
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use Deploymentcenter\Modules\Bugtracker\BugRepo;
|
||||
use Deploymentcenter\Modules\Notify\RocketChatNotifier;
|
||||
use Deploymentcenter\Modules\Notify\WebhookDispatcher;
|
||||
use PDO;
|
||||
|
||||
@@ -150,6 +151,13 @@ final class Evaluator
|
||||
$purgedMetrics = (new MetricStore($db))->purge();
|
||||
}
|
||||
|
||||
// Rocket.Chat 12-Stunden-Statusbericht (sofern faellig)
|
||||
try {
|
||||
RocketChatNotifier::sendStatusReport($db, false);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::warning('RocketChat-Statusbericht fehlgeschlagen', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
$durationMs = (int)round((microtime(true) - $started) * 1000);
|
||||
self::recordRun($db, $durationMs, count($changes));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user