From e6f7c0146f0a4d085807ebfe50623a5956c2bd6e Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Mon, 30 Mar 2026 17:06:34 +0200 Subject: [PATCH] DB Updater Install --- saas-app/public/admin.php | 110 +++++++- saas-app/public/admin/migration/index.php | 3 +- saas-app/public/install-support.php | 307 ++++++++++++++++++++++ 3 files changed, 415 insertions(+), 5 deletions(-) diff --git a/saas-app/public/admin.php b/saas-app/public/admin.php index 6db4c3b..9ee1a0f 100644 --- a/saas-app/public/admin.php +++ b/saas-app/public/admin.php @@ -34,6 +34,10 @@ function admin_update_summary(array $items): array foreach ($items as $item) { $status = (string) ($item['status'] ?? 'pending'); + if ($status === 'baselined') { + $status = 'success'; + } + if (array_key_exists($status, $summary)) { $summary[$status]++; } @@ -79,6 +83,7 @@ $page = match ($path) { '/admin/login' => 'login', '/admin/tenants' => 'tenants', '/admin/updates' => 'updates', + '/admin/migration' => 'migration', '/admin/logout' => 'logout', default => 'login', }; @@ -94,6 +99,8 @@ $summaryMetrics = []; $licensePlans = []; $updateItems = []; $updateSummary = ['pending' => 0, 'success' => 0, 'failed' => 0, 'running' => 0]; +$migrationItems = []; +$migrationSummary = ['pending' => 0, 'success' => 0, 'failed' => 0, 'running' => 0]; $appVersion = is_file(dirname(__DIR__) . '/version.php') ? (string) require dirname(__DIR__) . '/version.php' : 'unbekannt'; if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && $page === 'logout') { @@ -116,7 +123,7 @@ if ($page === 'login' && $pdo instanceof PDO) { $admin = app_admin_user(); } -if (in_array($page, ['overview', 'tenants', 'updates'], true)) { +if (in_array($page, ['overview', 'tenants', 'updates', 'migration'], true)) { $admin = app_require_platform_admin(); if (!$pdo instanceof PDO) { @@ -124,7 +131,7 @@ if (in_array($page, ['overview', 'tenants', 'updates'], true)) { } } -if (in_array($page, ['overview', 'tenants', 'updates'], true) && $pdo instanceof PDO) { +if (in_array($page, ['overview', 'tenants', 'updates', 'migration'], true) && $pdo instanceof PDO) { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && !hash_equals($_SESSION['admin_csrf'], (string) ($_POST['csrf'] ?? ''))) { app_flash('Die Sitzung ist abgelaufen. Bitte lade die Seite neu.', 'error'); app_redirect('/admin/' . ($page === 'overview' ? '' : $page . '/')); @@ -155,10 +162,33 @@ if (in_array($page, ['overview', 'tenants', 'updates'], true) && $pdo instanceof app_redirect('/admin/updates/'); } + if ($page === 'migration' && (string) ($_POST['action'] ?? '') === 'run-migrations') { + try { + $results = scripts_run_pending_migrations( + scripts_update_config_from_env(app_env()), + 'admin:' . (string) ($admin['email'] ?? 'platform') + ); + + $successful = count(array_filter($results, static fn(array $result): bool => in_array(($result['status'] ?? ''), ['success', 'baselined'], true))); + $skipped = count(array_filter($results, static fn(array $result): bool => ($result['status'] ?? '') === 'skipped')); + + app_flash( + $successful . ' Migration(en) verarbeitet, ' . $skipped . ' bereits erledigt.', + 'success' + ); + } catch (Throwable $exception) { + app_flash('Die Migrationen konnten nicht abgeschlossen werden: ' . $exception->getMessage(), 'error'); + } + + app_redirect('/admin/migration/'); + } + $tenants = app_admin_tenant_list($pdo); $licensePlans = app_license_plan_options($pdo); $updateItems = scripts_list_updates_status(scripts_update_config_from_env(app_env())); $updateSummary = admin_update_summary($updateItems); + $migrationItems = scripts_list_migrations_status(scripts_update_config_from_env(app_env())); + $migrationSummary = admin_update_summary($migrationItems); $summaryMetrics = admin_summary_metrics($tenants, $updateItems); if ($page === 'tenants' && isset($_GET['edit']) && $_GET['edit'] !== '') { @@ -211,6 +241,7 @@ SQL, Übersicht Mandanten Updates + Migrationen
@@ -371,7 +402,7 @@ SQL, - +
Update-Center

System-Updates zentral prüfen und ausführen.

@@ -433,6 +464,79 @@ SQL,
+ +
+
Migrations-Center
+

Datenbank-Migrationen zentral prüfen und ausführen.

+

Die SQL-Migrationen aus database/migrations/ können hier nachvollziehbar über die zentrale Verwaltung eingespielt werden.

+
+
+
+
+

Schema-Stand

+
+

Offene Migrationen

+

Erfolgreich

+

Fehlgeschlagen

+
+
+
+

Migration-Ausführung

+

Vorhandene Tabellen werden automatisch als bestehender Stand übernommen. Neue Migrationen werden nur einmal ausgeführt und protokolliert.

+ + + + + +
+
+

Hinweise

+
    +
  • Migrationen liegen unter saas-app/database/migrations/.
  • +
  • Die Weboberfläche führt nur fehlende Migrationen aus.
  • +
  • Bereits vorhandene Zieltabellen werden als Baseline protokolliert.
  • +
+
+
+
+

Migrations-Protokoll

+
+ + + + + 'success', + 'baselined' => 'success', + 'failed' => 'error', + 'running' => 'warning', + default => 'neutral', + }; + $label = match ($status) { + 'pending' => 'Offen', + 'baselined' => 'Baseline', + default => ucfirst($status), + }; + ?> + + + + + + + + + +
MigrationStatusZieltabellenAusgeführtDurch
+ + + + +
+
+
diff --git a/saas-app/public/admin/migration/index.php b/saas-app/public/admin/migration/index.php index eacb2aa..99e5eee 100644 --- a/saas-app/public/admin/migration/index.php +++ b/saas-app/public/admin/migration/index.php @@ -2,5 +2,4 @@ declare(strict_types=1); -header('Location: /admin/', true, 302); -exit; +require dirname(__DIR__, 2) . '/admin.php'; diff --git a/saas-app/public/install-support.php b/saas-app/public/install-support.php index 59db2d4..f59d122 100644 --- a/saas-app/public/install-support.php +++ b/saas-app/public/install-support.php @@ -868,6 +868,313 @@ function scripts_run_pending_updates(array $config, ?string $executedBy = null): return $results; } +function scripts_ensure_migrations_table(PDO $pdo, string $connection): void +{ + if (scripts_table_exists($pdo, 'app_migrations')) { + return; + } + + if ($connection === 'sqlsrv') { + $pdo->exec( + <<<'SQL' +CREATE TABLE app_migrations ( + id CHAR(36) NOT NULL PRIMARY KEY, + migration_key VARCHAR(190) NOT NULL, + title VARCHAR(255) NOT NULL, + checksum VARCHAR(64) NULL, + status VARCHAR(50) NOT NULL, + executed_at DATETIME NULL, + execution_time_ms INT NULL, + executed_by VARCHAR(255) NULL, + notes NVARCHAR(MAX) NULL, + error_message NVARCHAR(MAX) NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + CONSTRAINT app_migrations_migration_key_unique UNIQUE (migration_key) +); +SQL + ); + + return; + } + + $pdo->exec( + <<<'SQL' +CREATE TABLE IF NOT EXISTS app_migrations ( + id CHAR(36) NOT NULL PRIMARY KEY, + migration_key VARCHAR(190) NOT NULL, + title VARCHAR(255) NOT NULL, + checksum VARCHAR(64) NULL, + status VARCHAR(50) NOT NULL, + executed_at DATETIME NULL, + execution_time_ms INT NULL, + executed_by VARCHAR(255) NULL, + notes TEXT NULL, + error_message TEXT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY app_migrations_migration_key_unique (migration_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +SQL + ); +} + +function scripts_migration_files(): array +{ + $dir = scripts_saas_app_path() + . DIRECTORY_SEPARATOR . 'database' + . DIRECTORY_SEPARATOR . 'migrations'; + + if (!is_dir($dir)) { + return []; + } + + $files = glob($dir . DIRECTORY_SEPARATOR . '*.php') ?: []; + sort($files, SORT_STRING); + + return $files; +} + +/** + * @return array + */ +function scripts_declared_tables_from_sql(string $sql): array +{ + preg_match_all( + '/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"\[]?([A-Za-z0-9_.]+)[`"\]]?/i', + $sql, + $matches + ); + + $tables = array_map( + static fn(string $table): string => trim($table), + $matches[1] ?? [] + ); + + return array_values(array_unique(array_filter($tables, static fn(string $table): bool => $table !== ''))); +} + +/** + * @return array + */ +function scripts_load_migration_definition(string $file, string $connection): array +{ + $sql = scripts_load_migration_sql($file, $connection); + $key = pathinfo($file, PATHINFO_FILENAME); + + return [ + 'key' => $key, + 'title' => $key, + 'file' => $file, + 'checksum' => sha1_file($file) ?: null, + 'sql' => $sql, + 'tables' => scripts_declared_tables_from_sql($sql), + ]; +} + +function scripts_applied_migrations(PDO $pdo): array +{ + if (!scripts_table_exists($pdo, 'app_migrations')) { + return []; + } + + $rows = scripts_query_all( + $pdo, + 'SELECT migration_key, status, checksum, executed_at, execution_time_ms, executed_by, notes, error_message, title, id FROM app_migrations ORDER BY migration_key ASC' + ); + $applied = []; + + foreach ($rows as $row) { + $applied[(string) $row['migration_key']] = $row; + } + + return $applied; +} + +function scripts_migration_targets_exist(PDO $pdo, array $definition): bool +{ + $tables = $definition['tables'] ?? []; + + if (!is_array($tables) || $tables === []) { + return false; + } + + foreach ($tables as $table) { + if (!scripts_table_exists($pdo, (string) $table)) { + return false; + } + } + + return true; +} + +function scripts_list_migrations_status(array $config): array +{ + $pdo = scripts_connect_pdo($config); + $connection = scripts_normalize_db_connection((string) ($config['connection'] ?? 'mysql')); + scripts_ensure_migrations_table($pdo, $connection); + + $applied = scripts_applied_migrations($pdo); + $items = []; + + foreach (scripts_migration_files() as $file) { + $definition = scripts_load_migration_definition($file, $connection); + $current = $applied[$definition['key']] ?? null; + $status = $current['status'] ?? 'pending'; + $notes = $current['notes'] ?? null; + + if ($current === null && scripts_migration_targets_exist($pdo, $definition)) { + $status = 'baselined'; + $notes = 'Tabellen sind bereits vorhanden und koennen als bestehender Stand uebernommen werden.'; + } elseif ($status === 'success') { + $status = 'success'; + } elseif ($status === 'running') { + $status = 'running'; + } elseif ($current !== null) { + $status = 'failed'; + } + + $items[] = [ + 'key' => $definition['key'], + 'title' => $definition['title'], + 'status' => $status, + 'checksum' => $definition['checksum'], + 'tables' => $definition['tables'], + 'executed_at' => $current['executed_at'] ?? null, + 'execution_time_ms' => $current['execution_time_ms'] ?? null, + 'executed_by' => $current['executed_by'] ?? null, + 'notes' => $notes, + 'error_message' => $current['error_message'] ?? null, + ]; + } + + return $items; +} + +function scripts_run_pending_migrations(array $config, ?string $executedBy = null): array +{ + $pdo = scripts_connect_pdo($config); + $connection = scripts_normalize_db_connection((string) ($config['connection'] ?? 'mysql')); + scripts_ensure_migrations_table($pdo, $connection); + $applied = scripts_applied_migrations($pdo); + $results = []; + + foreach (scripts_migration_files() as $file) { + $definition = scripts_load_migration_definition($file, $connection); + $current = $applied[$definition['key']] ?? null; + + if (($current['status'] ?? null) === 'success') { + $results[] = [ + 'key' => $definition['key'], + 'title' => $definition['title'], + 'status' => 'skipped', + 'message' => 'Bereits erfolgreich ausgefuehrt.', + ]; + continue; + } + + if ($current === null && scripts_migration_targets_exist($pdo, $definition)) { + $migrationId = scripts_uuid(); + $now = date('Y-m-d H:i:s'); + $notes = 'Automatisch als bestehender Stand uebernommen, da die Zieltabellen bereits vorhanden sind.'; + + scripts_execute( + $pdo, + 'INSERT INTO app_migrations (id, migration_key, title, checksum, status, executed_at, execution_time_ms, executed_by, notes, error_message, created_at, updated_at) VALUES (:id, :migration_key, :title, :checksum, :status, :executed_at, 0, :executed_by, :notes, NULL, :created_at, :updated_at)', + [ + 'id' => $migrationId, + 'migration_key' => $definition['key'], + 'title' => $definition['title'], + 'checksum' => $definition['checksum'], + 'status' => 'success', + 'executed_at' => $now, + 'executed_by' => $executedBy !== null ? $executedBy . ' (baseline)' : 'baseline', + 'notes' => $notes, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + $results[] = [ + 'key' => $definition['key'], + 'title' => $definition['title'], + 'status' => 'baselined', + 'message' => $notes, + ]; + continue; + } + + $start = microtime(true); + $migrationId = (string) ($current['id'] ?? scripts_uuid()); + $now = date('Y-m-d H:i:s'); + + scripts_execute( + $pdo, + 'DELETE FROM app_migrations WHERE migration_key = :migration_key', + ['migration_key' => $definition['key']] + ); + + scripts_execute( + $pdo, + 'INSERT INTO app_migrations (id, migration_key, title, checksum, status, executed_at, execution_time_ms, executed_by, notes, error_message, created_at, updated_at) VALUES (:id, :migration_key, :title, :checksum, :status, NULL, NULL, :executed_by, NULL, NULL, :created_at, :updated_at)', + [ + 'id' => $migrationId, + 'migration_key' => $definition['key'], + 'title' => $definition['title'], + 'checksum' => $definition['checksum'], + 'status' => 'running', + 'executed_by' => $executedBy, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + try { + $pdo->exec((string) $definition['sql']); + $duration = (int) round((microtime(true) - $start) * 1000); + + scripts_execute( + $pdo, + 'UPDATE app_migrations SET status = :status, executed_at = :executed_at, execution_time_ms = :execution_time_ms, notes = :notes, error_message = NULL, updated_at = :updated_at WHERE id = :id', + [ + 'status' => 'success', + 'executed_at' => date('Y-m-d H:i:s'), + 'execution_time_ms' => $duration, + 'notes' => 'Migration erfolgreich ausgefuehrt.', + 'updated_at' => date('Y-m-d H:i:s'), + 'id' => $migrationId, + ] + ); + + $results[] = [ + 'key' => $definition['key'], + 'title' => $definition['title'], + 'status' => 'success', + 'message' => 'Erfolgreich ausgefuehrt.', + ]; + } catch (Throwable $exception) { + $duration = (int) round((microtime(true) - $start) * 1000); + + scripts_execute( + $pdo, + 'UPDATE app_migrations SET status = :status, executed_at = :executed_at, execution_time_ms = :execution_time_ms, error_message = :error_message, updated_at = :updated_at WHERE id = :id', + [ + 'status' => 'failed', + 'executed_at' => date('Y-m-d H:i:s'), + 'execution_time_ms' => $duration, + 'error_message' => $exception->getMessage(), + 'updated_at' => date('Y-m-d H:i:s'), + 'id' => $migrationId, + ] + ); + + throw $exception; + } + } + + return $results; +} + function scripts_role_id(string $roleKey, string $scope): string { return scripts_uuid_from_string('role:' . $scope . ':' . $roleKey);