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>
266 lines
10 KiB
PHP
266 lines
10 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Steuerung der Testinstanz.
|
|
*
|
|
* ACHTUNG: Diese Datei liegt NUR auf der Testinstanz. scripts/deploy_dev.py
|
|
* laedt sie eigens nach /testctl.php hoch; scripts/deploy.py schliesst das
|
|
* ganze Verzeichnis tests/ aus. Sie darf nie Teil eines Produktivstands
|
|
* werden - deshalb steht sie hier und nicht unter public/.
|
|
*
|
|
* Warum es sie ueberhaupt gibt: Ein automatisierter Testlauf braucht einen
|
|
* definierten Ausgangszustand. Ohne den prueft der zweite Lauf etwas anderes
|
|
* als der erste, und ein Fehlschlag sagt nichts mehr darueber aus, ob die
|
|
* Anwendung kaputt ist oder nur der Datenbestand gewachsen. Das Zuruecksetzen
|
|
* muss dort passieren, wo die Datenbank steht - also auf dem Server.
|
|
*
|
|
* Drei Schloesser sitzen davor, und jedes einzelne wuerde reichen:
|
|
* 1. Basic-Auth der Testinstanz (die Datei liegt nicht unter /api/)
|
|
* 2. testing.enabled = true UND app.env = 'test' in der config.php.
|
|
* Die Produktivkonfiguration hat beides nicht.
|
|
* 3. Der Shared Key der Instanz, per Header. Kein Session-Zugang: ein
|
|
* Browserfenster, das noch offen ist, soll keine Tabellen loeschen.
|
|
* Fuer die zerstoerenden Aktionen kommt der Name der Datenbank als
|
|
* Bestaetigung dazu - so laesst sich "reset" nicht versehentlich gegen die
|
|
* falsche Instanz absetzen.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/src/bootstrap.php';
|
|
|
|
use Deploymentcenter\Core\Config;
|
|
use Deploymentcenter\Core\Db;
|
|
use Deploymentcenter\Core\Http;
|
|
use Deploymentcenter\Core\Migrator;
|
|
use Deploymentcenter\Core\TokenManager;
|
|
|
|
Http::beginJson(['GET', 'POST', 'OPTIONS']);
|
|
|
|
// --- Schloss 2: Diese Instanz muss sich selbst als Testinstanz ausweisen ---
|
|
if (Config::get('testing.enabled') !== true || Config::get('app.env') !== 'test') {
|
|
Http::fail(
|
|
403,
|
|
'not_a_test_instance',
|
|
'testctl steht nur auf einer Instanz mit testing.enabled = true und app.env = "test" zur Verfuegung.'
|
|
);
|
|
}
|
|
|
|
// --- Schloss 3: Shared Key, ohne Umweg ueber Sitzung oder Sub-Token ---
|
|
$sharedKey = (string)Config::get('security.shared_key', '');
|
|
$presented = (string)(Http::bearerToken() ?? '');
|
|
if ($sharedKey === '' || !hash_equals($sharedKey, $presented)) {
|
|
Http::fail(401, 'unauthorized', 'Shared Key erforderlich (Header X-Master-Token oder Authorization: Bearer).');
|
|
}
|
|
|
|
$db = Db::init();
|
|
$dbName = (string)Config::get('db.dbname', '');
|
|
$action = strtolower(trim((string)(Http::str('action') ?? 'status')));
|
|
|
|
/** Bestaetigung fuer alles, was Daten vernichtet. */
|
|
$requireConfirmation = static function () use ($dbName): void {
|
|
if ((string)(Http::str('confirm') ?? '') !== $dbName) {
|
|
Http::fail(
|
|
400,
|
|
'confirmation_required',
|
|
sprintf('Zur Bestaetigung muss confirm den Datenbanknamen enthalten ("%s").', $dbName)
|
|
);
|
|
}
|
|
};
|
|
|
|
/** Alle Tabellen der Testdatenbank entfernen. */
|
|
$wipe = static function () use ($db): array {
|
|
$tables = $db->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
|
if ($tables === []) {
|
|
return [];
|
|
}
|
|
|
|
// Fremdschluessel vorruebergehend aus: sonst haengt die Reihenfolge des
|
|
// Loeschens von der Reihenfolge der Tabellen ab, und die ist nicht stabil.
|
|
$db->exec('SET FOREIGN_KEY_CHECKS = 0');
|
|
try {
|
|
foreach ($tables as $table) {
|
|
$db->exec('DROP TABLE IF EXISTS `' . str_replace('`', '', (string)$table) . '`');
|
|
}
|
|
} finally {
|
|
$db->exec('SET FOREIGN_KEY_CHECKS = 1');
|
|
}
|
|
|
|
return array_map('strval', $tables);
|
|
};
|
|
|
|
/** Zustand der Instanz - der Blick, den ein Testlauf vor und nach sich wirft. */
|
|
$status = static function () use ($db, $dbName): array {
|
|
$tables = $db->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
|
$counts = [];
|
|
foreach ($tables as $table) {
|
|
$name = (string)$table;
|
|
try {
|
|
$counts[$name] = (int)$db->query('SELECT COUNT(*) FROM `' . str_replace('`', '', $name) . '`')->fetchColumn();
|
|
} catch (Throwable $e) {
|
|
$counts[$name] = null;
|
|
}
|
|
}
|
|
|
|
$migrations = [];
|
|
if (in_array('dc_migrations', array_map('strval', $tables), true)) {
|
|
$migrations = $db->query('SELECT version FROM dc_migrations ORDER BY version')
|
|
->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
|
}
|
|
|
|
return [
|
|
'database' => $dbName,
|
|
'php' => PHP_VERSION,
|
|
'app_version' => Config::get('app.version'),
|
|
'debug' => Config::isDebug(),
|
|
'tables' => count($tables),
|
|
'rows' => $counts,
|
|
'migrations' => array_map('strval', $migrations),
|
|
'log_dir' => is_writable(DC_VAR . '/log'),
|
|
'signing_key' => is_readable((string)Config::get('security.release_private_key', '')),
|
|
];
|
|
};
|
|
|
|
switch ($action) {
|
|
// ------------------------------------------------------------------
|
|
case 'status':
|
|
Http::ok($status());
|
|
break;
|
|
|
|
// ------------------------------------------------------------------
|
|
case 'wipe':
|
|
$requireConfirmation();
|
|
Http::ok(['dropped' => $wipe(), 'state' => $status()]);
|
|
break;
|
|
|
|
// ------------------------------------------------------------------
|
|
case 'migrate':
|
|
$result = Migrator::migrate($db);
|
|
if ($result['failed'] !== null) {
|
|
Http::fail(500, 'migration_failed', 'Migration abgebrochen.', null, $result);
|
|
}
|
|
Http::ok(['migration' => $result, 'state' => $status()]);
|
|
break;
|
|
|
|
// ------------------------------------------------------------------
|
|
// Ausgangszustand herstellen: leere Datenbank, frisches Schema, ein
|
|
// Administrator und ein Master-Token mit allen Rechten. Beides gibt der
|
|
// Aufruf zurueck - der Testlauf soll sich seine Zugaenge selbst holen
|
|
// koennen und nicht darauf warten, dass jemand im WebUI etwas anklickt.
|
|
// ------------------------------------------------------------------
|
|
case 'reset':
|
|
$requireConfirmation();
|
|
|
|
$dropped = $wipe();
|
|
|
|
$result = Migrator::migrate($db);
|
|
if ($result['failed'] !== null) {
|
|
Http::fail(500, 'migration_failed', 'Migration nach dem Zuruecksetzen abgebrochen.', null, $result);
|
|
}
|
|
|
|
$adminUser = (string)(Http::str('admin_user') ?? 'admin');
|
|
$adminPass = (string)(Http::str('admin_pass') ?? 'test02763!');
|
|
|
|
$db->prepare('DELETE FROM dc_users WHERE username = :u')->execute([':u' => $adminUser]);
|
|
$db->prepare('
|
|
INSERT INTO dc_users (username, password_hash, created_at)
|
|
VALUES (:u, :h, UTC_TIMESTAMP())
|
|
')->execute([':u' => $adminUser, ':h' => password_hash($adminPass, PASSWORD_DEFAULT)]);
|
|
|
|
// Rolle setzen, falls die Spalte existiert (Migration 012).
|
|
try {
|
|
$db->prepare('UPDATE dc_users SET role = "admin" WHERE username = :u')->execute([':u' => $adminUser]);
|
|
} catch (Throwable $e) {
|
|
// Ohne Rollenspalte ist jeder Benutzer Administrator - nichts zu tun.
|
|
}
|
|
|
|
$manager = new TokenManager($db);
|
|
$master = $manager->createMasterToken(
|
|
'Testlauf (automatisch)',
|
|
null,
|
|
null,
|
|
'custom',
|
|
'testkit',
|
|
['*'],
|
|
'all'
|
|
);
|
|
|
|
Http::ok([
|
|
'dropped' => $dropped,
|
|
'migration' => $result,
|
|
'admin' => ['username' => $adminUser, 'password' => $adminPass],
|
|
'master' => ['token_id' => $master['token_id'], 'raw_token' => $master['raw_token']],
|
|
'state' => $status(),
|
|
]);
|
|
break;
|
|
|
|
// ------------------------------------------------------------------
|
|
// Signierschluessel fuer den Updateservice. Er wird auf der Testinstanz
|
|
// erzeugt und nie von der Produktion kopiert - ein Testlauf darf mit dem
|
|
// Schluessel, mit dem echte Releases signiert werden, nichts zu tun haben.
|
|
// ------------------------------------------------------------------
|
|
case 'genkey':
|
|
$target = (string)Config::get('security.release_private_key', DC_ROOT . '/config/release-signing.pem');
|
|
|
|
if (is_readable($target) && (string)(Http::str('force') ?? '') !== '1') {
|
|
Http::ok(['created' => false, 'path' => basename($target), 'message' => 'Schluessel vorhanden.']);
|
|
}
|
|
|
|
$res = openssl_pkey_new([
|
|
'private_key_bits' => 4096,
|
|
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
|
]);
|
|
if ($res === false) {
|
|
Http::fail(500, 'keygen_failed', 'Schluesselerzeugung fehlgeschlagen: ' . openssl_error_string());
|
|
}
|
|
|
|
$pem = '';
|
|
openssl_pkey_export($res, $pem);
|
|
if (file_put_contents($target, $pem) === false) {
|
|
Http::fail(500, 'keygen_failed', 'Schluessel konnte nicht geschrieben werden.');
|
|
}
|
|
@chmod($target, 0600);
|
|
|
|
$details = openssl_pkey_get_details($res);
|
|
Http::ok([
|
|
'created' => true,
|
|
'path' => basename($target),
|
|
'bits' => $details['bits'] ?? null,
|
|
'public' => $details['key'] ?? null,
|
|
]);
|
|
break;
|
|
|
|
// ------------------------------------------------------------------
|
|
// Protokoll lesen. Ohne diese Aktion endet jeder fehlgeschlagene Testlauf
|
|
// bei "500" und dem Ratespiel, was dahinter stand - die Logdatei liegt
|
|
// unter var/log/ und ist per .htaccess zu Recht gesperrt.
|
|
// ------------------------------------------------------------------
|
|
case 'log':
|
|
$lines = max(1, min(500, (int)(Http::str('lines') ?? 80)));
|
|
$dir = DC_VAR . '/log';
|
|
$files = glob($dir . '/*.log') ?: [];
|
|
usort($files, static fn($a, $b) => filemtime($b) <=> filemtime($a));
|
|
|
|
$out = [];
|
|
foreach (array_slice($files, 0, 3) as $file) {
|
|
$content = file($file, FILE_IGNORE_NEW_LINES) ?: [];
|
|
$out[basename($file)] = array_slice($content, -$lines);
|
|
}
|
|
Http::ok(['log' => $out]);
|
|
break;
|
|
|
|
// ------------------------------------------------------------------
|
|
case 'clearlog':
|
|
foreach (glob(DC_VAR . '/log/*.log') ?: [] as $file) {
|
|
file_put_contents($file, '');
|
|
}
|
|
Http::ok(['cleared' => true]);
|
|
break;
|
|
|
|
// ------------------------------------------------------------------
|
|
default:
|
|
Http::fail(400, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
|
|
'actions' => ['status', 'wipe', 'migrate', 'reset', 'genkey', 'log', 'clearlog'],
|
|
]);
|
|
}
|