Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Core\Database;
use OpenNIT\Hypervisor\HypervConnector;
use OpenNIT\Hypervisor\HypervisorSyncService;
use OpenNIT\Hypervisor\ProvidesDatastores;
use OpenNIT\Hypervisor\ProvidesSnapshots;
use OpenNIT\Hypervisor\ProxmoxConnector;
use OpenNIT\Hypervisor\VmwareConnector;
use OpenNIT\Hypervisor\XenConnector;
/**
* Deckt Snapshots, Speicherorte und die Gastinformationen ab – die drei
* Punkte, mit denen die Beta-Kennzeichnung der Virtualisierung faellt.
*/
return static function (): void {
$wurzel = dirname(__DIR__);
$src = static fn (string $datei): string => (string)file_get_contents($wurzel . $datei);
/** Datenbank-Doppel, das Schreibzugriffe mitschreibt. */
$db = static function (array $vms = []): Database {
return new class ($vms) extends Database {
/** @var list<array{sql:string, params:array}> */
public array $schreibvorgaenge = [];
public function __construct(private array $vms)
{
}
public function execute(string $sql, array $params = []): \PDOStatement
{
$this->schreibvorgaenge[] = ['sql' => preg_replace('/\s+/', ' ', trim($sql)), 'params' => $params];
return new class extends \PDOStatement {};
}
public function fetchAll(string $sql, array $params = []): array
{
return $this->vms;
}
public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
{
return 0;
}
};
};
/** Private Methode aufrufen. */
$ruf = static function (object $obj, string $methode, array $args = []): mixed {
$m = new \ReflectionMethod($obj, $methode);
$m->setAccessible(true);
return $m->invokeArgs($obj, $args);
};
// ----------------------------------------------------------------
// Faehigkeiten statt Pflichtmethoden
// ----------------------------------------------------------------
test('Nur wer liefern kann, gibt sich als Lieferant aus', function () {
// Eine Pflichtmethode am gemeinsamen Interface wuerde jeden Konnektor
// zwingen, still ein leeres Array zurueckzugeben - dann waere von
// aussen nicht mehr unterscheidbar, ob nichts da ist oder nichts geht.
assertTrue(is_subclass_of(ProxmoxConnector::class, ProvidesSnapshots::class), 'Proxmox ohne Snapshots');
assertTrue(is_subclass_of(ProxmoxConnector::class, ProvidesDatastores::class), 'Proxmox ohne Speicherorte');
assertTrue(is_subclass_of(HypervConnector::class, ProvidesSnapshots::class), 'Hyper-V ohne Snapshots');
assertTrue(is_subclass_of(VmwareConnector::class, ProvidesDatastores::class), 'VMware ohne Speicherorte');
// Xen liefert beides nicht – und gibt das auch zu.
assertFalse(is_subclass_of(XenConnector::class, ProvidesSnapshots::class), 'Xen behauptet Snapshots');
assertFalse(is_subclass_of(XenConnector::class, ProvidesDatastores::class), 'Xen behauptet Speicherorte');
});
test('Der Abgleich fragt nur, wer antworten kann', function () use ($src) {
$code = $src('/src/Hypervisor/HypervisorSyncService.php');
assertTrue(str_contains($code, '$conn instanceof ProvidesDatastores'), 'Speicherorte ungeprueft abgefragt');
assertTrue(str_contains($code, '$conn instanceof ProvidesSnapshots'), 'Snapshots ungeprueft abgefragt');
});
test('Ein Fehler dabei entwertet den VM-Abgleich nicht', function () use ($src) {
// Die VMs sind der Kern; Snapshots und Speicherorte sind Beiwerk.
$code = $src('/src/Hypervisor/HypervisorSyncService.php');
$von = strpos($code, 'instanceof ProvidesDatastores');
$block = substr($code, (int)$von, 700);
assertSame(2, substr_count($block, 'catch (\Throwable $e)'), 'Nicht beide Abrufe geschuetzt');
assertTrue(str_contains($block, '$this->warnungen[]'), 'Fehler wird verschwiegen');
});
// ----------------------------------------------------------------
// Speicherung
// ----------------------------------------------------------------
test('Ein abgehaengter Speicherort verschwindet wieder', function () use ($db, $ruf) {
$doppel = $db();
$svc = new HypervisorSyncService($doppel, 'k');
$n = $ruf($svc, 'uebernehmeDatastores', [5, [
['external_id' => 'local-lvm', 'name' => 'local-lvm', 'type' => 'lvmthin', 'capacity_gb' => 500.0, 'free_gb' => 120.0],
['external_id' => '', 'name' => 'ohne Kennung'],
]]);
assertSame(1, $n, 'Eintrag ohne Kennung nicht uebergangen');
$loeschung = array_values(array_filter(
$doppel->schreibvorgaenge,
static fn ($v) => str_contains($v['sql'], 'DELETE FROM vm_datastores')
));
assertSame(1, count($loeschung));
assertTrue(str_contains($loeschung[0]['sql'], 'external_id NOT IN'), 'Raeumt alles ab statt nur das Fehlende');
assertSame([5, 'local-lvm'], $loeschung[0]['params']);
});
test('Meldet der Hypervisor gar nichts, wird alles abgeraeumt', function () use ($db, $ruf) {
$doppel = $db();
$svc = new HypervisorSyncService($doppel, 'k');
$ruf($svc, 'uebernehmeDatastores', [5, []]);
$loeschung = array_values(array_filter(
$doppel->schreibvorgaenge,
static fn ($v) => str_contains($v['sql'], 'DELETE FROM vm_datastores')
));
// Ohne eigenen Zweig waere NOT IN () ungueltiges SQL.
assertSame('DELETE FROM vm_datastores WHERE host_id = ?', $loeschung[0]['sql']);
});
test('Snapshots landen an der richtigen VM', function () use ($db, $ruf) {
$doppel = $db([
['id' => 11, 'hypervisor_vm_id' => '100'],
['id' => 12, 'hypervisor_vm_id' => 'lxc-7'],
]);
$svc = new HypervisorSyncService($doppel, 'k');
$n = $ruf($svc, 'uebernehmeSnapshots', [5, [
['hypervisor_vm_id' => '100', 'external_id' => 'vor-update', 'name' => 'vor-update', 'is_current' => true],
['hypervisor_vm_id' => '999', 'external_id' => 'fremd', 'name' => 'fremd'],
['hypervisor_vm_id' => '100', 'external_id' => '', 'name' => 'ohne Kennung'],
]]);
assertSame(1, $n, 'Fremde oder unvollstaendige Snapshots nicht uebergangen');
$insert = array_values(array_filter(
$doppel->schreibvorgaenge,
static fn ($v) => str_contains($v['sql'], 'INSERT INTO vm_snapshots')
));
assertSame(11, $insert[0]['params'][0], 'Falsche VM');
assertSame(1, $insert[0]['params'][6], 'is_current nicht uebernommen');
});
test('Eine VM ohne Snapshots wird mit abgeraeumt', function () use ($db, $ruf) {
// Sonst blieben geloeschte Snapshots einer VM fuer immer stehen.
$doppel = $db([
['id' => 11, 'hypervisor_vm_id' => '100'],
['id' => 12, 'hypervisor_vm_id' => 'lxc-7'],
]);
$svc = new HypervisorSyncService($doppel, 'k');
$ruf($svc, 'uebernehmeSnapshots', [5, [
['hypervisor_vm_id' => '100', 'external_id' => 'snap', 'name' => 'snap'],
]]);
$loeschungen = array_values(array_filter(
$doppel->schreibvorgaenge,
static fn ($v) => str_contains($v['sql'], 'DELETE FROM vm_snapshots')
));
assertSame(2, count($loeschungen), 'Nicht jede VM aufgeraeumt');
// Die VM ohne Meldung verliert alle Snapshots.
assertSame('DELETE FROM vm_snapshots WHERE vm_id = ?', $loeschungen[1]['sql']);
assertSame([12], $loeschungen[1]['params']);
});
// ----------------------------------------------------------------
// Proxmox
// ----------------------------------------------------------------
test('Der Pseudo-Eintrag "current" wird nicht zum Snapshot', function () use ($src) {
// Proxmox listet den laufenden Stand als Eintrag mit; sein parent
// verraet aber, auf welchem Snapshot die VM sitzt.
$code = $src('/src/Hypervisor/ProxmoxConnector.php');
$von = strpos($code, 'public function getSnapshots');
$block = substr($code, (int)$von, 2200);
assertTrue(str_contains($block, "\$name === 'current'"), 'Pseudo-Eintrag wird uebernommen');
assertTrue(str_contains($block, "\$aktuell = (string)(\$eintrag['parent'] ?? '')"), 'Aktueller Stand nicht erkannt');
// Container haben denselben Praefix wie in der VM-Liste.
assertTrue(str_contains($block, "['lxc', 'lxc-']"), 'Container fehlen oder anderer Praefix');
});
test('Die Gast-Angaben kosten nur bei laufenden VMs Anfragen', function () use ($src) {
// Der QEMU Guest Agent antwortet nur in einer laufenden VM; sonst
// waeren es drei vergebliche Aufrufe je gestoppter VM.
$code = $src('/src/Hypervisor/ProxmoxConnector.php');
$von = strpos($code, 'private function normalizeQemuVm');
$block = substr($code, (int)$von, 900);
assertTrue(str_contains($block, "\$status === 'running'"), 'Auch gestoppte VMs werden befragt');
});
// ----------------------------------------------------------------
// Hyper-V
// ----------------------------------------------------------------
test('Hyper-V liefert alle Pruefpunkte in einem Aufruf', function () use ($src) {
// Ein Aufruf je VM waere hier reine Verschwendung.
$code = $src('/src/Hypervisor/HypervConnector.php');
$von = strpos($code, 'public function getSnapshots');
$block = substr($code, (int)$von, 1800);
assertSame(1, substr_count($block, '$this->runPowerShell('), 'Mehr als ein Aufruf');
assertTrue(str_contains($block, 'Get-VMSnapshot'), 'Falsches Kommando');
assertTrue(str_contains($block, 'VMId'), 'Ohne Zuordnung zur VM');
});
test('Ein einzelner Datensatz aus PowerShell geht nicht verloren', function () use ($ruf) {
// ConvertTo-Json schreibt ein einelementiges Ergebnis als Objekt.
$conn = new HypervConnector('host', 'u', 'p');
$einzeln = $ruf($conn, 'jsonListe', ['{"Id":"a","Name":"snap"}', 'Id']);
assertSame(1, count($einzeln), 'Einzelner Datensatz nicht erkannt');
assertSame('a', $einzeln[0]['Id']);
$liste = $ruf($conn, 'jsonListe', ['[{"Id":"a"},{"Id":"b"}]', 'Id']);
assertSame(['a', 'b'], array_column($liste, 'Id'));
assertSame([], $ruf($conn, 'jsonListe', ['', 'Id']));
assertSame([], $ruf($conn, 'jsonListe', ['kein json', 'Id']));
});
// ----------------------------------------------------------------
// Gastinformationen
// ----------------------------------------------------------------
test('Die Gast-Angaben sind optional', function () use ($src) {
// Nicht jeder Hypervisor meldet sie, und ohne laufende Tools gibt es
// sie auch dort nicht – ein leerer Wert darf nichts ueberschreiben.
$code = $src('/src/Hypervisor/HypervisorSyncService.php');
$von = strpos($code, 'private function upsertVm');
$block = substr($code, (int)$von, 2600);
// Die Anweisung steht als einfach zitierter String im Quelltext.
assertTrue(str_contains($block, 'guest_hostname=COALESCE(NULLIF(?,\\\'\\\'),guest_hostname)'), 'Leerer Wert ueberschreibt');
assertTrue(str_contains($block, 'guest_tools_version=COALESCE(NULLIF(?,\\\'\\\'),guest_tools_version)'), 'Leerer Wert ueberschreibt');
});
test('Die Migrationen sind wiederholbar', function () use ($wurzel) {
foreach (['215_vm_snapshots_datastores.sql', '216_vm_guest_info.sql'] as $datei) {
$sql = (string)file_get_contents($wurzel . '/database/migrations/' . $datei);
assertFalse(str_contains($sql, 'TRUNCATE'), "Zerstoerend: {$datei}");
assertFalse(
(bool)preg_match('/\bINSERT\s+INTO\b(?!.*ON DUPLICATE)/is', str_replace('INSERT IGNORE', 'X', $sql)),
"Nacktes INSERT: {$datei}"
);
if (str_contains($sql, 'CREATE TABLE')) {
assertTrue(str_contains($sql, 'CREATE TABLE IF NOT EXISTS'), "Ohne IF NOT EXISTS: {$datei}");
}
}
});
// ----------------------------------------------------------------
// Oberflaeche
// ----------------------------------------------------------------
test('Die Warnschwelle ist im Backend einstellbar', function () use ($src) {
$routen = $src('/src/Core/Application.php');
assertTrue(str_contains($routen, "'/admin/vms/snapshot-settings'"), 'Keine Route');
$ctrl = $src('/src/Controllers/Admin/VmHypervisorController.php');
$von = strpos($ctrl, 'public function saveSnapshotSettings');
$block = substr($ctrl, (int)$von, 1200);
assertTrue(str_contains($block, "authorize('admin_vms')"), 'Ungeschuetzt');
assertTrue(str_contains($block, 'vm_snapshot_warn_days'), 'Falscher Schluessel');
// 0 schaltet die Warnung ab und muss erhalten bleiben.
assertTrue(str_contains($block, 'max(0, min(3650'), 'Grenzen fehlen');
$tpl = $src('/templates/admin/vms/hypervisor_index.php');
assertTrue(str_contains($tpl, 'name="_csrf"'), 'Formular ohne Token');
assertTrue(str_contains($tpl, 'vm_snapshot_warn_days'), 'Kein Feld');
});
test('Die Kennzeichnung erscheint nur bei eingeschalteter Warnung', function () use ($src) {
$tpl = $src('/templates/vms/vm_show.php');
assertTrue(str_contains($tpl, '$warnTage > 0 && $alter !== null'), 'Warnung ungeprueft');
// Die Kachel taucht nur auf, wenn es wirklich alte Snapshots gibt.
$index = $src('/templates/vms/index.php');
assertTrue(str_contains($index, '($old_snapshots ?? 0) > 0'), 'Kachel immer sichtbar');
});
test('Die Anzeige uebersteht eine Instanz ohne die neuen Tabellen', function () use ($src) {
$mgr = $src('/src/Content/VmManager.php');
foreach (['getHostDatastores', 'getVmSnapshots', 'countVmsWithOldSnapshots'] as $methode) {
$von = strpos($mgr, 'function ' . $methode);
$block = substr($mgr, (int)$von, 900);
assertTrue(str_contains($block, 'catch (\Throwable)'), "Ungeschuetzt: {$methode}");
}
});
test('Jedes neue Symbol steht in der Emoji-Karte', function () use ($wurzel, $src) {
$map = $src('/src/Core/IconMap.php');
$css = (string)file_get_contents($wurzel . '/public/assets/fontawesome/css/fontawesome.min.css');
foreach (['fa-camera-retro'] as $icon) {
assertTrue(str_contains($map, "'{$icon}'"), "Nicht in der Karte: {$icon}");
assertTrue(str_contains($css, '.' . $icon . ':'), "Nicht in der Schrift: {$icon}");
}
});
};