feat(2.7.0): Roadmap im DC, Testinstanz mit Testtor, Supervisor und MCP

Sammelcommit fuer den ueber mehrere Sitzungen gewachsenen, bisher nicht
eingecheckten Stand. Getrennte Commits waeren ehrlicher gewesen, sind es
nachtraeglich aber nicht mehr: BugRepo, changelog.json und die Doku tragen
Aenderungen aus allen Straengen gleichzeitig, und keine Zwischenstufe waere
lauffaehig.

ROADMAP IM DEPLOYMENTCENTER (2.7.0)

Planungsstand, Reihenfolge und Zustaendigkeit lagen in handgepflegten
Markdown-Dateien neben einem Bugtracker, der genau diese drei Dinge bereits
fuehrt. Zwei Pfleger fuer dasselbe Feld - Drift war eine Frage der Zeit.

Ab jetzt: Zustand im DC, Begruendung in der Datei. Kein Feld hat zwei Pfleger.

  - Migration 016: epic, roadmap_rank, target_build, defer_trigger,
    doc_anchor, Status "deferred", Tabelle roadmap_epics.
    KEINE Roadmap-Tabelle - ein Eintrag ist ein bugtracker_item.
  - Zugehoerigkeit ist ein Feld: Ein Item steht auf der Roadmap, wenn es ein
    epic hat. Ein zweites Kennzeichen daneben koennte auseinanderlaufen.
  - Kennung ist die Item-ID. Handvergebene Kuerzel ("A12") erzeugen die
    schlimmste Fehlerart: Beide Seiten stimmig, Verschiedenes gemeint, weil
    die Kennung nach einer Loeschung neu vergeben wurde.
  - Zurueckstellen verlangt defer_trigger. Ohne benannten Anlass ist ein
    Vorhaben nicht zurueckgestellt, sondern vergessen. Die Regel sitzt im
    Repository, damit sie auf allen drei Schreibwegen gleich greift.
  - Modules/Bugtracker/Roadmap.php, /api/bugtracker/v1/roadmap.php,
    WebUI-Modul mit Filtern, cli/roadmap.php --write/--check,
    dc_roadmap und dc_roadmap_set im MCP-Adapter.
  - cli/roadmap.php --write ERZEUGT den Zustandsteil der Markdown-Datei;
    --check prueft nur die Prosa. Was erzeugt wird, kann nicht driften.
  - docs/ROADMAP_MIGRATION.md: Uebernahme fuer die anderen Projekte.

TESTINSTANZ UND TESTTOR

  - dc-dev.mhdf.de: zweite Installation, eigene DB, eigene Schluessel,
    Basic-Auth davor, Rocket.Chat aus.
  - 124 Testfaelle in tests/e2e gegen API, Rechte, Migrationen, Sperre.
  - scripts/deploy.py bringt vor jedem Upload erst den anstehenden Stand auf
    die Testinstanz und laesst dann den Testlauf laufen. Erst bei
    Rueckgabewert 0 geht etwas auf die Produktion (--ohne-test als Notausgang).
    Andersherum prueft der Lauf aelteren Code als den, der gleich hochgeht.
  - tests/ ist aus dem Produktiv-Deploy ausgeschlossen; testctl.php kann
    nicht versehentlich auf dc.mhdf.de landen.
  - Ein Lauf mit mehr uebersprungenen als geprueften Faellen endet mit
    Rueckgabewert 2 statt 0. Nichts ist kaputt, geprueft wurde es aber auch
    nicht - der Unterschied darf nicht am Rueckgabewert verschwinden.

SUPERVISOR, PROJEKTLANDSCHAFT, NACHPRUEFUNG (2.6.0/2.6.1)

  - Migration 013: projektuebergreifend melden (dev_agent, evidence,
    triage_external, affects).
  - Migration 014: supervisor_findings, Katalog, fuenf serverseitige
    Detektoren im Tick.
  - Migration 015: Nachpruefung als Arbeitsschlange statt Benachrichtigung.
  - DEV-Lizenzschluessel zur Selbstbedienung, dc-testkit fuer die Test-VM.
  - MCP-Adapter: 15 Werkzeuge, ohne Fremdabhaengigkeiten, mit Abgrenzung
    von Fremddaten gegen Prompt-Injektion.

BEHOBEN

  - observe und status im Supervisor legten den Zustand des Befundes als
    "status" in die Antwort. Http::ok() merged ['status' => 'success']
    darunter, also gewann der Befundzustand - eine angenommene Meldung kam
    als status="open" an. Heisst jetzt finding_status. War Item #59.

