DB Updater Install

This commit is contained in:
2026-03-30 17:06:34 +02:00
parent d408f70819
commit e6f7c0146f
3 changed files with 415 additions and 5 deletions
+107 -3
View File
@@ -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,
<a href="/admin/" class="<?= $page === 'overview' ? 'active' : '' ?>">Übersicht</a>
<a href="/admin/tenants/" class="<?= $page === 'tenants' ? 'active' : '' ?>">Mandanten</a>
<a href="/admin/updates/" class="<?= $page === 'updates' ? 'active' : '' ?>">Updates</a>
<a href="/admin/migration/" class="<?= $page === 'migration' ? 'active' : '' ?>">Migrationen</a>
<form method="post" action="/admin/logout/">
<input type="hidden" name="csrf" value="<?= admin_h($_SESSION['admin_csrf']) ?>">
<button type="submit" class="button secondary">Abmelden</button>
@@ -371,7 +402,7 @@ SQL,
</table>
</div>
</section>
<?php else: ?>
<?php elseif ($page === 'updates'): ?>
<section class="hero">
<div class="eyebrow">Update-Center</div>
<h1>System-Updates zentral prüfen und ausführen.</h1>
@@ -433,6 +464,79 @@ SQL,
</table>
</div>
</section>
<?php else: ?>
<section class="hero">
<div class="eyebrow">Migrations-Center</div>
<h1>Datenbank-Migrationen zentral prüfen und ausführen.</h1>
<p>Die SQL-Migrationen aus <code>database/migrations/</code> können hier nachvollziehbar über die zentrale Verwaltung eingespielt werden.</p>
</section>
<?php if ($dbError !== null): ?><section class="alert alert-warning"><?= admin_h($dbError) ?></section><?php endif; ?>
<section class="grid grid-3">
<article class="card">
<h2>Schema-Stand</h2>
<div class="stack">
<div class="metric"><h3>Offene Migrationen</h3><p><?= admin_h((string) $migrationSummary['pending']) ?></p></div>
<div class="metric"><h3>Erfolgreich</h3><p><?= admin_h((string) $migrationSummary['success']) ?></p></div>
<div class="metric"><h3>Fehlgeschlagen</h3><p><?= admin_h((string) $migrationSummary['failed']) ?></p></div>
</div>
</article>
<article class="card">
<h2>Migration-Ausführung</h2>
<p>Vorhandene Tabellen werden automatisch als bestehender Stand übernommen. Neue Migrationen werden nur einmal ausgeführt und protokolliert.</p>
<form method="post" action="/admin/migration/" style="margin-top:18px">
<input type="hidden" name="csrf" value="<?= admin_h($_SESSION['admin_csrf']) ?>">
<input type="hidden" name="action" value="run-migrations">
<button type="submit">Ausstehende Migrationen jetzt ausführen</button>
</form>
</article>
<article class="card">
<h2>Hinweise</h2>
<ul>
<li>Migrationen liegen unter <code>saas-app/database/migrations/</code>.</li>
<li>Die Weboberfläche führt nur fehlende Migrationen aus.</li>
<li>Bereits vorhandene Zieltabellen werden als Baseline protokolliert.</li>
</ul>
</article>
</section>
<section class="card" style="margin-top:18px">
<h2>Migrations-Protokoll</h2>
<div class="table">
<table>
<thead><tr><th>Migration</th><th>Status</th><th>Zieltabellen</th><th>Ausgeführt</th><th>Durch</th></tr></thead>
<tbody>
<?php foreach ($migrationItems as $item): ?>
<?php
$status = (string) ($item['status'] ?? 'pending');
$tone = match ($status) {
'success' => 'success',
'baselined' => 'success',
'failed' => 'error',
'running' => 'warning',
default => 'neutral',
};
$label = match ($status) {
'pending' => 'Offen',
'baselined' => 'Baseline',
default => ucfirst($status),
};
?>
<tr>
<td>
<strong><?= admin_h((string) $item['title']) ?></strong>
<small class="mono"><?= admin_h((string) $item['key']) ?></small>
<?php if (!empty($item['notes'])): ?><small><?= admin_h((string) $item['notes']) ?></small><?php endif; ?>
<?php if (!empty($item['error_message'])): ?><small><?= admin_h((string) $item['error_message']) ?></small><?php endif; ?>
</td>
<td><?= admin_badge($label, $tone) ?></td>
<td><?= admin_h(implode(', ', array_map('strval', $item['tables'] ?? []))) ?></td>
<td><?= admin_h((string) ($item['executed_at'] ?? '')) ?></td>
<td><?= admin_h((string) ($item['executed_by'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<?php endif; ?>
<p class="footer">Kaffeeliste Admin | zentrale Mandanten-Verwaltung, Lizenzsteuerung und Update-Prozess über PHP-Webseiten</p>
+1 -2
View File
@@ -2,5 +2,4 @@
declare(strict_types=1);
header('Location: /admin/', true, 302);
exit;
require dirname(__DIR__, 2) . '/admin.php';
+307
View File
@@ -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<int, string>
*/
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<string, mixed>
*/
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);