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>
240 lines
8.5 KiB
PHP
240 lines
8.5 KiB
PHP
<?php
|
|
|
|
/**
|
|
* /api/supervisor/v1/...
|
|
*
|
|
* Eingang und Abfrage der Supervisor-Befunde.
|
|
*
|
|
* POST ?action=observe Befund einliefern (supervisor:report)
|
|
* GET ?action=findings Befunde abfragen (supervisor:read)
|
|
* GET ?action=summary Zaehlung je Detektor (supervisor:read)
|
|
* GET ?action=catalog Der Detektor-Katalog (supervisor:read)
|
|
* POST ?action=status Befund einstufen (supervisor:read)
|
|
*
|
|
* Ein Befund ist keine Fehlermeldung. Er behauptet nicht, dass etwas kaputt
|
|
* ist, sondern dass etwas ungewoehnlich ist - deshalb loest er hier auch
|
|
* keine Benachrichtigung aus.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../../../../src/bootstrap.php';
|
|
|
|
use Deploymentcenter\Core\ApiAuth;
|
|
use Deploymentcenter\Core\Config;
|
|
use Deploymentcenter\Core\Db;
|
|
use Deploymentcenter\Core\Http;
|
|
use Deploymentcenter\Modules\License\RateLimiter;
|
|
use Deploymentcenter\Modules\Supervisor\Catalog;
|
|
use Deploymentcenter\Modules\Supervisor\FindingRepo;
|
|
|
|
const READ_ACTIONS = ['findings', 'summary', 'catalog'];
|
|
const WRITE_ACTIONS = ['observe', 'status'];
|
|
|
|
/**
|
|
* Aktion aus ?action= oder dem letzten Pfadsegment - wie beim Watchdog, damit
|
|
* beide Schreibweisen funktionieren:
|
|
* POST /api/supervisor/v1/observe
|
|
* POST /api/supervisor/v1/?action=observe
|
|
*/
|
|
function resolveSupervisorAction(): string
|
|
{
|
|
$explicit = Http::str('action');
|
|
if ($explicit !== null) {
|
|
return strtolower($explicit);
|
|
}
|
|
|
|
$segments = array_values(array_filter(
|
|
explode('/', trim(Http::path(), '/')),
|
|
static fn(string $s): bool => $s !== ''
|
|
));
|
|
|
|
$last = strtolower((string)end($segments));
|
|
|
|
return match ($last) {
|
|
'observe' => 'observe',
|
|
'summary' => 'summary',
|
|
'catalog' => 'catalog',
|
|
'status' => 'status',
|
|
default => 'findings',
|
|
};
|
|
}
|
|
|
|
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
|
|
|
|
$action = resolveSupervisorAction();
|
|
|
|
if (!in_array($action, READ_ACTIONS, true) && !in_array($action, WRITE_ACTIONS, true)) {
|
|
Http::fail(404, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
|
|
'available' => array_merge(READ_ACTIONS, WRITE_ACTIONS),
|
|
]);
|
|
}
|
|
|
|
$isWrite = in_array($action, WRITE_ACTIONS, true);
|
|
|
|
if ($isWrite && Http::method() !== 'POST') {
|
|
Http::fail(405, 'method_not_allowed', sprintf('Die Aktion "%s" erwartet POST.', $action));
|
|
}
|
|
|
|
$db = Db::init();
|
|
|
|
// Ein Beobachter meldet regelmaessig. Bricht seine Zustandsverwaltung, sendet
|
|
// er im Sekundentakt - dann soll er gedrosselt werden und nicht die Tabelle
|
|
// fuellen.
|
|
if ($action === 'observe') {
|
|
$limiter = new RateLimiter(
|
|
$db,
|
|
(int)Config::get('supervisor.observe_rate', 120),
|
|
60,
|
|
'sv_observe'
|
|
);
|
|
|
|
if (!$limiter->check(Http::clientIp())) {
|
|
Http::fail(429, 'rate_limited', 'Zu viele Befunde. Bitte Sendefrequenz reduzieren.');
|
|
}
|
|
}
|
|
|
|
// Drei Rechte, drei Rollen:
|
|
// observe - der Beobachter auf der Kundenmaschine, meldet nur
|
|
// status - die Sichtung, entscheidet ueber Befunde
|
|
// Rest - Lesen
|
|
//
|
|
// Beim Einliefern ist die Sitzung bewusst nicht erlaubt: das ist die
|
|
// Maschinenschnittstelle.
|
|
$scope = match ($action) {
|
|
'observe' => 'supervisor:report',
|
|
'status' => 'supervisor:manage',
|
|
default => 'supervisor:read',
|
|
};
|
|
|
|
$context = ApiAuth::requireScope($db, $scope, null, $action !== 'observe');
|
|
|
|
$repo = new FindingRepo($db);
|
|
|
|
switch ($action) {
|
|
|
|
// ---------------------------------------------------------------- lesen
|
|
case 'catalog':
|
|
Http::ok([
|
|
'count' => count(Catalog::DETECTORS),
|
|
'detectors' => Catalog::DETECTORS,
|
|
]);
|
|
// no break - Http::ok beendet die Anfrage
|
|
|
|
case 'summary':
|
|
$slug = ApiAuth::projectFilter($context, true) ?? Http::str('project_slug');
|
|
Http::ok([
|
|
'project_slug' => $slug,
|
|
'by_detector' => $repo->byDetector($slug),
|
|
]);
|
|
|
|
case 'findings':
|
|
$bound = ApiAuth::projectFilter($context, true);
|
|
|
|
$result = $repo->search([
|
|
'project_slug' => $bound ?? Http::str('project_slug') ?? 'all',
|
|
'detector' => Http::str('detector'),
|
|
'status' => Http::str('status'),
|
|
'instance' => Http::str('instance'),
|
|
'build' => Http::str('build'),
|
|
'environment' => Http::str('environment'),
|
|
'since' => Http::str('since'),
|
|
'limit' => Http::int('limit', 50),
|
|
'offset' => Http::int('offset', 0),
|
|
]);
|
|
|
|
Http::ok($result);
|
|
|
|
// ------------------------------------------------------------ schreiben
|
|
case 'observe':
|
|
$data = Http::body();
|
|
|
|
if ($data === []) {
|
|
Http::fail(400, 'empty_body', 'Der Request-Body ist leer.');
|
|
}
|
|
|
|
$projectSlug = is_string($data['project_slug'] ?? null) ? trim($data['project_slug']) : null;
|
|
ApiAuth::enforceProject($context, $projectSlug);
|
|
|
|
// Ein projektgebundenes Token meldet immer fuer sein eigenes Projekt.
|
|
// Anders als beim Bugtracker gibt es hier keine Ausnahme fuer
|
|
// Werkbank-Token: ein Befund ist eine Beobachtung an einer laufenden
|
|
// Installation, und die macht man nicht stellvertretend fuer ein
|
|
// fremdes Projekt.
|
|
$home = ApiAuth::homeProject($context);
|
|
if ($home !== null) {
|
|
$data['project_slug'] = $home;
|
|
}
|
|
|
|
// Die Herkunft wird abgeleitet, nicht uebernommen - ein Client darf
|
|
// seine Befunde nicht als serverseitig ermittelt ausgeben.
|
|
$data['origin'] = Catalog::ORIGIN_CLIENT;
|
|
|
|
try {
|
|
$result = $repo->record($data);
|
|
} catch (\InvalidArgumentException $e) {
|
|
Http::fail(400, 'invalid_finding', $e->getMessage());
|
|
}
|
|
|
|
Http::ok([
|
|
'finding_id' => $result['id'],
|
|
'is_new' => $result['is_new'],
|
|
'occurrence_count' => $result['occurrence_count'],
|
|
'detector' => $result['detector'],
|
|
// NICHT 'status'. Http::ok() legt ['status' => 'success'] als
|
|
// Umschlag darum und merged dieses Feld darueber - der Zustand des
|
|
// Befundes ueberschrieb also die Erfolgsmeldung. Jeder Client, der
|
|
// wie dokumentiert auf status === "success" prueft, brach
|
|
// ausgerechnet an dem Endpunkt, den ein meldender Client am
|
|
// haeufigsten aufruft.
|
|
'finding_status' => $result['status'],
|
|
'client_ref' => $result['client_ref'],
|
|
// Ein unbekannter Detektorname wird angenommen, aber gemeldet.
|
|
// Still verwerfen waere schlimmer: dann suchte jemand tagelang
|
|
// nach Befunden, die nie ankamen.
|
|
'known_detector' => $result['known_detector'],
|
|
'message' => $result['known_detector']
|
|
? ($result['is_new'] ? 'Befund aufgenommen.' : 'Bekannter Befund - Zaehler erhoeht.')
|
|
: sprintf(
|
|
'Befund aufgenommen. Achtung: "%s" steht nicht im Katalog - Tippfehler, '
|
|
. 'oder der Katalog muss erweitert werden.',
|
|
$result['detector']
|
|
),
|
|
], $result['is_new'] ? 201 : 200);
|
|
|
|
case 'status':
|
|
$id = Http::int('id', 0);
|
|
|
|
if ($id <= 0) {
|
|
Http::fail(400, 'missing_id', 'Es wurde keine Befund-ID uebergeben.');
|
|
}
|
|
|
|
$finding = $repo->get($id);
|
|
if ($finding === null) {
|
|
Http::fail(404, 'not_found', sprintf('Befund #%d existiert nicht.', $id));
|
|
}
|
|
|
|
ApiAuth::enforceProject($context, (string)$finding['project_slug'], true);
|
|
|
|
$status = Http::str('status');
|
|
if ($status === null || !in_array($status, FindingRepo::STATUSES, true)) {
|
|
Http::fail(400, 'invalid_status', 'Ungueltiger Status.', null, [
|
|
'allowed' => FindingRepo::STATUSES,
|
|
]);
|
|
}
|
|
|
|
if (!$repo->setStatus($id, $status, $context['actor'])) {
|
|
Http::fail(500, 'update_failed', sprintf('Befund #%d konnte nicht geaendert werden.', $id));
|
|
}
|
|
|
|
Http::ok([
|
|
'finding_id' => $id,
|
|
// Aus demselben Grund wie bei 'observe' nicht 'status': Der
|
|
// gesetzte Zustand ueberschriebe sonst die Erfolgsmeldung des
|
|
// Umschlags. Hier faellt es weniger auf, weil ein Mensch die
|
|
// Einstufung ausloest - der Fehler ist derselbe.
|
|
'finding_status' => $status,
|
|
'message' => sprintf('Befund #%d auf "%s" gesetzt.', $id, $status),
|
|
]);
|
|
}
|