Migrationen 013-016 sind auf dem Produktivserver noch NICHT ausgefuehrt.
Ohne sie bricht das WebUI, weil die Roadmap-Ansicht bei jedem Seitenaufruf
gegen bugtracker_items.epic abfragt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-29 21:59:20 +02:00
co-authored by Claude Opus 5
parent 9d0261306c
commit 1d5ac92051
52 changed files with 15261 additions and 103 deletions
+74 -5
View File
@@ -111,14 +111,69 @@ final class ApiAuth
return null;
}
/**
* Ist das ein Werkbank-Token?
*
* Der Unterschied, der zaehlt, ist nicht "welches Projekt", sondern WO das
* Token liegt:
*
* - Laufzeit-Token stecken im ausgelieferten Client, also auf fremder
* Hardware. Sie sind extrahierbar. Ein solches Token darf ausschliesslich
* in sein eigenes Projekt schreiben - sonst koennte ein Kunde mit einem
* ausgelesenen Token in fremde Tracker schreiben.
* - Werkbank-Token (owner_type "dev_agent") liegen auf einer eigenen
* Maschine: Claude Code, der MCP-Adapter, die CI. Sie duerfen ueber die
* Projekte hinweg arbeiten.
*
* Die Instanz fuehrt ausschliesslich eigene Projekte; eine geteilte Instanz
* fuer Fremde ist ausgeschlossen. Deshalb genuegt der Token-Typ als
* Unterscheidung - ohne Mandantentrennung und ohne Liste erlaubter Ziele.
*
* @param array{token:?array} $context
*/
public static function isWorkbench(array $context): bool
{
$token = $context['token'] ?? null;
return is_array($token) && ($token['owner_type'] ?? null) === 'dev_agent';
}
/**
* Das Projekt, in dem der Aufrufer zuhause ist - unabhaengig davon, welches
* Projekt er gerade anspricht.
*
* Auch ein Werkbank-Token bleibt an sein Heimatprojekt gebunden, obwohl es
* fremde ansprechen darf. Das ist der Punkt: die Herkunft einer
* Fremdmeldung (reported_from) wird daraus abgeleitet und ist damit
* bezeugt statt behauptet. Koennte ein Agent sie frei angeben, waere das
* Signal "drei Projekte melden dasselbe" wertlos.
*
* @param array{project_slug:?string} $context
*/
public static function homeProject(array $context): ?string
{
$bound = $context['project_slug'] ?? null;
return is_string($bound) && $bound !== '' ? $bound : null;
}
/**
* Stellt sicher, dass ein projektgebundenes Token nur auf sein eigenes
* Projekt zugreift. Bricht sonst mit 403 ab.
*
* @param array{project_slug:?string,method:string} $context
* $allowWorkbench oeffnet die Schranke fuer Werkbank-Token. Der Vorgabewert
* false ist Absicht: die Ausnahme wird an jeder Aufrufstelle einzeln
* gesetzt, nie global. Ein Werkbank-Token darf melden und kommentieren -
* aber nicht in einem fremden Projekt veroeffentlichen, einrichten oder
* Items abschliessen. Ein pauschaler Freibrief haette genau das getan.
*
* @param array{project_slug:?string,method:string,token:?array} $context
*/
public static function enforceProject(array $context, ?string $requestedSlug): void
{
public static function enforceProject(
array $context,
?string $requestedSlug,
bool $allowWorkbench = false
): void {
$bound = $context['project_slug'] ?? null;
if ($bound === null || $bound === '') {
@@ -129,6 +184,10 @@ final class ApiAuth
return;
}
if ($allowWorkbench && self::isWorkbench($context)) {
return;
}
Http::fail(
403,
'project_forbidden',
@@ -142,10 +201,20 @@ final class ApiAuth
* Liefert den Projekt-Slug, auf den eine Anfrage eingeschraenkt werden muss,
* oder null bei uneingeschraenktem Zugriff.
*
* @param array{project_slug:?string} $context
* $allowWorkbench hebt die Einschraenkung fuer Werkbank-Token auf. Beim
* Lesen ist das gewollt: ein Agent, der entscheiden soll, ob ein Fehler ins
* eigene oder ins fremde Projekt gehoert, muss die Landschaft sehen. Beim
* Schreiben bleibt der Vorgabewert false stehen - dort schuetzt genau diese
* Einschraenkung fremde Items vor Zugriff.
*
* @param array{project_slug:?string,token:?array} $context
*/
public static function projectFilter(array $context): ?string
public static function projectFilter(array $context, bool $allowWorkbench = false): ?string
{
if ($allowWorkbench && self::isWorkbench($context)) {
return null;
}
$bound = $context['project_slug'] ?? null;
return is_string($bound) && $bound !== '' ? $bound : null;
}
+164
View File
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
use PDO;
/**
* Wer setzt auf wem auf?
*
* dc_projects kannte bisher Slug, Name und Notiz - aber nicht, welches Projekt
* auf welchem aufbaut. Damit war die Frage, die nach jedem SDK-Fehler als
* erstes kommt, nicht beantwortbar: welche meiner Projekte sind betroffen?
*
* Der zweite Zweck ist integrated_version. Bisher stand die Fassung, gegen die
* ein Projekt integriert hat, von Hand gepflegt in dessen CLAUDE.md
* ("Deploymentcenter-Anbindung: SDK 2.5.1"). Eine Angabe, die an N Stellen von
* Hand nachgezogen werden muss, ist nach dem ersten Vergessen falsch - und
* zwar schweigend. Hier steht sie einmal, und der Agent schreibt sie fort,
* wenn er die Anbindung anfasst.
*/
final class ProjectLinks
{
/**
* uses_sdk - bindet das SDK des anderen Projekts ein
* depends_on - braucht das andere Projekt zur Laufzeit
* shares_code - teilt Quelltext, etwa eine gemeinsame Bibliothek
*/
public const KINDS = ['uses_sdk', 'depends_on', 'shares_code'];
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
/**
* Alle Beziehungen, in denen dieses Projekt vorkommt - in beide Richtungen.
*
* @return array{uses:list<array<string,mixed>>,used_by:list<array<string,mixed>>}
*/
public function forProject(string $slug): array
{
$stmt = $this->db->prepare('
SELECT from_slug, to_slug, kind, integrated_version, integrated_at, notes
FROM dc_project_links
WHERE from_slug = :slug OR to_slug = :slug
ORDER BY kind ASC, to_slug ASC
');
$stmt->execute([':slug' => $slug]);
$uses = [];
$usedBy = [];
foreach ($stmt->fetchAll() ?: [] as $row) {
$entry = [
'kind' => (string)$row['kind'],
'integrated_version' => $row['integrated_version'] ?? null,
'integrated_at' => $row['integrated_at'] ?? null,
'notes' => $row['notes'] ?? null,
];
if ((string)$row['from_slug'] === $slug) {
$uses[] = ['project_slug' => (string)$row['to_slug']] + $entry;
} else {
$usedBy[] = ['project_slug' => (string)$row['from_slug']] + $entry;
}
}
return ['uses' => $uses, 'used_by' => $usedBy];
}
/**
* Die Beziehung zwischen zwei bestimmten Projekten, aus Sicht von $from.
*
* Liefert null, wenn keine besteht - das ist der Normalfall bei zwei
* Projekten, die nichts miteinander zu tun haben, und kein Fehler.
*
* @return array{direction:string,kind:string,integrated_version:?string}|null
*/
public function between(string $from, string $to): ?array
{
if ($from === $to) {
return ['direction' => 'self', 'kind' => 'self', 'integrated_version' => null];
}
$stmt = $this->db->prepare('
SELECT from_slug, kind, integrated_version
FROM dc_project_links
WHERE (from_slug = :a AND to_slug = :b)
OR (from_slug = :b AND to_slug = :a)
ORDER BY from_slug = :a DESC
LIMIT 1
');
$stmt->execute([':a' => $from, ':b' => $to]);
$row = $stmt->fetch();
if (!is_array($row)) {
return null;
}
return [
'direction' => (string)$row['from_slug'] === $from ? 'uses' : 'used_by',
'kind' => (string)$row['kind'],
'integrated_version' => $row['integrated_version'] ?? null,
];
}
/** @return list<array<string,mixed>> */
public function all(): array
{
$stmt = $this->db->query('
SELECT from_slug, to_slug, kind, integrated_version, integrated_at, notes
FROM dc_project_links
ORDER BY from_slug ASC, to_slug ASC
');
return $stmt->fetchAll() ?: [];
}
/**
* Legt eine Beziehung an oder schreibt die integrierte Fassung fort.
*
* integrated_at wird nur gesetzt, wenn tatsaechlich eine Fassung mitkommt -
* sonst stuende dort ein Zeitpunkt ohne Aussage.
*/
public function record(
string $from,
string $to,
string $kind = 'uses_sdk',
?string $integratedVersion = null,
?string $notes = null
): bool {
if ($from === '' || $to === '' || $from === $to) {
return false;
}
if (!in_array($kind, self::KINDS, true)) {
$kind = 'uses_sdk';
}
$stmt = $this->db->prepare('
INSERT INTO dc_project_links
(from_slug, to_slug, kind, integrated_version, integrated_at, notes)
VALUES
(:from, :to, :kind, :version, IF(:version_set, UTC_TIMESTAMP(), NULL), :notes)
ON DUPLICATE KEY UPDATE
integrated_version = COALESCE(VALUES(integrated_version), integrated_version),
integrated_at = IF(VALUES(integrated_version) IS NULL, integrated_at, UTC_TIMESTAMP()),
notes = COALESCE(VALUES(notes), notes)
');
return $stmt->execute([
':from' => $from,
':to' => $to,
':kind' => $kind,
':version' => $integratedVersion,
':version_set' => $integratedVersion !== null ? 1 : 0,
':notes' => $notes,
]);
}
}
+21
View File
@@ -37,8 +37,23 @@ final class TokenManager
'bugtracker:manage',
'watchdog:ping',
'watchdog:read',
// Supervisor: Befunde einliefern bzw. lesen. Getrennt vom Bugtracker,
// weil ein Befund etwas anderes behauptet als ein Bugreport - naemlich
// nur, dass etwas ungewoehnlich ist. Ein Beobachter auf Kundenhardware
// soll melden duerfen, ohne damit Zugriff auf den Tracker zu bekommen.
'supervisor:report',
'supervisor:read',
// Befunde sichten und einstufen. Bewusst getrennt von report: ein
// Beobachter auf Kundenhardware darf melden, aber nicht entscheiden,
// dass seine eigene Meldung erledigt ist.
'supervisor:manage',
'updateservice:read',
'updateservice:publish',
// Entwicklungsschluessel abholen. Gehoert auf das Werkbank-Token einer
// Testmaschine und auf kein ausgeliefertes Token: Wer ihn hat, kann
// sich fuer sein Projekt selbst eine befristete Lizenz ausstellen -
// und damit an die Release-Ablage.
'license:dev',
'tokens:provision',
// Erstinstallation: Katalog lesen und eine Anwendung einrichten.
// Diese Rechte traegt ausschliesslich das kurzlebige Token aus
@@ -434,6 +449,12 @@ final class TokenManager
'updateservice:publish' => ['updateservice:read'],
'watchdog:evaluate' => ['watchdog:read'],
'watchdog:ping' => ['watchdog:read'],
'supervisor:report' => ['supervisor:read'],
'supervisor:manage' => ['supervisor:read'],
// Wer sich einen Entwicklungsschluessel holen darf, tut das, um damit
// Pakete zu ziehen. Ohne updateservice:read waere der Schluessel in der
// Hand und der Katalog trotzdem verschlossen.
'license:dev' => ['updateservice:read'],
// Wer einrichten darf, muss den Katalog sehen und Releases lesen
// koennen - sonst gibt es nichts zu installieren.
'setup:install' => ['setup:catalog', 'updateservice:read'],
File diff suppressed because it is too large Load Diff
+863
View File
@@ -0,0 +1,863 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Bugtracker;
use PDO;
/**
* Die Roadmap - eine Sicht auf bugtracker_items, keine zweite Tabelle.
*
* DIE ARBEITSTEILUNG
*
* Kein Feld hat zwei Pfleger. Daraus folgt alles Weitere:
*
* Zustand - Status, Reihenfolge, Zielfassung, Bearbeiter
* -> Wahrheit ist die Datenbank. Die Datei zeigt eine
* maschinell erzeugte Kopie.
* Begruendung - warum, verworfen, Ausloeser, Herleitung
* -> Wahrheit ist die Datei. Das Item haelt nur den Anker.
*
* Wird ein Feld auf beiden Seiten von Hand gepflegt, ist Drift eingebaut.
* Deshalb gibt es das hier nicht.
*
* DIE KENNUNG
*
* Handvergebene Kennungen ("A12") sind der Grund fuer die schlimmste Fehlerart:
* Beide Seiten sind fuer sich stimmig und meinen Verschiedenes, weil die
* Kennung nach einer Loeschung neu vergeben wurde. Deshalb ist die Kennung
* `bugtracker_items.id` - AUTO_INCREMENT vergibt nie zweimal dieselbe Nummer.
* Der Buchstabe bleibt, aber nur als Themenbereich:
*
* ### [A] #142 - Symbol-Upload und dc_symbolicate
*/
final class Roadmap
{
/**
* Status -> Zeichen in der erzeugten Datei.
*
* Dieselben Zeichen, die die abgeloeste handgepflegte ROADMAP.md benutzt
* hat. Absicht: Wer die Datei nach der Umstellung aufschlaegt, liest
* dasselbe wie vorher - nur stimmt es jetzt.
*/
public const MARKERS = [
'open' => '📋',
'planned' => '📋',
'in_progress' => '🔨',
'awaiting_verification' => '🔍',
'in_verification' => '🔍',
'fix_insufficient' => '⚠️',
'resolved' => '✅',
'closed' => '✅',
'deferred' => '🧊',
'rejected' => '❌',
'ignored' => '🔇',
'triage_external' => '📨',
];
/** Klartext je Status, fuer Oberflaeche und Legende. */
public const STATUS_LABELS = [
'open' => 'Offen',
'planned' => 'Geplant',
'in_progress' => 'In Arbeit',
'awaiting_verification' => 'Wartet auf Nachpruefung',
'in_verification' => 'In Nachpruefung',
'fix_insufficient' => 'Fix trug nicht',
'resolved' => 'Erledigt',
'closed' => 'Abgeschlossen',
'deferred' => 'Zurueckgestellt',
'rejected' => 'Verworfen',
'ignored' => 'Stummgeschaltet',
'triage_external' => 'Fremdmeldung, ungesichtet',
];
/**
* Grobe Zusammenfassung der Status fuer den Filter.
*
* Elf Status sind fuer die Ablage richtig und fuer eine Auswahlliste zu
* viele. Wer "was ist offen?" fragt, meint nicht einen ENUM-Wert, sondern
* eine Lage.
*/
public const STATUS_GROUPS = [
// "aktiv" ist die uebliche Frage und deshalb die Voreinstellung: alles,
// was noch zu tun ist. Eine Roadmap, die mit sechzig erledigten
// Eintraegen beginnt, beantwortet sie nicht.
'aktiv' => ['open', 'planned', 'in_progress',
'awaiting_verification', 'in_verification', 'fix_insufficient'],
'offen' => ['open', 'planned'],
'arbeit' => ['in_progress', 'awaiting_verification', 'in_verification', 'fix_insufficient'],
'erledigt' => ['resolved', 'closed'],
'zurueckgestellt' => ['deferred'],
'verworfen' => ['rejected'],
];
/** Status, die als noch zu tun gelten - die eigentliche Roadmap. */
public const PENDING_STATUSES = [
'open', 'planned', 'in_progress',
'awaiting_verification', 'in_verification', 'fix_insufficient',
];
/**
* Marker der erzeugten Bloecke in der Markdown-Datei.
*
* Alles zwischen START und ENDE wird bei jedem Lauf ueberschrieben, alles
* ausserhalb nie angefasst. Damit kann der Zustandsteil per Konstruktion
* nicht driften - er wird nicht gepflegt, sondern erzeugt.
*/
public const BLOCK_START = '<!-- dc:roadmap';
public const BLOCK_END = '<!-- /dc:roadmap -->';
/**
* Erkennt einen Verweis auf ein Item im Fliesstext: "#142".
*
* Die Wortgrenze davor verhindert Treffer in Ankern und Farbwerten
* (`#a4c8ff`), die Ziffernpruefung dahinter Treffer in Ueberschriften-IDs.
*/
private const ANCHOR_PATTERN = '/(?<![\w#])#(\d{1,9})\b/';
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
// ==================================================================
// Lesen
// ==================================================================
/**
* Themenbereiche eines Projekts samt Zaehlung je Lage.
*
* Die Zaehlung kommt aus demselben Durchlauf wie die Bereiche: Ein Bereich
* ohne Items ist eine Ueberschrift ohne Inhalt und soll als solche
* sichtbar sein, statt in der Liste zu fehlen.
*
* @return list<array<string,mixed>>
*/
public function epics(?string $projectSlug = null): array
{
$where = '';
$params = [];
if ($projectSlug !== null && $projectSlug !== '' && $projectSlug !== 'all') {
$where = ' WHERE e.project_slug = :slug';
$params[':slug'] = $projectSlug;
}
$sql = '
SELECT e.project_slug, e.epic, e.title, e.summary, e.sort_order, e.is_closed,
COALESCE(c.total, 0) AS total,
COALESCE(c.pending, 0) AS pending,
COALESCE(c.working, 0) AS working,
COALESCE(c.done, 0) AS done,
COALESCE(c.deferred, 0) AS deferred,
COALESCE(c.rejected, 0) AS rejected
FROM roadmap_epics e
LEFT JOIN (
SELECT project_slug, epic,
COUNT(*) AS total,
SUM(status IN ("open","planned")) AS pending,
SUM(status IN ("in_progress","awaiting_verification",
"in_verification","fix_insufficient")) AS working,
SUM(status IN ("resolved","closed")) AS done,
SUM(status = "deferred") AS deferred,
SUM(status = "rejected") AS rejected
FROM bugtracker_items
WHERE epic IS NOT NULL AND epic <> ""
GROUP BY project_slug, epic
) c ON c.project_slug = e.project_slug AND c.epic = e.epic'
. $where
. ' ORDER BY e.project_slug ASC, e.sort_order ASC, e.epic ASC';
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll() ?: [];
}
/**
* Bereiche, die an Items haengen, aber in roadmap_epics fehlen.
*
* Entsteht, wenn jemand ein Item auf einen Bereich setzt, den es nicht
* gibt - ein Tippfehler erzeugt sonst lautlos einen zwoelften Bereich, der
* in keiner Uebersicht auftaucht.
*
* @return list<array<string,mixed>>
*/
public function orphanEpics(?string $projectSlug = null): array
{
$where = 'i.epic IS NOT NULL AND i.epic <> ""';
$params = [];
if ($projectSlug !== null && $projectSlug !== '' && $projectSlug !== 'all') {
$where .= ' AND i.project_slug = :slug';
$params[':slug'] = $projectSlug;
}
$stmt = $this->db->prepare('
SELECT i.project_slug, i.epic, COUNT(*) AS items
FROM bugtracker_items i
LEFT JOIN roadmap_epics e
ON e.project_slug = i.project_slug AND e.epic = i.epic
WHERE ' . $where . ' AND e.id IS NULL
GROUP BY i.project_slug, i.epic
ORDER BY items DESC
');
$stmt->execute($params);
return $stmt->fetchAll() ?: [];
}
/**
* Kennzahlen fuer die Kopfzeile der Ansicht.
*
* @return array<string,int>
*/
public function stats(?string $projectSlug = null): array
{
$where = 'epic IS NOT NULL AND epic <> ""';
$params = [];
if ($projectSlug !== null && $projectSlug !== '' && $projectSlug !== 'all') {
$where .= ' AND project_slug = :slug';
$params[':slug'] = $projectSlug;
}
$stmt = $this->db->prepare('
SELECT
COUNT(*) AS total,
SUM(status IN ("open","planned")) AS pending,
SUM(status IN ("in_progress","awaiting_verification",
"in_verification","fix_insufficient")) AS working,
SUM(status IN ("resolved","closed")) AS done,
SUM(status = "deferred") AS deferred,
SUM(status = "rejected") AS rejected,
SUM(claimed_by IS NOT NULL AND lease_until > UTC_TIMESTAMP()) AS claimed,
SUM(doc_anchor IS NULL OR doc_anchor = "") AS without_doc
FROM bugtracker_items
WHERE ' . $where
);
$stmt->execute($params);
$row = $stmt->fetch() ?: [];
return array_map(static fn($v): int => (int)$v, [
'total' => $row['total'] ?? 0,
'pending' => $row['pending'] ?? 0,
'working' => $row['working'] ?? 0,
'done' => $row['done'] ?? 0,
'deferred' => $row['deferred'] ?? 0,
'rejected' => $row['rejected'] ?? 0,
'claimed' => $row['claimed'] ?? 0,
'without_doc' => $row['without_doc'] ?? 0,
]);
}
/**
* "Als Naechstes" - die vorderen Eintraege der Warteschlange.
*
* Genau die Liste, die in der abgeloesten Datei von Hand oben stand und
* regelmaessig veraltete. Sie ergibt sich jetzt aus Reihenfolge und
* Status, ohne dass jemand sie fortschreibt.
*
* @return list<array<string,mixed>>
*/
public function nextUp(?string $projectSlug = null, int $limit = 5): array
{
$where = 'epic IS NOT NULL AND epic <> "" AND status IN ("open","planned","in_progress")';
$params = [];
if ($projectSlug !== null && $projectSlug !== '' && $projectSlug !== 'all') {
$where .= ' AND project_slug = :slug';
$params[':slug'] = $projectSlug;
}
$limit = max(1, min($limit, 25));
$stmt = $this->db->prepare('
SELECT id, project_slug, epic, roadmap_rank, title, status, severity,
target_build, claimed_by, lease_until, doc_anchor
FROM bugtracker_items
WHERE ' . $where . '
ORDER BY roadmap_rank IS NULL ASC, roadmap_rank ASC,
FIELD(severity, "critical","high","medium","low","wishlist","idea") ASC,
id ASC
LIMIT ' . $limit
);
$stmt->execute($params);
return $stmt->fetchAll() ?: [];
}
/**
* Wer arbeitet gerade woran - ueber alle Projekte.
*
* Die Frage, die den Anstoss zu dieser Umstellung gab. Sie brauchte keine
* neue Tabelle: claimed_by und lease_until gibt es seit dem
* Agenten-Workflow, nur gefragt hat sie niemand.
*
* @return list<array<string,mixed>>
*/
public function activeWork(?string $projectSlug = null): array
{
$where = 'claimed_by IS NOT NULL AND lease_until > UTC_TIMESTAMP()';
$params = [];
if ($projectSlug !== null && $projectSlug !== '' && $projectSlug !== 'all') {
$where .= ' AND project_slug = :slug';
$params[':slug'] = $projectSlug;
}
$stmt = $this->db->prepare('
SELECT id, project_slug, epic, title, status, claimed_by, claimed_at,
lease_until, target_build,
TIMESTAMPDIFF(MINUTE, UTC_TIMESTAMP(), lease_until) AS lease_minutes_left
FROM bugtracker_items
WHERE ' . $where . '
ORDER BY project_slug ASC, lease_until ASC
');
$stmt->execute($params);
return $stmt->fetchAll() ?: [];
}
/**
* Alle Roadmap-Eintraege eines Projekts, nach Bereich gruppiert.
*
* @return array<string,list<array<string,mixed>>> Bereich => Eintraege
*/
public function byEpic(string $projectSlug, bool $includeFinished = true): array
{
$where = 'project_slug = :slug AND epic IS NOT NULL AND epic <> ""';
if (!$includeFinished) {
$where .= ' AND status NOT IN ("resolved","closed")';
}
$stmt = $this->db->prepare('
SELECT * FROM bugtracker_items
WHERE ' . $where . '
ORDER BY epic ASC, roadmap_rank IS NULL ASC, roadmap_rank ASC, id ASC
');
$stmt->execute([':slug' => $projectSlug]);
$grouped = [];
foreach ($stmt->fetchAll() ?: [] as $row) {
$grouped[(string)$row['epic']][] = $row;
}
return $grouped;
}
// ==================================================================
// Reihenfolge
// ==================================================================
/**
* Vergibt die Reihenfolge eines Bereichs neu, in Zehnerschritten.
*
* Die Luecken sind der Zweck: Ein Eintrag laesst sich spaeter
* dazwischenschieben (Rang 15), ohne dass alle folgenden neu geschrieben
* werden muessen. Erst wenn die Luecke aufgebraucht ist, laeuft das hier
* noch einmal.
*
* @param list<int> $orderedIds Item-IDs in der gewuenschten Reihenfolge
*/
public function reorder(string $projectSlug, string $epic, array $orderedIds): int
{
$stmt = $this->db->prepare('
UPDATE bugtracker_items
SET roadmap_rank = :rank
WHERE id = :id AND project_slug = :slug AND epic = :epic
');
$touched = 0;
$rank = 10;
foreach ($orderedIds as $id) {
$stmt->execute([
':rank' => $rank,
':id' => (int)$id,
':slug' => $projectSlug,
':epic' => $epic,
]);
$touched += $stmt->rowCount();
$rank += 10;
}
return $touched;
}
/**
* Schiebt einen Eintrag innerhalb seines Bereichs um eine Position.
*
* Tauscht die Raenge mit dem Nachbarn, statt sie durchzuzaehlen: Das ist
* ein UPDATE auf zwei Zeilen und bleibt auch bei einem Bereich mit
* hundert Eintraegen billig.
*/
public function move(int $itemId, string $direction): bool
{
$stmt = $this->db->prepare('
SELECT id, project_slug, epic, roadmap_rank
FROM bugtracker_items WHERE id = :id
');
$stmt->execute([':id' => $itemId]);
$item = $stmt->fetch();
if (!is_array($item) || ($item['epic'] ?? null) === null) {
return false;
}
// Ein ungereihter Eintrag hat keinen Nachbarn, mit dem sich tauschen
// liesse. Er bekommt erst einmal einen Rang - hinter allen anderen,
// denn dort steht er schon.
if ($item['roadmap_rank'] === null) {
$max = $this->db->prepare('
SELECT COALESCE(MAX(roadmap_rank), 0) FROM bugtracker_items
WHERE project_slug = :slug AND epic = :epic
');
$max->execute([':slug' => $item['project_slug'], ':epic' => $item['epic']]);
$newRank = ((int)$max->fetchColumn()) + 10;
$this->setRank($itemId, $newRank);
$item['roadmap_rank'] = $newRank;
}
$comparison = $direction === 'up' ? '<' : '>';
$sort = $direction === 'up' ? 'DESC' : 'ASC';
$neighbour = $this->db->prepare('
SELECT id, roadmap_rank FROM bugtracker_items
WHERE project_slug = :slug AND epic = :epic
AND roadmap_rank IS NOT NULL
AND roadmap_rank ' . $comparison . ' :rank
ORDER BY roadmap_rank ' . $sort . ', id ' . $sort . '
LIMIT 1
');
$neighbour->execute([
':slug' => $item['project_slug'],
':epic' => $item['epic'],
':rank' => (int)$item['roadmap_rank'],
]);
$other = $neighbour->fetch();
if (!is_array($other)) {
return false; // schon ganz oben bzw. ganz unten
}
$this->setRank((int)$item['id'], (int)$other['roadmap_rank']);
$this->setRank((int)$other['id'], (int)$item['roadmap_rank']);
return true;
}
private function setRank(int $itemId, int $rank): void
{
$stmt = $this->db->prepare('UPDATE bugtracker_items SET roadmap_rank = :rank WHERE id = :id');
$stmt->execute([':rank' => $rank, ':id' => $itemId]);
}
// ==================================================================
// Bereiche verwalten
// ==================================================================
public function saveEpic(string $projectSlug, string $epic, string $title, ?string $summary, int $sortOrder): void
{
$stmt = $this->db->prepare('
INSERT INTO roadmap_epics (project_slug, epic, title, summary, sort_order)
VALUES (:slug, :epic, :title, :summary, :sort)
ON DUPLICATE KEY UPDATE title = VALUES(title),
summary = VALUES(summary),
sort_order = VALUES(sort_order)
');
$stmt->execute([
':slug' => $projectSlug,
':epic' => mb_substr(trim($epic), 0, 24),
':title' => mb_substr(trim($title), 0, 160),
':summary' => ($summary !== null && trim($summary) !== '') ? trim($summary) : null,
':sort' => $sortOrder,
]);
}
/**
* Loescht einen Bereich - aber nur, solange kein Eintrag daran haengt.
*
* Sonst blieben Items mit einem Bereich zurueck, den es nicht mehr gibt:
* genau die haengende Referenz, die diese Umstellung vermeiden soll.
*/
public function deleteEpic(string $projectSlug, string $epic): bool
{
$count = $this->db->prepare('
SELECT COUNT(*) FROM bugtracker_items
WHERE project_slug = :slug AND epic = :epic
');
$count->execute([':slug' => $projectSlug, ':epic' => $epic]);
if ((int)$count->fetchColumn() > 0) {
return false;
}
$stmt = $this->db->prepare('
DELETE FROM roadmap_epics WHERE project_slug = :slug AND epic = :epic
');
$stmt->execute([':slug' => $projectSlug, ':epic' => $epic]);
return $stmt->rowCount() > 0;
}
// ==================================================================
// Markdown erzeugen
// ==================================================================
/**
* Erzeugt den Zustandsblock fuer ein Projekt.
*
* Das Ergebnis wird zwischen die Marker geschrieben und bei jedem Lauf
* vollstaendig ersetzt. Es enthaelt bewusst NUR Zustand - keine
* Begruendung, keine Herleitung. Die stehen in der Prosa darunter und
* werden nie angefasst.
*/
public function renderBlock(string $projectSlug): string
{
$epics = $this->epics($projectSlug);
$grouped = $this->byEpic($projectSlug);
$stats = $this->stats($projectSlug);
$lines = [];
$lines[] = sprintf(
'%s stand=%s items=%d projekt=%s -->',
self::BLOCK_START,
gmdate('Y-m-d\TH:i\Z'),
$stats['total'],
$projectSlug
);
$lines[] = '';
$lines[] = '<!-- ERZEUGT - nicht von Hand aendern. Dieser Block wird bei jedem Lauf von';
$lines[] = ' `php cli/roadmap.php --write` ueberschrieben. Der Zustand wird im';
$lines[] = ' Deploymentcenter gepflegt, nicht hier. Prosa gehoert AUSSERHALB der';
$lines[] = ' Marker - dort bleibt sie unberuehrt. -->';
$lines[] = '';
$next = $this->nextUp($projectSlug, 4);
if ($next !== []) {
$lines[] = '### Als Naechstes';
$lines[] = '';
foreach ($next as $i => $item) {
$lines[] = sprintf(
'%d. **[%s] #%d — %s**%s',
$i + 1,
(string)$item['epic'],
(int)$item['id'],
(string)$item['title'],
$item['claimed_by'] !== null ? ' — in Arbeit: ' . (string)$item['claimed_by'] : ''
);
}
$lines[] = '';
}
foreach ($epics as $epic) {
$key = (string)$epic['epic'];
$items = $grouped[$key] ?? [];
$lines[] = sprintf('### %s — %s', $key, (string)$epic['title']);
$lines[] = '';
if ($items === []) {
$lines[] = '_Noch keine Eintraege._';
$lines[] = '';
continue;
}
$lines[] = '| | Vorhaben | Stand | Ziel |';
$lines[] = '|---|---|---|---|';
foreach ($items as $item) {
$status = (string)$item['status'];
$marker = self::MARKERS[$status] ?? '·';
$stand = $marker . ' ' . (self::STATUS_LABELS[$status] ?? $status);
if ($item['claimed_by'] !== null && $status === 'in_progress') {
$stand .= ' — ' . (string)$item['claimed_by'];
}
if ($status === 'deferred' && !empty($item['defer_trigger'])) {
$stand .= ' — Ausloeser: ' . self::inlineText((string)$item['defer_trigger']);
}
$ziel = '';
if ($status === 'resolved' || $status === 'closed') {
$ziel = (string)($item['resolved_in_build'] ?? '');
} elseif (!empty($item['target_build'])) {
$ziel = (string)$item['target_build'];
}
$lines[] = sprintf(
'| #%d | %s | %s | %s |',
(int)$item['id'],
self::inlineText((string)$item['title']),
$stand,
$ziel !== '' ? '`' . $ziel . '`' : '—'
);
}
$lines[] = '';
}
$lines[] = self::BLOCK_END;
return implode("\n", $lines);
}
/**
* Setzt den erzeugten Block in eine bestehende Datei ein.
*
* Findet sich kein Marker, wird der Block angehaengt statt die Datei zu
* ersetzen - eine vorhandene Roadmap darf durch einen ersten Lauf nicht
* verloren gehen.
*/
public static function spliceBlock(string $content, string $block): string
{
$start = strpos($content, self::BLOCK_START);
$end = strpos($content, self::BLOCK_END);
if ($start === false || $end === false || $end < $start) {
$separator = rtrim($content) === '' ? '' : "\n\n";
return rtrim($content) . $separator . $block . "\n";
}
return substr($content, 0, $start)
. $block
. substr($content, $end + strlen(self::BLOCK_END));
}
// ==================================================================
// Abgleich
// ==================================================================
/**
* Vergleicht die Prosa einer Datei gegen die Items eines Projekts.
*
* Der Zustandsblock wird dabei ausgeklammert - er ist erzeugt und kann
* nicht abweichen. Geprueft wird nur, was von Hand geschrieben wurde:
* Verweist ein Absatz auf ein Item, das es nicht gibt? Gehoert das Item
* einem anderen Projekt? Heisst es inzwischen anders?
*
* Die Gegenrichtung - ein Item ohne Absatz - ist KEIN Befund. Nicht jedes
* Vorhaben braucht eine Herleitung; die meisten sind eine Zeile wert und
* mehr nicht.
*
* @return array{ok:bool,findings:list<array<string,string>>,checked:int,stale:?string}
*/
public function check(string $projectSlug, string $file): array
{
$findings = [];
if (!is_file($file)) {
return [
'ok' => false,
'findings' => [[
'level' => 'error',
'code' => 'file_missing',
'message' => sprintf('Datei "%s" nicht gefunden.', $file),
]],
'checked' => 0,
'stale' => null,
];
}
$content = (string)file_get_contents($file);
// Zustandsblock ausklammern - die dortigen IDs stammen aus derselben
// Abfrage und wuerden jede Pruefung trivial bestehen lassen.
$prose = $content;
$start = strpos($content, self::BLOCK_START);
$end = strpos($content, self::BLOCK_END);
if ($start !== false && $end !== false && $end > $start) {
$prose = substr($content, 0, $start) . substr($content, $end + strlen(self::BLOCK_END));
}
$stale = self::blockAge($content);
preg_match_all(self::ANCHOR_PATTERN, $prose, $matches);
$ids = array_values(array_unique(array_map('intval', $matches[1] ?? [])));
if ($ids === []) {
return ['ok' => true, 'findings' => [], 'checked' => 0, 'stale' => $stale];
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $this->db->prepare('
SELECT id, project_slug, title, status, epic
FROM bugtracker_items WHERE id IN (' . $placeholders . ')
');
$stmt->execute($ids);
$known = [];
foreach ($stmt->fetchAll() ?: [] as $row) {
$known[(int)$row['id']] = $row;
}
foreach ($ids as $id) {
if (!isset($known[$id])) {
$findings[] = [
'level' => 'error',
'code' => 'unknown_item',
'message' => sprintf(
'#%d wird im Text erwaehnt, existiert aber nicht. '
. 'Item geloescht oder Nummer vertippt.',
$id
),
];
continue;
}
$item = $known[$id];
if ((string)$item['project_slug'] !== $projectSlug) {
$findings[] = [
'level' => 'error',
'code' => 'foreign_item',
'message' => sprintf(
'#%d gehoert zu Projekt "%s", die Datei beschreibt "%s". '
. 'Verwechslung oder ein Verweis, der als solcher gekennzeichnet gehoert.',
$id,
(string)$item['project_slug'],
$projectSlug
),
];
continue;
}
if ($item['epic'] === null || (string)$item['epic'] === '') {
$findings[] = [
'level' => 'warning',
'code' => 'not_on_roadmap',
'message' => sprintf(
'#%d hat einen Absatz, steht aber nicht auf der Roadmap (kein Bereich). '
. 'Entweder Bereich setzen oder den Absatz ins Archiv.',
$id
),
];
}
// Ueberschrift gegen Titel. Nur eine Warnung: Eine Ueberschrift
// darf kuerzer sein als der Item-Titel. Gemeldet wird, wenn sie
// gar nichts mehr gemeinsam haben.
if (!self::headingMatches($prose, $id, (string)$item['title'])) {
$findings[] = [
'level' => 'warning',
'code' => 'title_drift',
'message' => sprintf(
'#%d heisst im DC "%s" - die Ueberschrift in der Datei sagt etwas anderes.',
$id,
(string)$item['title']
),
];
}
}
// Zurueckgestelltes und Verworfenes gehoert in die Prosa, damit die
// Begruendung mitgeht. Fehlt dort der Absatz, steht die Entscheidung
// nur als Statuswert da - und der naechste Agent schlaegt es erneut vor.
foreach ($this->undocumentedDecisions($projectSlug) as $row) {
if (in_array((int)$row['id'], $ids, true)) {
continue;
}
$findings[] = [
'level' => 'warning',
'code' => 'decision_undocumented',
'message' => sprintf(
'#%d ist "%s", aber in der Datei steht keine Begruendung. '
. 'Genau die ist der Wert des Eintrags.',
(int)$row['id'],
self::STATUS_LABELS[(string)$row['status']] ?? (string)$row['status']
),
];
}
$hasError = false;
foreach ($findings as $finding) {
if ($finding['level'] === 'error') {
$hasError = true;
break;
}
}
return [
'ok' => !$hasError,
'findings' => $findings,
'checked' => count($ids),
'stale' => $stale,
];
}
/**
* Zurueckgestellte und verworfene Vorhaben eines Projekts.
*
* @return list<array<string,mixed>>
*/
private function undocumentedDecisions(string $projectSlug): array
{
$stmt = $this->db->prepare('
SELECT id, title, status FROM bugtracker_items
WHERE project_slug = :slug
AND epic IS NOT NULL AND epic <> ""
AND status IN ("deferred","rejected")
ORDER BY id ASC
');
$stmt->execute([':slug' => $projectSlug]);
return $stmt->fetchAll() ?: [];
}
/**
* Prueft, ob eine Ueberschrift zu "#<id>" noch etwas mit dem Titel zu tun hat.
*
* Bewusst grosszuegig: Ein Wort mit mindestens vier Zeichen reicht.
* Strenger waere unbrauchbar - "Symbol-Upload und dc_symbolicate" und
* "Symbol-Upload" sollen als dasselbe durchgehen.
*/
private static function headingMatches(string $prose, int $id, string $title): bool
{
if (!preg_match('/^#{1,6}[^\n]*#' . $id . '\b([^\n]*)$/m', $prose, $m)) {
return true; // kein Ueberschriften-Verweis, nur eine Erwaehnung im Text
}
$heading = mb_strtolower($m[1]);
$words = preg_split('/[^\p{L}\p{N}_]+/u', mb_strtolower($title)) ?: [];
foreach ($words as $word) {
if (mb_strlen($word) >= 4 && str_contains($heading, $word)) {
return true;
}
}
return $words === [];
}
/**
* Liest den Zeitstempel aus dem Blockmarker.
*
* Ein veralteter Block ist kein Fehler - er ist eine Kopie, und Kopien
* altern. Gemeldet wird er trotzdem, damit niemand eine Woche alte Liste
* fuer den Stand von heute haelt.
*/
private static function blockAge(string $content): ?string
{
if (!preg_match('/' . preg_quote(self::BLOCK_START, '/') . '\s+stand=(\S+)/', $content, $m)) {
return null;
}
return $m[1];
}
/** Macht einen Text tabellensicher: keine Zeilenumbrueche, keine rohen Pipes. */
private static function inlineText(string $value): string
{
$value = preg_replace('/\s+/u', ' ', trim($value)) ?? $value;
$value = str_replace('|', '\\|', $value);
return mb_strlen($value) > 120 ? mb_substr($value, 0, 117) . '...' : $value;
}
}
+497
View File
@@ -0,0 +1,497 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\License;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Modules\UpdateService\ReleaseGuard;
use PDO;
use RuntimeException;
/**
* Selbstbedienung fuer Entwicklungs-Lizenzschluessel.
*
* Ein Agent auf der Testmaschine braucht einen gueltigen Lizenzschluessel, um
* ueberhaupt etwas pruefen zu koennen: Die Lizenzpruefung selbst haengt daran,
* und seit 2.4 auch der Zugang zur Release-Ablage (siehe ReleaseGuard). Bis
* hierher gab es dafuer nur den Weg ueber das WebUI - ein Mensch legt einen
* Schluessel an und reicht ihn weiter.
*
* Das hat drei Nachteile, die alle denselben Kern haben: Der Schluessel wird
* von Hand verteilt.
*
* 1. Er wandert durch Chatverlaeufe und Konfigurationsdateien und bleibt
* dort liegen, weil ihn niemand zurueckzieht.
* 2. Er laeuft nie ab, weil beim Anlegen niemand weiss, wie lange getestet
* wird.
* 3. Er ist von einem Kundenschluessel nicht zu unterscheiden - in der
* Aktivierungsliste steht Testhardware neben echten Installationen.
*
* Ein selbst abgeholter Schluessel loest alle drei: Er ist befristet, er traegt
* eine erkennbare Marke, und er gehoert genau einer Maschine.
*
* ABGRENZUNG: Das hier ist kein zweites Lizenzsystem. Ein DEV-Schluessel ist
* eine ganz gewoehnliche Zeile in license_licenses - dieselbe Pruefung,
* dieselbe Aktivierungsgrenze, derselbe Release-Zugang. Er unterscheidet sich
* nur in drei Werten: befristet, wenige Aktivierungen, Marke in
* customer_email. Waere er ein Sonderfall im Lizenzpfad, pruefte der Test
* anschliessend etwas anderes als der Betrieb - und genau das soll er nicht.
*/
final class DevKeys
{
/**
* Marke, an der ein DEV-Schluessel erkennbar ist.
*
* Bewusst in customer_email und nicht in einer eigenen Spalte: Die
* Unterscheidung ist eine Betriebsangabe, keine fachliche Eigenschaft der
* Lizenz. Eine Spalte haette eine Migration, eine Anzeige und eine Regel in
* jeder Abfrage nach sich gezogen, die den Wert haette beruecksichtigen
* muessen - fuer eine Information, die in der Verwaltungsansicht ohnehin
* gelesen werden soll. Die Adresse zeigt auf .invalid; sie ist per RFC 2606
* garantiert nicht zustellbar, es kann also nie jemand versehentlich
* angeschrieben werden.
*/
public const MARKER_EMAIL = 'dev-agent@testumgebung.invalid';
/** Vorgabe-Laufzeit in Tagen, ueberschreibbar ueber dc_settings. */
private const DEFAULT_TTL_DAYS = 30;
/** Aeusserste Laufzeit. Wer laenger testet, holt sich den Schluessel erneut. */
private const MAX_TTL_DAYS = 90;
/** Aktivierungen je DEV-Schluessel: Sandkasten, Dienst, Wiederholung. */
private const DEFAULT_MAX_ACTIVATIONS = 3;
/**
* Obergrenze gleichzeitig gueltiger DEV-Schluessel je Projekt.
*
* Ohne Grenze legt eine Schleife in einem fehlerhaften Testskript in einer
* Nacht tausende Zeilen an, und die .htpasswd des Produkts waechst mit -
* jede davon ein bcrypt-Hash, den Apache bei jedem Download durchgeht.
*/
private const MAX_PER_PROJECT = 12;
/**
* Abgelaufene Schluessel werden nach dieser Frist geloescht.
*
* Nicht sofort: Wer am Morgen einen fehlgeschlagenen Lauf der Vornacht
* nachvollzieht, soll in der Aktivierungsliste noch sehen, welche Hardware
* sich womit gemeldet hat.
*/
private const PURGE_GRACE_DAYS = 14;
/**
* Holt den DEV-Schluessel dieser Maschine fuer dieses Projekt.
*
* Der Aufruf ist absichtlich wiederholbar: Gibt es fuer die Kombination aus
* Projekt und Kennung bereits einen gueltigen Schluessel, kommt derselbe
* zurueck und seine Frist wird verlaengert. Ein Testskript kann den Aufruf
* damit an den Anfang jedes Laufs setzen, ohne bei jedem Lauf einen
* weiteren Schluessel zu hinterlassen.
*
* @return array{license_key:string,project_slug:string,identity:string,
* expires_at:string,max_activations:int,created:bool,
* renewed:bool,activations_used:int}
*
* @throws RuntimeException bei unbekanntem Projekt oder erreichter Grenze
*/
public static function issue(
PDO $db,
string $projectSlug,
string $identity,
?int $ttlDays,
string $actor
): array {
$identity = self::normalizeIdentity($identity);
$ttlDays = self::resolveTtlDays($db, $ttlDays);
$project = self::findProject($db, $projectSlug);
if ($project === null) {
throw new RuntimeException(sprintf('Projekt "%s" ist nicht bekannt.', $projectSlug));
}
// Erst aufraeumen, dann zaehlen: Sonst blockieren Schluessel von
// Maschinen, die es laengst nicht mehr gibt, den Platz fuer neue.
self::purgeExpired($db, (int)$project['id']);
$existing = self::findActive($db, (int)$project['id'], $identity);
$expires = gmdate('Y-m-d H:i:s', time() + $ttlDays * 86400);
if ($existing !== null) {
$db->prepare('
UPDATE license_licenses
SET expires_at = :expires, status = "active", notes = :notes
WHERE id = :id
')->execute([
':expires' => $expires,
':notes' => self::noteFor($identity, $actor),
':id' => $existing['id'],
]);
self::refreshGuard($db, $project['slug']);
Audit::log($db, $actor, 'license.dev_key.renew', [
'project' => $project['slug'],
'identity' => $identity,
'until' => $expires,
]);
return self::describe($db, $existing, $project['slug'], $identity, $expires, false, true);
}
$active = self::countActive($db, (int)$project['id']);
if ($active >= self::MAX_PER_PROJECT) {
throw new RuntimeException(sprintf(
'Fuer "%s" sind bereits %d DEV-Schluessel vergeben. Nicht mehr benoetigte zuerst '
. 'zurueckgeben (action=revoke) oder ablaufen lassen.',
$project['slug'],
$active
));
}
// Bei einer Kollision auf dem UNIQUE-Index einfach neu wuerfeln. Bei
// 32^25 moeglichen Schluesseln passiert das praktisch nie - aber "passiert
// praktisch nie" ist kein Grund, den Fall als Absturz enden zu lassen.
$inserted = null;
for ($attempt = 0; $attempt < 5; $attempt++) {
$key = KeyGen::generateKey();
try {
$db->prepare('
INSERT INTO license_licenses
(product_id, license_key, customer_name, customer_email,
max_activations, expires_at, notes)
VALUES (:pid, :key, :cname, :cemail, :max, :expires, :notes)
')->execute([
':pid' => $project['id'],
':key' => $key,
':cname' => self::customerNameFor($identity),
':cemail' => self::MARKER_EMAIL,
':max' => self::maxActivations($db),
':expires' => $expires,
':notes' => self::noteFor($identity, $actor),
]);
$inserted = ['id' => (int)$db->lastInsertId(), 'license_key' => $key];
break;
} catch (\PDOException $e) {
if (!self::isDuplicateKey($e)) {
throw $e;
}
}
}
if ($inserted === null) {
throw new RuntimeException('Es liess sich kein freier Lizenzschluessel erzeugen.');
}
self::refreshGuard($db, $project['slug']);
Audit::log($db, $actor, 'license.dev_key.issue', [
'project' => $project['slug'],
'identity' => $identity,
'until' => $expires,
]);
return self::describe($db, $inserted, $project['slug'], $identity, $expires, true, false);
}
/**
* Gibt den DEV-Schluessel dieser Maschine zurueck.
*
* Loeschen statt widerrufen: Ein widerrufener Schluessel bliebe in der
* Liste stehen und muesste spaeter von jemandem beurteilt werden, der nicht
* mehr weiss, worum es ging. Die zugehoerigen Aktivierungen gehen ueber den
* Fremdschluessel mit.
*/
public static function revoke(PDO $db, string $projectSlug, string $identity, string $actor): bool
{
$identity = self::normalizeIdentity($identity);
$project = self::findProject($db, $projectSlug);
if ($project === null) {
throw new RuntimeException(sprintf('Projekt "%s" ist nicht bekannt.', $projectSlug));
}
$existing = self::findActive($db, (int)$project['id'], $identity);
if ($existing === null) {
return false;
}
$db->prepare('DELETE FROM license_licenses WHERE id = :id')
->execute([':id' => $existing['id']]);
self::refreshGuard($db, $project['slug']);
Audit::log($db, $actor, 'license.dev_key.revoke', [
'project' => $project['slug'],
'identity' => $identity,
]);
return true;
}
/**
* Loescht abgelaufene DEV-Schluessel eines Projekts.
*
* Laeuft bei jeder Ausgabe mit, damit kein zusaetzlicher Cron-Eintrag
* noetig ist. Kundenschluessel bleiben unberuehrt - die Marke in
* customer_email ist Bedingung der Abfrage.
*/
public static function purgeExpired(PDO $db, int $productId): int
{
$stmt = $db->prepare("
DELETE FROM license_licenses
WHERE product_id = :pid
AND customer_email = :marker
AND expires_at IS NOT NULL
AND expires_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL " . self::PURGE_GRACE_DAYS . " DAY)
");
$stmt->execute([
':pid' => $productId,
':marker' => self::MARKER_EMAIL,
]);
return $stmt->rowCount();
}
/**
* Alle gueltigen DEV-Schluessel eines Projekts - ohne die Schluessel selbst.
*
* Wer wissen will, welche Maschinen gerade testen, braucht dafuer keine
* Schluesselwerte. Herausgegeben wird ein Schluessel nur an die Maschine,
* die ihn angefordert hat.
*
* @return list<array{identity:string,expires_at:?string,activations_used:int,max_activations:int}>
*/
public static function listFor(PDO $db, string $projectSlug): array
{
$project = self::findProject($db, $projectSlug);
if ($project === null) {
return [];
}
$stmt = $db->prepare('
SELECT l.id, l.customer_name, l.expires_at, l.max_activations,
(SELECT COUNT(*) FROM license_activations a WHERE a.license_id = l.id) AS used
FROM license_licenses l
WHERE l.product_id = :pid
AND l.customer_email = :marker
AND l.status = "active"
AND (l.expires_at IS NULL OR l.expires_at > UTC_TIMESTAMP())
ORDER BY l.created_at
');
$stmt->execute([':pid' => $project['id'], ':marker' => self::MARKER_EMAIL]);
$out = [];
foreach ($stmt->fetchAll() ?: [] as $row) {
$out[] = [
'identity' => self::identityFromCustomerName((string)$row['customer_name']),
'expires_at' => $row['expires_at'] !== null ? (string)$row['expires_at'] : null,
'activations_used' => (int)$row['used'],
'max_activations' => (int)$row['max_activations'],
];
}
return $out;
}
// ------------------------------------------------------------------
// Innereien
// ------------------------------------------------------------------
/**
* Macht aus einer beliebigen Kennung eine, die sich wiederfinden laesst.
*
* Die Kennung ist der Schluessel, ueber den ein wiederholter Aufruf
* denselben Lizenzschluessel zurueckbekommt. Sie muss deshalb stabil sein:
* Grossschreibung, Leerzeichen und Domaenensuffixe eines Rechnernamens
* duerfen nicht zu einem zweiten Schluessel fuehren.
*/
private static function normalizeIdentity(string $identity): string
{
$identity = strtolower(trim($identity));
// Ein FQDN und der kurze Name sind dieselbe Maschine.
if (str_contains($identity, '.') && !str_starts_with($identity, '.')) {
$head = strstr($identity, '.', true);
if ($head !== false && strlen($head) >= 3) {
$identity = $head;
}
}
$identity = preg_replace('/[^a-z0-9._-]+/', '-', $identity) ?? '';
$identity = trim($identity, '-');
if ($identity === '') {
throw new RuntimeException('Es wurde keine brauchbare Maschinenkennung uebergeben.');
}
return substr($identity, 0, 48);
}
private static function resolveTtlDays(PDO $db, ?int $requested): int
{
$configured = self::setting($db, 'license.dev_ttl_days', self::DEFAULT_TTL_DAYS);
$days = $requested ?? $configured;
return max(1, min(self::MAX_TTL_DAYS, $days));
}
private static function maxActivations(PDO $db): int
{
return max(1, min(25, self::setting($db, 'license.dev_max_activations', self::DEFAULT_MAX_ACTIVATIONS)));
}
private static function setting(PDO $db, string $key, int $default): int
{
try {
$stmt = $db->prepare('SELECT svalue FROM dc_settings WHERE skey = :k LIMIT 1');
$stmt->execute([':k' => $key]);
$value = $stmt->fetchColumn();
if ($value !== false && $value !== null && is_numeric((string)$value)) {
return (int)$value;
}
} catch (\Throwable $e) {
// Fehlt die Tabelle oder der Eintrag, gilt die Vorgabe.
}
return $default;
}
/** @return array{id:int,slug:string}|null */
private static function findProject(PDO $db, string $slug): ?array
{
$stmt = $db->prepare('SELECT id, slug FROM dc_projects WHERE slug = :slug LIMIT 1');
$stmt->execute([':slug' => trim($slug)]);
$row = $stmt->fetch();
return $row === false ? null : ['id' => (int)$row['id'], 'slug' => (string)$row['slug']];
}
/** @return array{id:int,license_key:string}|null */
private static function findActive(PDO $db, int $productId, string $identity): ?array
{
$stmt = $db->prepare('
SELECT id, license_key
FROM license_licenses
WHERE product_id = :pid
AND customer_email = :marker
AND customer_name = :cname
AND status = "active"
ORDER BY id DESC
LIMIT 1
');
$stmt->execute([
':pid' => $productId,
':marker' => self::MARKER_EMAIL,
':cname' => self::customerNameFor($identity),
]);
$row = $stmt->fetch();
return $row === false ? null : ['id' => (int)$row['id'], 'license_key' => (string)$row['license_key']];
}
private static function countActive(PDO $db, int $productId): int
{
$stmt = $db->prepare('
SELECT COUNT(*)
FROM license_licenses
WHERE product_id = :pid
AND customer_email = :marker
AND status = "active"
AND (expires_at IS NULL OR expires_at > UTC_TIMESTAMP())
');
$stmt->execute([':pid' => $productId, ':marker' => self::MARKER_EMAIL]);
return (int)$stmt->fetchColumn();
}
/**
* Der angezeigte Name traegt die Kennung - so ist in der
* Verwaltungsansicht ohne Nachschlagen zu sehen, welche Maschine gemeint
* ist, und die Zuordnung braucht keine zusaetzliche Spalte.
*/
private static function customerNameFor(string $identity): string
{
return 'DEV ' . $identity;
}
private static function identityFromCustomerName(string $name): string
{
return str_starts_with($name, 'DEV ') ? substr($name, 4) : $name;
}
private static function noteFor(string $identity, string $actor): string
{
return sprintf(
"Automatisch ausgestellter Entwicklungsschluessel.\nMaschine: %s\nAusgestellt an: %s\nZuletzt: %s UTC",
$identity,
$actor,
gmdate('Y-m-d H:i:s')
);
}
/**
* @param array{id:int,license_key:string} $license
* @return array{license_key:string,project_slug:string,identity:string,expires_at:string,
* max_activations:int,created:bool,renewed:bool,activations_used:int}
*/
private static function describe(
PDO $db,
array $license,
string $slug,
string $identity,
string $expires,
bool $created,
bool $renewed
): array {
$stmt = $db->prepare('
SELECT max_activations,
(SELECT COUNT(*) FROM license_activations a WHERE a.license_id = l.id) AS used
FROM license_licenses l WHERE l.id = :id
');
$stmt->execute([':id' => $license['id']]);
$row = $stmt->fetch() ?: ['max_activations' => 0, 'used' => 0];
return [
'license_key' => $license['license_key'],
'project_slug' => $slug,
'identity' => $identity,
'expires_at' => $expires,
'max_activations' => (int)$row['max_activations'],
'activations_used' => (int)$row['used'],
'created' => $created,
'renewed' => $renewed,
];
}
/**
* Traegt den Schluessel in die .htpasswd des Produkts ein.
*
* Ohne diesen Schritt gaebe es einen gueltigen Schluessel, mit dem sich
* kein Paket herunterladen laesst - und der Test scheiterte an 401 statt an
* dem, was er pruefen soll. Ein Fehlschlag hier darf die Ausgabe nicht
* scheitern lassen: Der Schluessel ist ausgestellt, und der Abgleich in
* ReleaseGuard::reconcile() holt die Datei spaeter nach.
*/
private static function refreshGuard(PDO $db, string $slug): void
{
try {
ReleaseGuard::regenerateForProject($db, $slug);
} catch (\Throwable $e) {
Logger::warning('Release-Zugangsschutz nach DEV-Schluessel nicht erneuert', [
'project' => $slug,
'error' => $e->getMessage(),
]);
}
}
private static function isDuplicateKey(\PDOException $e): bool
{
return ($e->errorInfo[1] ?? null) === 1062;
}
}
+213
View File
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Supervisor;
/**
* Der Detektor-Katalog.
*
* Legt fest, wonach der Supervisor sucht - unter Namen, die in jedem Projekt
* dieselben sind. Das ist der eigentliche Zweck: Befunde aus verschiedenen
* Anwendungen muessen vergleichbar bleiben, sonst ist die Sammlung nur ein
* Haufen Text.
*
* Ein Name hat die Form "familie.sache" und wandert nie mehr. Aendert sich,
* wonach gesucht wird, bekommt es einen neuen Namen - ein umbenannter Detektor
* zerreisst jede Auswertung, die ueber den Zeitpunkt der Umbenennung
* hinausgeht.
*
* Zur Einordnung, warum das mehr ist als "Unit-Tests zur Laufzeit": Ein Test
* prueft bekannte Logik gegen bekannte Eingaben in einer kontrollierten
* Umgebung. Die Detektoren hier pruefen gegen unbekannte Eingaben in einer
* unbekannten Umgebung, ueber Zeitraeume, die ein Testlauf nicht herstellen
* kann, und - die Gruppe NOVEL/SILENCE/DEVIATION - ohne den gesuchten Fehler
* vorher zu kennen.
*/
final class Catalog
{
/**
* Woher kommt ein Befund?
*
* SERVER - hier ermittelt, aus Daten, die ohnehin hereinkommen
* (Fehlerberichte, Heartbeats, Kennzahlen). Laeuft von selbst.
*
* CLIENT - von aussen ueber POST /api/supervisor/v1/observe eingeliefert.
* Der Name ist historisch: Gedacht war urspruenglich ein
* Beobachter im ausgelieferten Programm. Den gibt es nicht mehr -
* er haette zu viele Baustellen aufgemacht, und Error-Reporting
* und Bugtracker decken den Zweck ohnehin ab. Wer heute
* einliefert, sind Pruefwerkzeuge und Testlaeufe.
*
* Der Wert "client" bleibt, weil er so in supervisor_findings.origin
* steht; eine Umbenennung waere eine Migration ohne Gegenwert.
*/
public const ORIGIN_CLIENT = 'client';
public const ORIGIN_SERVER = 'server';
/**
* Der Katalog.
*
* origin - wer den Befund erzeugt
* needs - was der Detektor braucht, damit er ueberhaupt etwas sagen kann
* finds - wonach er sucht, in einem Satz
*
* @var array<string,array{origin:string,finds:string,needs:string}>
*/
public const DETECTORS = [
// ------------------------------------------------------------------
// Findet Dinge, an die niemand gedacht hat
// ------------------------------------------------------------------
// Diese Gruppe braucht kein Vorwissen ueber den konkreten Fehler. Der
// Massstab kommt nicht aus einem Kopf, sondern aus der Wirklichkeit:
// aus dem eigenen Verlauf, aus der Vorversion, aus den anderen
// Installationen derselben Fassung.
'novel.error_signature' => [
'origin' => self::ORIGIN_SERVER,
'finds' => 'Fehlersignatur, die es in dieser Fassung noch nie gab',
'needs' => 'Fehler-Stream (bugtracker_items mit error_level)',
],
'novel.log_template' => [
'origin' => self::ORIGIN_SERVER,
'finds' => 'Logzeilen-Schablone, die noch nie auftrat',
'needs' => 'Logauszuege im Fehler-Stream',
],
'silence.periodic' => [
'origin' => self::ORIGIN_CLIENT,
'finds' => 'Bekanntes wiederkehrendes Ereignis bleibt aus - der still gestorbene Hintergrundjob',
'needs' => 'Ein Melder, der das Ereignis bestaetigt, solange es stattfindet',
],
'silence.heartbeat' => [
'origin' => self::ORIGIN_SERVER,
'finds' => 'Instanz meldet sich nicht mehr',
'needs' => 'Watchdog-Monitor',
],
'deviation.self' => [
'origin' => self::ORIGIN_SERVER,
'finds' => 'Abweichung vom eigenen Verlauf der letzten Tage',
'needs' => 'Kennzahlen im MetricStore, mindestens 30 Messpunkte aelter als zwei Stunden',
],
'deviation.fleet' => [
'origin' => self::ORIGIN_SERVER,
'finds' => 'Ausreisser gegenueber anderen Installationen derselben Fassung',
'needs' => 'mehrere Installationen mit derselben app_version',
],
'regression.build' => [
'origin' => self::ORIGIN_SERVER,
'finds' => 'Kennzahl verschiebt sich nach einem Update gegenueber der Vorgaengerfassung',
'needs' => 'app_version am Monitor und Kennzahlen ueber den Rollout hinweg',
],
// ------------------------------------------------------------------
// Die Zeitachse
// ------------------------------------------------------------------
// Ein Test laeuft Millisekunden auf frischem Zustand, ein
// Kundenprozess Wochen. Alles, was sich ansammelt, ist fuer Tests
// strukturell unsichtbar.
'trend.monotonic' => [
'origin' => self::ORIGIN_CLIENT,
'finds' => 'Kennzahl waechst ueber mehrere Fenster nur nach oben - Speicher-, Handle-, Cache-Leck',
'needs' => 'Eine Messreihe ueber Stunden, von wem auch immer gemeldet',
],
'trend.saturation' => [
'origin' => self::ORIGIN_CLIENT,
'finds' => 'Kennzahl naehert sich einer harten Grenze - Platte, Verbindungspool, Dateihandles',
'needs' => 'bekannte Obergrenze',
],
'trend.latency' => [
'origin' => self::ORIGIN_CLIENT,
'finds' => 'Antwortzeiten driften ueber Tage nach oben - etwa Index-Degradation',
'needs' => 'Zeitmessung an einer wiederkehrenden Operation, regelmaessig gemeldet',
],
// ------------------------------------------------------------------
// Stellen, an denen still verziehen wird
// ------------------------------------------------------------------
// Der ergiebigste und billigste Fang: Zaehler auf die Stellen, an
// denen die Software heute schon still verzeiht. Dort sitzen die
// Fehler, die niemand meldet.
'swallowed.exception' => [
'origin' => self::ORIGIN_CLIENT,
'finds' => 'catch-Bloecke, die einen Fehler schlucken und weitermachen',
'needs' => 'Ein Melder an den catch-Bloecken, die einen Fehler schlucken',
],
'fallback.usage' => [
'origin' => self::ORIGIN_CLIENT,
'finds' => 'Rueckfall auf den degradierten Pfad haeuft sich',
'needs' => 'Ein Melder im Ausweichzweig',
],
'retry.storm' => [
'origin' => self::ORIGIN_CLIENT,
'finds' => 'Wiederholungsversuche haeufen sich',
'needs' => 'Ein Melder an der Wiederholung',
],
'probe.failed' => [
'origin' => self::ORIGIN_CLIENT,
'finds' => 'Registrierte Probe schlaegt fehl',
'needs' => 'Ein Pruefwerkzeug, das die Pruefung ausfuehrt und das Ergebnis meldet',
],
'probe.slow' => [
'origin' => self::ORIGIN_CLIENT,
'finds' => 'Probe antwortet, aber deutlich langsamer als ueblich',
'needs' => 'Wie probe.failed, zusaetzlich mit Zeitmessung',
],
// ------------------------------------------------------------------
// Fachliche Zusicherungen
// ------------------------------------------------------------------
// Die einzige Gruppe, die tatsaechlich "Unit-Test zur Laufzeit" ist -
// und fuer ihren Zweck ist genau das richtig. Sie deckt die Klasse ab,
// die sonst komplett durchfaellt: semantisch falsche, aber
// unauffaellige Ergebnisse. Der Bericht zeigt eine falsche Zahl, die
// Rundung stimmt seit Monaten nicht, ein Mandant sieht Daten eines
// anderen. Nichts stuerzt ab, keine Kennzahl weicht ab, keine Signatur
// ist neu - kein anderer Detektor sieht das.
'invariant.violated' => [
'origin' => self::ORIGIN_CLIENT,
'finds' => 'Vom Projekt formulierte Zusicherung gegen echte Daten verletzt',
'needs' => 'Ein Melder, der die Zusicherung gegen echte Daten prueft',
],
];
/** @return list<string> */
public static function names(): array
{
return array_keys(self::DETECTORS);
}
public static function isKnown(string $detector): bool
{
return isset(self::DETECTORS[$detector]);
}
/**
* Wo entsteht dieser Befund normalerweise?
*
* Unbekannte Detektoren gelten als clientseitig - ein Projekt darf einen
* eigenen Namen melden, ohne dass der Katalog dafuer erweitert werden
* muss. Es taucht dann in der Auswertung als eigene Reihe auf und faellt
* beim Durchsehen auf, statt still verworfen zu werden.
*/
public static function originOf(string $detector): string
{
return self::DETECTORS[$detector]['origin'] ?? self::ORIGIN_CLIENT;
}
/**
* Nur die Detektoren einer Herkunft.
*
* @return array<string,array{origin:string,finds:string,needs:string}>
*/
public static function byOrigin(string $origin): array
{
return array_filter(
self::DETECTORS,
static fn(array $meta): bool => $meta['origin'] === $origin
);
}
}
+462
View File
@@ -0,0 +1,462 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Supervisor;
use Deploymentcenter\Core\Logger;
use PDO;
/**
* Aufnahme und Abfrage der Supervisor-Befunde.
*
* Ein Befund behauptet nicht, dass etwas kaputt ist, sondern dass etwas
* ungewoehnlich ist. Deshalb loest er auch keine Benachrichtigung aus - anders
* als ein Bugtracker-Item. Wer bei jeder Auffaelligkeit alarmiert, hat nach
* einer Woche einen Kanal, den niemand mehr liest.
*/
final class FindingRepo
{
public const STATUSES = ['open', 'acknowledged', 'promoted', 'resolved', 'ignored'];
public const SEVERITY_HINTS = ['idea', 'wishlist', 'low', 'medium', 'high', 'critical'];
public const ENVIRONMENTS = ['production', 'development', 'staging', 'testing'];
/** Status, in denen ein Befund noch Aufmerksamkeit verdient. */
public const ACTIVE_STATUSES = ['open', 'acknowledged'];
/**
* Aufbewahrung in Tagen.
*
* Deutlich kuerzer als bei Bugtracker-Items: Befunde entstehen um
* Groessenordnungen haeufiger, und ein drei Monate alter Ausreisser einer
* Kennzahl beantwortet keine Frage mehr. Vorbild ist MetricStore::purge().
*/
private const RETENTION_DAYS = 30;
private const DEFAULT_LIMIT = 50;
private const MAX_LIMIT = 500;
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
// ==================================================================
// Aufnahme
// ==================================================================
/**
* Nimmt einen Befund entgegen.
*
* Idempotent ueber client_ref: derselbe Befund darf beliebig oft gemeldet
* werden. Ein Beobachter, der minuetlich nachsieht, erzeugt so einen
* Eintrag mit hohem Zaehler statt 1.440 Eintraegen am Tag.
*
* @param array<string,mixed> $data
* @return array<string,mixed>
*/
public function record(array $data): array
{
$projectSlug = self::text($data['project_slug'] ?? null) ?? 'default';
$detector = self::text($data['detector'] ?? null);
$subject = self::text($data['subject'] ?? null) ?? '(ohne Gegenstand)';
if ($detector === null) {
throw new \InvalidArgumentException('Das Feld "detector" wird benoetigt.');
}
$instance = self::text($data['instance'] ?? null) ?? 'default';
$origin = ($data['origin'] ?? null) === Catalog::ORIGIN_SERVER
? Catalog::ORIGIN_SERVER
: Catalog::ORIGIN_CLIENT;
$environment = self::oneOf($data['environment'] ?? null, self::ENVIRONMENTS, 'production');
$hint = self::oneOfOrNull($data['severity_hint'] ?? null, self::SEVERITY_HINTS);
$build = self::text($data['build'] ?? $data['build_version'] ?? null);
$commitSha = self::text($data['commit_sha'] ?? null);
$observed = self::jsonOrNull($data['observed'] ?? null);
$baseline = self::jsonOrNull($data['baseline'] ?? null);
// Fehlt der Idempotenz-Schluessel, wird er aus dem gebaut, was den
// Befund ausmacht. Ein zufaelliger Wert waere hier das Schlimmste: er
// sieht aus wie Idempotenz und legt trotzdem jedes Mal neu an.
$clientRef = self::text($data['client_ref'] ?? null)
?? self::buildClientRef($detector, $subject, $instance, $build);
$clientRef = mb_substr($clientRef, 0, 190);
$stmt = $this->db->prepare('
INSERT INTO supervisor_findings (
project_slug, instance, detector, subject, origin,
severity_hint, observed_json, baseline_json,
build, commit_sha, environment, status, occurrence_count,
client_ref, first_seen_at, last_seen_at
) VALUES (
:project_slug, :instance, :detector, :subject, :origin,
:severity_hint, :observed, :baseline,
:build, :commit_sha, :environment, "open", 1,
:client_ref, UTC_TIMESTAMP(), UTC_TIMESTAMP()
)
ON DUPLICATE KEY UPDATE
occurrence_count = occurrence_count + 1,
last_seen_at = UTC_TIMESTAMP(),
observed_json = VALUES(observed_json),
baseline_json = VALUES(baseline_json),
build = COALESCE(VALUES(build), build),
commit_sha = COALESCE(VALUES(commit_sha), commit_sha),
severity_hint = COALESCE(VALUES(severity_hint), severity_hint),
-- Ein als erledigt markierter Befund, der wieder auftritt, ist
-- eine Regression und gehoert zurueck in den Eingang.
-- "ignored" bleibt dagegen stehen: das war eine Entscheidung.
status = IF(status = "resolved", "open", status)
');
$stmt->execute([
':project_slug' => $projectSlug,
':instance' => mb_substr($instance, 0, 100),
':detector' => mb_substr($detector, 0, 64),
':subject' => mb_substr($subject, 0, 190),
':origin' => $origin,
':severity_hint' => $hint,
':observed' => $observed,
':baseline' => $baseline,
':build' => $build,
':commit_sha' => $commitSha,
':environment' => $environment,
':client_ref' => $clientRef,
]);
// rowCount ist 1 bei Neuanlage, 2 bei einer Aktualisierung durch
// ON DUPLICATE KEY - so unterscheidet MySQL die beiden Faelle.
$isNew = $stmt->rowCount() === 1;
$existing = $this->findByClientRef($projectSlug, $clientRef);
return [
'id' => $existing !== null ? (int)$existing['id'] : 0,
'is_new' => $isNew,
'detector' => $detector,
'subject' => $subject,
'occurrence_count' => $existing !== null ? (int)$existing['occurrence_count'] : 1,
'status' => $existing !== null ? (string)$existing['status'] : 'open',
'known_detector' => Catalog::isKnown($detector),
'client_ref' => $clientRef,
];
}
/**
* Wie record(), aber fuer die serverseitigen Detektoren.
*
* Sie laufen im Cron und duerfen dabei nichts umwerfen: ein Detektor, der
* eine Exception wirft, wuerde den ganzen Lauf abbrechen und damit auch
* die Detektoren dahinter stilllegen.
*
* @param array<string,mixed> $data
*/
public function recordServerSide(array $data): bool
{
try {
$this->record($data + ['origin' => Catalog::ORIGIN_SERVER]);
return true;
} catch (\Throwable $e) {
Logger::warning('Befund nicht gespeichert', [
'detector' => $data['detector'] ?? '?',
'error' => $e->getMessage(),
]);
return false;
}
}
// ==================================================================
// Abfrage
// ==================================================================
/**
* @param array<string,mixed> $filters
* @return array{count:int,total:int,findings:list<array<string,mixed>>}
*/
public function search(array $filters = []): array
{
$where = [];
$params = [];
$slug = self::text($filters['project_slug'] ?? null);
if ($slug !== null && $slug !== 'all') {
$where[] = 'project_slug = :slug';
$params[':slug'] = $slug;
}
$detector = self::text($filters['detector'] ?? null);
if ($detector !== null && $detector !== 'all') {
// Praefix erlaubt: "trend" findet trend.monotonic, trend.saturation
// und alles weitere dieser Familie.
if (!str_contains($detector, '.')) {
$where[] = 'detector LIKE :detector';
$params[':detector'] = $detector . '.%';
} else {
$where[] = 'detector = :detector';
$params[':detector'] = $detector;
}
}
$status = self::text($filters['status'] ?? null) ?? 'active';
if ($status === 'active') {
$where[] = 'status IN ("open","acknowledged")';
} elseif ($status !== 'all') {
$where[] = 'status = :status';
$params[':status'] = $status;
}
$instance = self::text($filters['instance'] ?? null);
if ($instance !== null) {
$where[] = 'instance = :instance';
$params[':instance'] = $instance;
}
$build = self::text($filters['build'] ?? null);
if ($build !== null) {
$where[] = 'build = :build';
$params[':build'] = $build;
}
$environment = self::text($filters['environment'] ?? null);
if ($environment !== null && $environment !== 'all') {
$where[] = 'environment = :environment';
$params[':environment'] = $environment;
}
$since = self::text($filters['since'] ?? null);
if ($since !== null) {
$where[] = 'last_seen_at >= :since';
$params[':since'] = $since;
}
$clause = $where === [] ? '' : ' WHERE ' . implode(' AND ', $where);
$limit = self::clampLimit($filters['limit'] ?? null);
$offset = max(0, (int)($filters['offset'] ?? 0));
$countStmt = $this->db->prepare('SELECT COUNT(*) FROM supervisor_findings' . $clause);
$countStmt->execute($params);
$total = (int)$countStmt->fetchColumn();
// Neuheit zuerst: ein Befund, der zum ersten Mal auftaucht, ist
// interessanter als einer, der seit Wochen mitlaeuft.
$stmt = $this->db->prepare('
SELECT * FROM supervisor_findings' . $clause . '
ORDER BY first_seen_at DESC, occurrence_count DESC, id DESC
LIMIT ' . $limit . ' OFFSET ' . $offset
);
$stmt->execute($params);
$rows = array_map([self::class, 'hydrate'], $stmt->fetchAll() ?: []);
return ['count' => count($rows), 'total' => $total, 'findings' => $rows];
}
/** @return array<string,mixed>|null */
public function get(int $id): ?array
{
$stmt = $this->db->prepare('SELECT * FROM supervisor_findings WHERE id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
return is_array($row) ? self::hydrate($row) : null;
}
/** @return array<string,mixed>|null */
public function findByClientRef(string $projectSlug, string $clientRef): ?array
{
$stmt = $this->db->prepare('
SELECT * FROM supervisor_findings
WHERE project_slug = :slug AND client_ref = :ref
LIMIT 1
');
$stmt->execute([':slug' => $projectSlug, ':ref' => $clientRef]);
$row = $stmt->fetch();
return is_array($row) ? $row : null;
}
/**
* Zaehlt Befunde je Detektor - der Ueberblick, mit dem man anfaengt.
*
* @return list<array{detector:string,findings:int,occurrences:int,newest:string}>
*/
public function byDetector(?string $projectSlug = null): array
{
$where = 'WHERE status IN ("open","acknowledged")';
$params = [];
if ($projectSlug !== null && $projectSlug !== 'all') {
$where .= ' AND project_slug = :slug';
$params[':slug'] = $projectSlug;
}
$stmt = $this->db->prepare('
SELECT detector,
COUNT(*) AS findings,
SUM(occurrence_count) AS occurrences,
MAX(last_seen_at) AS newest
FROM supervisor_findings ' . $where . '
GROUP BY detector
ORDER BY findings DESC, occurrences DESC
');
$stmt->execute($params);
return array_map(static fn(array $row): array => [
'detector' => (string)$row['detector'],
'findings' => (int)$row['findings'],
'occurrences' => (int)$row['occurrences'],
'newest' => (string)$row['newest'],
'finds' => Catalog::DETECTORS[$row['detector']]['finds'] ?? null,
], $stmt->fetchAll() ?: []);
}
// ==================================================================
// Pflege
// ==================================================================
public function setStatus(int $id, string $status, ?string $actor = null): bool
{
if (!in_array($status, self::STATUSES, true)) {
return false;
}
$stmt = $this->db->prepare('
UPDATE supervisor_findings
SET status = :status,
acknowledged_at = IF(:status2 = "acknowledged", UTC_TIMESTAMP(), acknowledged_at),
acknowledged_by = IF(:status3 = "acknowledged", :actor, acknowledged_by)
WHERE id = :id
');
$stmt->execute([
':status' => $status,
':status2' => $status,
':status3' => $status,
':actor' => $actor !== null ? mb_substr($actor, 0, 100) : null,
':id' => $id,
]);
return $stmt->rowCount() > 0;
}
/** Verknuepft einen Befund mit dem Item, das daraus geworden ist. */
public function promote(int $id, int $bugItemId): bool
{
$stmt = $this->db->prepare('
UPDATE supervisor_findings
SET status = "promoted", bug_item_id = :item
WHERE id = :id
');
$stmt->execute([':item' => $bugItemId, ':id' => $id]);
return $stmt->rowCount() > 0;
}
/**
* Entfernt alte Befunde.
*
* Erledigte und stummgeschaltete fliegen frueher als offene: ein offener
* Befund, der seit Wochen dasteht, ist eine unbeantwortete Frage und soll
* unbequem bleiben.
*/
public function purge(): int
{
try {
$stmt = $this->db->prepare('
DELETE FROM supervisor_findings
WHERE last_seen_at < (UTC_TIMESTAMP() - INTERVAL ' . self::RETENTION_DAYS . ' DAY)
AND status IN ("resolved","ignored","promoted")
LIMIT 10000
');
$stmt->execute();
return $stmt->rowCount();
} catch (\Throwable $e) {
Logger::warning('Befund-Bereinigung fehlgeschlagen', ['error' => $e->getMessage()]);
return 0;
}
}
// ==================================================================
// Interne Hilfsfunktionen
// ==================================================================
public static function buildClientRef(
string $detector,
string $subject,
string $instance,
?string $build
): string {
return implode('/', [$detector, $subject, $instance, $build ?? 'unknown']);
}
/** @param array<string,mixed> $row */
private static function hydrate(array $row): array
{
$row['observed'] = self::decode($row['observed_json'] ?? null);
$row['baseline'] = self::decode($row['baseline_json'] ?? null);
unset($row['observed_json'], $row['baseline_json']);
$row['finds'] = Catalog::DETECTORS[$row['detector']]['finds'] ?? null;
return $row;
}
/** @return array<string,mixed>|null */
private static function decode($value): ?array
{
if (!is_string($value) || $value === '') {
return null;
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : null;
}
private static function jsonOrNull($value): ?string
{
if (!is_array($value) && !is_object($value)) {
return null;
}
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return is_string($encoded) ? mb_substr($encoded, 0, 8000) : null;
}
private static function text($value): ?string
{
if (!is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
/** @param list<string> $allowed */
private static function oneOf($value, array $allowed, string $fallback): string
{
return is_string($value) && in_array($value, $allowed, true) ? $value : $fallback;
}
/** @param list<string> $allowed */
private static function oneOfOrNull($value, array $allowed): ?string
{
return is_string($value) && in_array($value, $allowed, true) ? $value : null;
}
private static function clampLimit($value): int
{
$limit = is_numeric($value) ? (int)$value : self::DEFAULT_LIMIT;
return max(1, min($limit, self::MAX_LIMIT));
}
}
+722
View File
@@ -0,0 +1,722 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Supervisor;
use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Logger;
use PDO;
/**
* Die Detektoren. Sie laufen hier und nirgends sonst.
*
* Sie arbeiten ausschliesslich auf Daten, die ohnehin schon hereinkommen -
* Fehlerberichte, Watchdog-Pings, Kennzahlen. Eine Anwendung muss dafuer
* nichts einbauen und kein neues SDK beziehen.
*
* Das war urspruenglich nur die erste Etappe; ein Beobachter im
* ausgelieferten Programm sollte folgen. Der ist verworfen - er haette zu
* viele Baustellen aufgemacht, und Error-Reporting und Bugtracker decken den
* Zweck ab. Was hier steht, ist damit nicht der Anfang, sondern das Ganze.
* Siehe docs/ROADMAP.md, Abschnitt H.
*
* Was sie gemeinsam haben: keiner von ihnen muss den gesuchten Fehler vorher
* kennen. Der Massstab kommt nicht aus einem Kopf, sondern aus dem eigenen
* Verlauf, aus der Vorversion oder aus den anderen Installationen derselben
* Fassung. Genau deshalb finden sie Dinge, an die niemand gedacht hat.
*
* Bewusst ohne Benachrichtigung: ein Befund behauptet nicht, dass etwas kaputt
* ist. Wer bei jeder Auffaelligkeit alarmiert, hat nach einer Woche einen
* Kanal, den niemand mehr liest.
*/
final class ServerDetectors
{
/** Name im Taktgeber watchdog_cron_jobs. */
private const CRON_NAME = 'supervisor_detectors';
/**
* Wie oft die Detektoren laufen.
*
* Der Cron tickt minuetlich, ein Flottenvergleich alle zehn Minuten sagt
* aber dasselbe wie einer jede Minute und kostet ein Zehntel.
*/
private const INTERVAL_SEC = 600;
/**
* Ab wie vielen Installationen ein Flottenvergleich etwas taugt.
*
* Unter fuenf ist die Streuung so grob geschaetzt, dass jeder zweite Wert
* als Ausreisser durchginge - der Detektor wuerde dann vor allem sich
* selbst melden.
*/
private const MIN_COHORT = 5;
/** Ab dem Wievielfachen der Streuung gilt ein Wert als Ausreisser. */
private const SIGMA = 3.0;
/** Ab welcher Verschiebung zwischen zwei Fassungen gemeldet wird. */
private const REGRESSION_RATIO = 1.25;
/**
* Nur Signaturen aus diesem Zeitfenster gelten als neu.
*
* Ohne Fenster meldete der erste Lauf die gesamte Historie auf einmal.
*/
private const NOVEL_WINDOW_MIN = 120;
/** Obergrenzen, damit ein Lauf nicht entgleist. */
private const MAX_ROWS = 800;
private const MAX_FINDINGS_PER_RUN = 200;
/**
* Fuehrt einen Detektorlauf durch, sofern faellig.
*
* @return array<string,mixed>
*/
public static function run(PDO $db, bool $force = false): array
{
if (!$force && !self::isDue($db)) {
return ['ran' => false, 'reason' => 'nicht faellig'];
}
$started = microtime(true);
$repo = new FindingRepo($db);
$counts = [];
$errors = [];
// Jeder Detektor einzeln gekapselt: einer, der an einem unerwarteten
// Datensatz scheitert, darf die anderen nicht mitreissen.
foreach ([
'novel.error_signature' => [self::class, 'novelErrorSignatures'],
'silence.heartbeat' => [self::class, 'silentHeartbeats'],
'deviation.self' => [self::class, 'selfDeviations'],
'deviation.fleet' => [self::class, 'fleetOutliers'],
'regression.build' => [self::class, 'buildRegressions'],
] as $name => $callable) {
try {
$counts[$name] = $callable($db, $repo);
} catch (\Throwable $e) {
$counts[$name] = 0;
$errors[$name] = $e->getMessage();
Logger::warning('Detektor fehlgeschlagen', [
'detector' => $name,
'error' => $e->getMessage(),
]);
}
}
$purged = $repo->purge();
self::markRun($db);
return [
'ran' => true,
'findings' => array_sum($counts),
'by_detector' => $counts,
'purged' => $purged,
'errors' => $errors,
'duration_ms' => (int)round((microtime(true) - $started) * 1000),
];
}
// ==================================================================
// novel.error_signature
// ==================================================================
/**
* Fehlersignaturen, die es in dieser Fassung noch nie gab.
*
* Nahezu geschenkt, weil der Bugtracker ohnehin ueber dedup_key gruppiert:
* ein frisch angelegtes Item MIT error_level ist definitionsgemaess eine
* Signatur, die vorher nicht da war. Kein Regelwerk noetig, und findet
* genau die Fehler, an die niemand gedacht hat.
*
* Laeuft in JEDER Umgebung, nicht nur in production. Auf der
* Testumgebung ist eine neue Fehlersignatur zwar der Normalfall - genau
* dafuer wird getestet -, aber sie ist deshalb nicht wertlos: Ein
* Testlauf, der einen Fehler ausloest, den es vorher nicht gab, ist genau
* das, was ein Testlauf finden soll.
*
* Unterschieden wird ueber environment am Befund und ueber den
* Schweregradhinweis: Was ausserhalb von production auffaellt, ist um eine
* Stufe niedriger eingestuft. Es steht also da, draengt sich aber nicht
* vor das, was bei einem Kunden passiert.
*
* Die eigentliche Frage - Testartefakt oder auch in production moeglich? -
* kann hier niemand beantworten. Siehe ROADMAP G8.
*/
private static function novelErrorSignatures(PDO $db, FindingRepo $repo): int
{
$stmt = $db->prepare('
SELECT id, project_slug, dedup_key, title, build_version, commit_sha,
environment, error_level, regression_of, first_seen_at
FROM bugtracker_items
WHERE error_level IS NOT NULL
AND first_seen_at > (UTC_TIMESTAMP() - INTERVAL ' . self::NOVEL_WINDOW_MIN . ' MINUTE)
AND status NOT IN ("ignored")
ORDER BY first_seen_at DESC
LIMIT ' . self::MAX_ROWS
);
$stmt->execute();
$written = 0;
foreach ($stmt->fetchAll() ?: [] as $row) {
if ($written >= self::MAX_FINDINGS_PER_RUN) {
break;
}
$isRegression = $row['regression_of'] !== null;
$repo->recordServerSide([
'project_slug' => (string)$row['project_slug'],
'detector' => 'novel.error_signature',
'subject' => (string)($row['dedup_key'] ?? $row['title']),
'instance' => 'default',
'environment' => (string)$row['environment'],
'build' => $row['build_version'] ?? null,
'commit_sha' => $row['commit_sha'] ?? null,
// Ein "fatal" wiegt schwerer als ein "warning" - aber die
// Einstufung bleibt ein Hinweis, kein Urteil.
//
// Ausserhalb von production eine Stufe niedriger: Was auf
// einer Testmaschine auffaellt, kann ein Artefakt des
// Pruefstands sein (fehlende Zugangsdaten, Attrappe statt
// echter Gegenstelle). Es soll sichtbar bleiben, sich aber
// nicht vor das draengen, was bei einem Kunden passiert.
'severity_hint' => self::hintForEnvironment(
match ((string)$row['error_level']) {
'fatal' => 'high',
'error' => 'medium',
default => 'low',
},
(string)$row['environment']
),
'observed' => [
'bug_item_id' => (int)$row['id'],
'title' => (string)$row['title'],
'error_level' => (string)$row['error_level'],
'first_seen_at' => (string)$row['first_seen_at'],
// Eine Signatur, die schon einmal geloest war und wieder
// auftritt, ist keine Neuheit, sondern ein Rueckfall.
'regression_of' => $isRegression ? (int)$row['regression_of'] : null,
],
'client_ref' => 'novel.error_signature/' . (string)$row['dedup_key'],
]);
$written++;
}
return $written;
}
// ==================================================================
// silence.heartbeat
// ==================================================================
/**
* Instanzen, die sich nicht mehr melden.
*
* Der Evaluator erkennt und alarmiert das bereits - hier entsteht bewusst
* kein zweiter Alarm, sondern nur ein Eintrag im Befundstrom. Der Zweck
* ist Vollstaendigkeit: ein Agent, der dc_findings abfragt, soll das Bild
* an einer Stelle bekommen und nicht drei Quellen zusammensuchen muessen.
*/
private static function silentHeartbeats(PDO $db, FindingRepo $repo): int
{
$stmt = $db->prepare('
SELECT source, instance, project_slug, app_version, state,
last_seen_utc, expected_interval_sec
FROM watchdog_monitors
WHERE state IN ("down","error")
AND is_muted = 0
AND expect_running = 1
ORDER BY updated_utc DESC
LIMIT ' . self::MAX_ROWS
);
$stmt->execute();
$written = 0;
$fallback = self::fallbackProject();
foreach ($stmt->fetchAll() ?: [] as $row) {
if ($written >= self::MAX_FINDINGS_PER_RUN) {
break;
}
$repo->recordServerSide([
'project_slug' => self::text($row['project_slug']) ?? $fallback,
'detector' => 'silence.heartbeat',
'subject' => (string)$row['source'],
'instance' => (string)$row['instance'],
'build' => $row['app_version'] ?? null,
'severity_hint' => (string)$row['state'] === 'down' ? 'high' : 'medium',
'observed' => [
'state' => (string)$row['state'],
'last_seen_utc' => $row['last_seen_utc'] ?? null,
'expected_interval_sec' => (int)$row['expected_interval_sec'],
],
'client_ref' => FindingRepo::buildClientRef(
'silence.heartbeat',
(string)$row['source'],
(string)$row['instance'],
$row['app_version'] ?? null
),
]);
$written++;
}
return $written;
}
// ==================================================================
// deviation.self
// ==================================================================
/**
* Abweichung vom eigenen Verlauf.
*
* Nicht "Speicher ueber 2 GB", sondern "diese Installation verhaelt sich
* anders als in ihren letzten Wochen". Das braucht kein Projektwissen und
* keinen Schwellwert je Anwendung.
*
* Gerechnet wird gegen die Streuung, nicht gegen ein Verhaeltnis wie in
* MetricStore::deviation(). Bei einer Kennzahl, die normalerweise um null
* pendelt, ist jedes Verhaeltnis unbrauchbar; die Streuung bleibt
* aussagekraeftig.
*
* Alles in einer Abfrage: je Monitor und Kennzahl einzeln nachzufragen
* waeren bei 50 Monitoren mit je 10 Kennzahlen 500 Abfragen pro Lauf.
*
* Der Vergleichszeitraum ist auf drei Tage begrenzt. Bei minuetlichen
* Heartbeats und 25 Kennzahlen je Monitor liegen dort schon
* Hunderttausende Zeilen; laenger zurueckzugehen kostet linear mehr und
* verbessert einen Mittelwert, der ohnehin ueber tausend Messpunkte geht,
* nicht mehr. ix_metric_lookup deckt die Gruppierung ab.
*/
private static function selfDeviations(PDO $db, FindingRepo $repo): int
{
$stmt = $db->prepare('
SELECT
wm.source,
wm.instance,
wm.metric_key,
m.project_slug,
m.app_version,
AVG(CASE WHEN wm.recorded_utc > (UTC_TIMESTAMP() - INTERVAL 30 MINUTE)
THEN wm.metric_value END) AS current_avg,
AVG(CASE WHEN wm.recorded_utc <= (UTC_TIMESTAMP() - INTERVAL 2 HOUR)
THEN wm.metric_value END) AS baseline_avg,
STDDEV_POP(CASE WHEN wm.recorded_utc <= (UTC_TIMESTAMP() - INTERVAL 2 HOUR)
THEN wm.metric_value END) AS baseline_sd,
COUNT(CASE WHEN wm.recorded_utc <= (UTC_TIMESTAMP() - INTERVAL 2 HOUR)
THEN 1 END) AS baseline_samples
FROM watchdog_metrics wm
LEFT JOIN watchdog_monitors m
ON m.source = wm.source AND m.instance = wm.instance
WHERE wm.recorded_utc > (UTC_TIMESTAMP() - INTERVAL 3 DAY)
GROUP BY wm.source, wm.instance, wm.metric_key, m.project_slug, m.app_version
HAVING current_avg IS NOT NULL
AND baseline_avg IS NOT NULL
AND baseline_samples >= 30
LIMIT ' . self::MAX_ROWS
);
$stmt->execute();
$written = 0;
$fallback = self::fallbackProject();
foreach ($stmt->fetchAll() ?: [] as $row) {
if ($written >= self::MAX_FINDINGS_PER_RUN) {
break;
}
$current = (float)$row['current_avg'];
$baseline = (float)$row['baseline_avg'];
$sd = (float)$row['baseline_sd'];
// Eine Kennzahl ohne jede Streuung ist entweder eine Konstante
// oder ein Zaehler, der noch nie gewackelt hat. Beides taugt nicht
// als Vergleichsmassstab.
if ($sd <= 0.0) {
continue;
}
$z = abs($current - $baseline) / $sd;
if ($z < self::SIGMA) {
continue;
}
$repo->recordServerSide([
'project_slug' => self::text($row['project_slug']) ?? $fallback,
'detector' => 'deviation.self',
'subject' => (string)$row['metric_key'],
'instance' => (string)$row['instance'],
'build' => $row['app_version'] ?? null,
'severity_hint' => $z >= self::SIGMA * 2 ? 'medium' : 'low',
'observed' => [
'source' => (string)$row['source'],
'current' => round($current, 4),
'sigma' => round($z, 2),
'direction' => $current > $baseline ? 'hoch' : 'runter',
],
'baseline' => [
'mean' => round($baseline, 4),
'stddev' => round($sd, 4),
'samples' => (int)$row['baseline_samples'],
],
'client_ref' => FindingRepo::buildClientRef(
'deviation.self',
(string)$row['source'] . ':' . (string)$row['metric_key'],
(string)$row['instance'],
$row['app_version'] ?? null
),
]);
$written++;
}
return $written;
}
// ==================================================================
// deviation.fleet
// ==================================================================
/**
* Ausreisser gegenueber den anderen Installationen derselben Fassung.
*
* Der staerkste Detektor im Katalog, weil der Vergleichsmassstab
* ueberhaupt nicht aus einem Kopf kommt: dieselbe Fassung laeuft auf N
* Maschinen, eine faellt bei irgendeiner Kennzahl aus dem Rahmen - auch
* bei einer, an die nie jemand gedacht hat.
*
* Die Kohorte ist (Projekt, Fassung). Ein Monitor ohne Projektzuordnung
* wird uebersprungen: "Fassung 1.0" zweier verschiedener Produkte hat
* nichts miteinander zu tun, und ein Vergleich darueber hinweg erzeugt
* ausschliesslich Unsinn.
*/
private static function fleetOutliers(PDO $db, FindingRepo $repo): int
{
$stmt = $db->prepare('
SELECT
m.project_slug,
m.app_version,
wm.metric_key,
wm.source,
wm.instance,
AVG(wm.metric_value) AS value
FROM watchdog_metrics wm
JOIN watchdog_monitors m
ON m.source = wm.source AND m.instance = wm.instance
WHERE wm.recorded_utc > (UTC_TIMESTAMP() - INTERVAL 2 HOUR)
AND m.project_slug IS NOT NULL AND m.project_slug <> ""
AND m.app_version IS NOT NULL AND m.app_version <> ""
AND m.is_muted = 0
GROUP BY m.project_slug, m.app_version, wm.metric_key, wm.source, wm.instance
LIMIT ' . (self::MAX_ROWS * 4)
);
$stmt->execute();
// Nach Kohorte buendeln: (Projekt, Fassung, Kennzahl).
$cohorts = [];
foreach ($stmt->fetchAll() ?: [] as $row) {
$key = implode('|', [
(string)$row['project_slug'],
(string)$row['app_version'],
(string)$row['metric_key'],
]);
$cohorts[$key][] = $row;
}
$written = 0;
foreach ($cohorts as $key => $members) {
if ($written >= self::MAX_FINDINGS_PER_RUN) {
break;
}
if (count($members) < self::MIN_COHORT) {
continue;
}
$values = array_map(static fn(array $r): float => (float)$r['value'], $members);
$mean = array_sum($values) / count($values);
$sd = self::stddev($values, $mean);
if ($sd <= 0.0) {
continue;
}
[$projectSlug, $appVersion, $metricKey] = explode('|', $key, 3);
foreach ($members as $member) {
$value = (float)$member['value'];
$z = abs($value - $mean) / $sd;
if ($z < self::SIGMA) {
continue;
}
$repo->recordServerSide([
'project_slug' => $projectSlug,
'detector' => 'deviation.fleet',
'subject' => $metricKey,
'instance' => (string)$member['instance'],
'build' => $appVersion,
'severity_hint' => 'medium',
'observed' => [
'source' => (string)$member['source'],
'value' => round($value, 4),
'sigma' => round($z, 2),
'direction' => $value > $mean ? 'hoch' : 'runter',
],
'baseline' => [
'cohort_mean' => round($mean, 4),
'cohort_stddev' => round($sd, 4),
'cohort_size' => count($members),
],
'client_ref' => FindingRepo::buildClientRef(
'deviation.fleet',
(string)$member['source'] . ':' . $metricKey,
(string)$member['instance'],
$appVersion
),
]);
$written++;
}
}
return $written;
}
// ==================================================================
// regression.build
// ==================================================================
/**
* Verschiebt sich eine Kennzahl nach einem Update?
*
* Verglichen werden die beiden hoechsten Fassungen eines Projekts, die
* gerade im Feld sind: verbrauchen die Installationen auf 1.4.3 deutlich
* mehr Speicher als die auf 1.4.2, ist das ein Befund - ohne dass jemand
* einen Schwellwert gesetzt haette.
*
* Einschraenkung, die man kennen muss: Das ist ein Querschnitt, kein
* Vorher-Nachher. Verglichen werden verschiedene Maschinen zur selben
* Zeit, nicht dieselbe Maschine vor und nach dem Update. Ein echter
* Laengsschnitt braeuchte eine Historie der app_version je Monitor - die
* gibt es heute nicht, weil das Feld ueberschrieben wird. Bei gemischten
* Kohorten kann der Unterschied deshalb auch von der Maschine kommen und
* nicht von der Fassung.
*/
private static function buildRegressions(PDO $db, FindingRepo $repo): int
{
$stmt = $db->prepare('
SELECT
m.project_slug,
m.app_version,
wm.metric_key,
AVG(wm.metric_value) AS value,
COUNT(DISTINCT CONCAT(wm.source, "|", wm.instance)) AS installs
FROM watchdog_metrics wm
JOIN watchdog_monitors m
ON m.source = wm.source AND m.instance = wm.instance
WHERE wm.recorded_utc > (UTC_TIMESTAMP() - INTERVAL 24 HOUR)
AND m.project_slug IS NOT NULL AND m.project_slug <> ""
AND m.app_version IS NOT NULL AND m.app_version <> ""
AND m.is_muted = 0
GROUP BY m.project_slug, m.app_version, wm.metric_key
LIMIT ' . (self::MAX_ROWS * 4)
);
$stmt->execute();
// Nach (Projekt, Kennzahl) ordnen, darin nach Fassung.
$series = [];
foreach ($stmt->fetchAll() ?: [] as $row) {
$key = (string)$row['project_slug'] . '|' . (string)$row['metric_key'];
$series[$key][(string)$row['app_version']] = [
'value' => (float)$row['value'],
'installs' => (int)$row['installs'],
];
}
$written = 0;
foreach ($series as $key => $byVersion) {
if ($written >= self::MAX_FINDINGS_PER_RUN) {
break;
}
if (count($byVersion) < 2) {
continue;
}
// version_compare() statt Sortierung als Text - sonst gilt
// "1.10" als aelter als "1.9".
$versions = array_keys($byVersion);
usort($versions, static fn(string $a, string $b): int => version_compare($a, $b));
$newer = (string)array_pop($versions);
$older = (string)array_pop($versions);
$new = $byVersion[$newer];
$old = $byVersion[$older];
if (abs($old['value']) < 0.0001) {
continue;
}
$ratio = $new['value'] / $old['value'];
if ($ratio < self::REGRESSION_RATIO && $ratio > 1 / self::REGRESSION_RATIO) {
continue;
}
[$projectSlug, $metricKey] = explode('|', $key, 2);
$repo->recordServerSide([
'project_slug' => $projectSlug,
'detector' => 'regression.build',
'subject' => $metricKey,
'instance' => 'fleet',
'build' => $newer,
'severity_hint' => 'medium',
'observed' => [
'version' => $newer,
'value' => round($new['value'], 4),
'installs' => $new['installs'],
'ratio' => round($ratio, 3),
'direction' => $ratio > 1 ? 'hoch' : 'runter',
],
'baseline' => [
'version' => $older,
'value' => round($old['value'], 4),
'installs' => $old['installs'],
'method' => 'querschnitt',
],
'client_ref' => FindingRepo::buildClientRef(
'regression.build',
$metricKey . ':' . $older . '->' . $newer,
'fleet',
$newer
),
]);
$written++;
}
return $written;
}
// ==================================================================
// Taktgeber und Hilfsfunktionen
// ==================================================================
/** Vorbild ist Evaluator::shouldPurgeMetrics(). */
private static function isDue(PDO $db): bool
{
try {
$stmt = $db->query('
SELECT last_run_utc, enabled FROM watchdog_cron_jobs
WHERE name = "' . self::CRON_NAME . '"
');
$row = $stmt !== false ? $stmt->fetch() : false;
if (is_array($row) && (int)$row['enabled'] === 0) {
return false;
}
if (!is_array($row) || $row['last_run_utc'] === null) {
return true;
}
return (time() - (int)strtotime((string)$row['last_run_utc'] . ' UTC')) >= self::INTERVAL_SEC;
} catch (\Throwable $e) {
// Fehlt die Tabelle noch, weil die Migration nicht gelaufen ist,
// soll der Tick nicht scheitern.
return false;
}
}
private static function markRun(PDO $db): void
{
try {
$db->prepare('
INSERT INTO watchdog_cron_jobs (name, interval_sec, last_run_utc, enabled)
VALUES ("' . self::CRON_NAME . '", ' . self::INTERVAL_SEC . ', UTC_TIMESTAMP(), 1)
ON DUPLICATE KEY UPDATE last_run_utc = UTC_TIMESTAMP()
')->execute();
} catch (\Throwable $e) {
Logger::warning('Detektorlauf nicht vermerkt', ['error' => $e->getMessage()]);
}
}
/**
* Projekt fuer Befunde von Monitoren ohne Zuordnung.
*
* "default" ist im Bugtracker bereits der Rueckfallwert fuer einen
* fehlenden Slug - hier derselbe, damit nicht zwei Sammelbecken entstehen.
*/
private static function fallbackProject(): string
{
$configured = Config::get('supervisor.default_project', 'default');
return is_string($configured) && $configured !== '' ? $configured : 'default';
}
/**
* Stuft einen Schweregradhinweis ausserhalb von production eine Stufe
* herunter.
*
* Nicht, weil ein Befund auf einer Testmaschine unwichtig waere - er kann
* genau der sein, den man sucht. Sondern weil dort systematisch mehr
* Rauschen entsteht: fehlende Zugangsdaten, Attrappen, halb eingerichtete
* Gegenstellen. Wer beides gleich hoch einstuft, liest bald keines von
* beiden mehr.
*/
private static function hintForEnvironment(string $hint, string $environment): string
{
if ($environment === 'production') {
return $hint;
}
return [
'critical' => 'high',
'high' => 'medium',
'medium' => 'low',
][$hint] ?? $hint;
}
/** @param list<float> $values */
private static function stddev(array $values, float $mean): float
{
$count = count($values);
if ($count < 2) {
return 0.0;
}
$sum = 0.0;
foreach ($values as $value) {
$sum += ($value - $mean) ** 2;
}
return sqrt($sum / $count);
}
private static function text($value): ?string
{
if (!is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}
+17 -1
View File
@@ -137,7 +137,10 @@ final class Evaluator
}
// Abgelaufene Bugtracker-Leases freigeben, damit haengengebliebene
// Agenten kein Item dauerhaft blockieren.
// Agenten kein Item dauerhaft blockieren. Seit 2.6.1 wird dabei auch
// der Status zurueckgestuft - ohne das blieb ein liegengelassenes Item
// auf "in_progress" stehen und wurde von claimNext() nie wieder
// ausgegeben.
$releasedLeases = 0;
try {
$releasedLeases = (new BugRepo($db))->expireStaleLeases();
@@ -145,6 +148,19 @@ final class Evaluator
Logger::warning('Lease-Bereinigung fehlgeschlagen', ['error' => $e->getMessage()]);
}
// Nachpruefungen, die niemand annimmt oder nie beendet. Getrennt vom
// Lease-Lauf, weil hier nicht aufgeraeumt, sondern entschieden wird:
// eskalieren oder ungeprueft schliessen.
try {
$verification = (new BugRepo($db))->runVerificationMaintenance();
if ($verification['escalated'] > 0 || $verification['closed'] > 0) {
Logger::info('Nachpruefung fortgeschrieben', $verification);
}
} catch (\Throwable $e) {
Logger::warning('Nachpruefungs-Wartung fehlgeschlagen', ['error' => $e->getMessage()]);
}
// Alten Metrik-Verlauf abraeumen (hoechstens einmal pro Stunde).
$purgedMetrics = 0;
if (self::shouldPurgeMetrics($db)) {