OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

VmSnapshotsTest.php

15 KB
<?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}");
        }
    });
};