adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-04-15 02:41:09 +02:00
parent 0b555e7dd4
commit 7157c98dcb
13 changed files with 614 additions and 20 deletions

View File

@@ -40,6 +40,17 @@ final class BaseSchema
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_migrations (
module_name TEXT NOT NULL,
migration TEXT NOT NULL,
version TEXT,
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (module_name, migration)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_auth (
name TEXT PRIMARY KEY,
@@ -117,6 +128,17 @@ final class BaseSchema
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_migrations (
module_name TEXT NOT NULL,
migration TEXT NOT NULL,
version TEXT,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (module_name, migration)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_auth (
name TEXT PRIMARY KEY,
@@ -194,6 +216,17 @@ final class BaseSchema
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_migrations (
module_name VARCHAR(190) NOT NULL,
migration VARCHAR(190) NOT NULL,
version VARCHAR(64),
checksum VARCHAR(64) NOT NULL,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (module_name, migration)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_auth (
name VARCHAR(190) PRIMARY KEY,

View File

@@ -65,6 +65,24 @@ final class ModuleManager
$this->modules[$name]['enabled'] = $enabled;
}
public function migrationStatus(string $name): array
{
if (!$this->basePdo || !isset($this->modules[$name])) {
return ['available' => 0, 'applied' => 0, 'pending' => [], 'changed' => []];
}
return (new ModuleMigrationService($this->basePdo, $this))->status($this->modules[$name]);
}
public function applyPendingMigrations(string $name): array
{
if (!$this->basePdo || !isset($this->modules[$name])) {
return [];
}
return (new ModuleMigrationService($this->basePdo, $this))->applyPending($this->modules[$name]);
}
public function settings(string $name): array
{
if (!$this->basePdo) {
@@ -260,6 +278,7 @@ final class ModuleManager
'sidebar' => $data['sidebar'] ?? [],
'db_defaults' => $data['db_defaults'] ?? [],
'metadata_db_defaults' => $data['metadata_db_defaults'] ?? [],
'schema_version' => (int)($data['schema_version'] ?? 0),
'path' => $dir,
'entry' => '/module/' . rawurlencode($name),
'auth' => $this->loadAuth($name, $manifestAuth),

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App;
final class ModuleMigrationContext
{
public function __construct(
public readonly \PDO $basePdo,
public readonly ModuleManager $modules,
public readonly array $module
) {}
public function settings(): array
{
return $this->modules->settings((string)$this->module['name']);
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App;
final class ModuleMigrationService
{
public function __construct(
private \PDO $basePdo,
private ModuleManager $modules
) {}
public function status(array $module): array
{
$migrations = $this->discover($module);
$applied = $this->applied((string)$module['name']);
$pending = [];
$changed = [];
foreach ($migrations as $migration) {
$id = $migration['id'];
if (!isset($applied[$id])) {
$pending[] = $migration;
continue;
}
if (($applied[$id]['checksum'] ?? '') !== $migration['checksum']) {
$changed[] = $migration;
}
}
return [
'available' => count($migrations),
'applied' => count($applied),
'pending' => $pending,
'changed' => $changed,
];
}
public function applyPending(array $module): array
{
$status = $this->status($module);
$applied = [];
$moduleName = (string)$module['name'];
$context = new ModuleMigrationContext($this->basePdo, $this->modules, $module);
foreach ($status['pending'] as $migration) {
$this->basePdo->beginTransaction();
try {
$runner = require $migration['path'];
if (is_object($runner) && method_exists($runner, 'up')) {
$runner->up($context);
} elseif (is_callable($runner)) {
$runner($context);
} else {
throw new \RuntimeException('Migration does not return a callable or object with up().');
}
$this->record($moduleName, $migration);
$this->basePdo->commit();
$applied[] = $migration;
} catch (\Throwable $e) {
if ($this->basePdo->inTransaction()) {
$this->basePdo->rollBack();
}
throw new \RuntimeException(
'Migration fehlgeschlagen: ' . $moduleName . '/' . $migration['id'] . ' - ' . $e->getMessage(),
0,
$e
);
}
}
if ($applied !== []) {
$this->recordModuleVersion($moduleName, (string)($module['version'] ?? ''));
}
return $applied;
}
private function discover(array $module): array
{
$path = (string)($module['path'] ?? '') . '/migrations';
if (!is_dir($path)) {
return [];
}
$items = [];
foreach (glob($path . '/*.php') ?: [] as $file) {
$id = basename($file, '.php');
$items[] = [
'id' => $id,
'version' => $this->versionFromId($id),
'path' => $file,
'checksum' => hash_file('sha256', $file) ?: '',
];
}
usort($items, static fn(array $a, array $b): int => strnatcmp($a['id'], $b['id']));
return $items;
}
private function applied(string $moduleName): array
{
$stmt = $this->basePdo->prepare(
"SELECT migration, version, checksum, applied_at
FROM nexus_module_migrations
WHERE module_name = :module
ORDER BY migration ASC"
);
$stmt->execute(['module' => $moduleName]);
$items = [];
foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) {
$items[(string)$row['migration']] = $row;
}
return $items;
}
private function record(string $moduleName, array $migration): void
{
$driver = (string)$this->basePdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
if ($driver === 'pgsql' || $driver === 'sqlite') {
$sql = "INSERT INTO nexus_module_migrations (module_name, migration, version, checksum, applied_at)
VALUES (:module, :migration, :version, :checksum, CURRENT_TIMESTAMP)
ON CONFLICT(module_name, migration) DO UPDATE SET
version = excluded.version,
checksum = excluded.checksum,
applied_at = CURRENT_TIMESTAMP";
} else {
$sql = "INSERT INTO nexus_module_migrations (module_name, migration, version, checksum, applied_at)
VALUES (:module, :migration, :version, :checksum, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
version = VALUES(version),
checksum = VALUES(checksum),
applied_at = CURRENT_TIMESTAMP";
}
$stmt = $this->basePdo->prepare($sql);
$stmt->execute([
'module' => $moduleName,
'migration' => $migration['id'],
'version' => $migration['version'],
'checksum' => $migration['checksum'],
]);
}
private function recordModuleVersion(string $moduleName, string $version): void
{
if ($version === '') {
return;
}
$stmt = $this->basePdo->prepare(
"UPDATE nexus_modules
SET version = :version,
updated_at = CURRENT_TIMESTAMP
WHERE name = :module"
);
$stmt->execute([
'module' => $moduleName,
'version' => $version,
]);
}
private function versionFromId(string $id): string
{
if (preg_match('/(?:^|_)(\d+\.\d+\.\d+)(?:_|$)/', $id, $m)) {
return $m[1];
}
return '';
}
}

View File

@@ -24,11 +24,15 @@ final class KeaHostMetadataRepository
owner TEXT,
location TEXT,
device_type TEXT,
group_name TEXT,
desired_ip TEXT,
notes TEXT,
tags_json JSONB,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$this->ensureColumn('nexus_dhcp_host_meta', 'group_name', 'TEXT');
$this->ensureColumn('nexus_dhcp_host_meta', 'desired_ip', 'TEXT');
return;
}
@@ -43,11 +47,15 @@ final class KeaHostMetadataRepository
owner TEXT,
location TEXT,
device_type TEXT,
group_name TEXT,
desired_ip TEXT,
notes TEXT,
tags_json TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$this->ensureColumn('nexus_dhcp_host_meta', 'group_name', 'TEXT');
$this->ensureColumn('nexus_dhcp_host_meta', 'desired_ip', 'TEXT');
return;
}
@@ -61,11 +69,16 @@ final class KeaHostMetadataRepository
owner TEXT,
location TEXT,
device_type TEXT,
group_name TEXT,
desired_ip TEXT,
notes TEXT,
tags_json TEXT,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"
);
$this->ensureColumn('nexus_dhcp_host_meta', 'group_name', 'TEXT');
$this->ensureColumn('nexus_dhcp_host_meta', 'desired_ip', 'TEXT');
}
public function findByHostIds(array $hostIds): array
@@ -84,7 +97,7 @@ final class KeaHostMetadataRepository
}
$stmt = $this->pdo->prepare(
'SELECT host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, notes, tags_json, updated_at
'SELECT host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, group_name, desired_ip, notes, tags_json, updated_at
FROM nexus_dhcp_host_meta
WHERE host_id IN (' . implode(', ', $placeholders) . ')'
);
@@ -109,6 +122,8 @@ final class KeaHostMetadataRepository
'owner' => null,
'location' => null,
'device_type' => null,
'group_name' => null,
'desired_ip' => null,
'notes' => null,
'tags' => [],
], $metadata);
@@ -122,9 +137,9 @@ final class KeaHostMetadataRepository
if ($driver === 'pgsql') {
$stmt = $this->pdo->prepare(
"INSERT INTO nexus_dhcp_host_meta (
host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, notes, tags_json, updated_at
host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, group_name, desired_ip, notes, tags_json, updated_at
) VALUES (
:host_id, :hardware_address, :ip_address, :real_name, :device_name, :owner, :location, :device_type, :notes, CAST(:tags_json AS jsonb), NOW()
:host_id, :hardware_address, :ip_address, :real_name, :device_name, :owner, :location, :device_type, :group_name, :desired_ip, :notes, CAST(:tags_json AS jsonb), NOW()
)
ON CONFLICT (host_id) DO UPDATE SET
hardware_address = EXCLUDED.hardware_address,
@@ -134,6 +149,8 @@ final class KeaHostMetadataRepository
owner = EXCLUDED.owner,
location = EXCLUDED.location,
device_type = EXCLUDED.device_type,
group_name = EXCLUDED.group_name,
desired_ip = EXCLUDED.desired_ip,
notes = EXCLUDED.notes,
tags_json = EXCLUDED.tags_json,
updated_at = NOW()"
@@ -141,9 +158,9 @@ final class KeaHostMetadataRepository
} else {
$stmt = $this->pdo->prepare(
"REPLACE INTO nexus_dhcp_host_meta (
host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, notes, tags_json, updated_at
host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, group_name, desired_ip, notes, tags_json, updated_at
) VALUES (
:host_id, :hardware_address, :ip_address, :real_name, :device_name, :owner, :location, :device_type, :notes, :tags_json, CURRENT_TIMESTAMP
:host_id, :hardware_address, :ip_address, :real_name, :device_name, :owner, :location, :device_type, :group_name, :desired_ip, :notes, :tags_json, CURRENT_TIMESTAMP
)"
);
}
@@ -157,11 +174,85 @@ final class KeaHostMetadataRepository
'owner' => $this->nullableString($metadata['owner']),
'location' => $this->nullableString($metadata['location']),
'device_type' => $this->nullableString($metadata['device_type']),
'group_name' => $this->nullableString($metadata['group_name']),
'desired_ip' => $this->nullableString($metadata['desired_ip']),
'notes' => $this->nullableString($metadata['notes']),
'tags_json' => $tagsJson,
]);
}
public function listGroups(): array
{
$stmt = $this->pdo->query(
"SELECT DISTINCT group_name
FROM nexus_dhcp_host_meta
WHERE group_name IS NOT NULL AND group_name <> ''
ORDER BY group_name ASC"
);
return array_values(array_filter(array_map(
static fn(array $row): string => (string)($row['group_name'] ?? ''),
$stmt->fetchAll(PDO::FETCH_ASSOC)
)));
}
private function ensureColumn(string $table, string $column, string $definition): void
{
if ($this->columnExists($table, $column)) {
return;
}
$driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$quote = $driver === 'mysql' ? '`' : '"';
try {
$this->pdo->exec(
'ALTER TABLE ' . $quote . $table . $quote
. ' ADD COLUMN ' . $quote . $column . $quote . ' ' . $definition
);
} catch (\PDOException $e) {
if (!$this->columnExists($table, $column)) {
throw $e;
}
}
}
private function columnExists(string $table, string $column): bool
{
$driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'sqlite') {
$stmt = $this->pdo->query('PRAGMA table_info(' . $table . ')');
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
if (($row['name'] ?? '') === $column) {
return true;
}
}
return false;
}
if ($driver === 'pgsql') {
$stmt = $this->pdo->prepare(
"SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = :table
AND column_name = :column
LIMIT 1"
);
} else {
$stmt = $this->pdo->prepare(
"SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table
AND column_name = :column
LIMIT 1"
);
}
$stmt->execute(['table' => $table, 'column' => $column]);
return (bool)$stmt->fetchColumn();
}
private function nullableString(mixed $value): ?string
{
$value = trim((string)$value);

View File

@@ -84,6 +84,7 @@ final class KeaHostRepository
{$ipExpr} AS ipv4_address_text,
hostname,
user_context,
dhcp4_subnet_id AS subnet_id,
'reservation' AS source,
host_id AS sort_id,
{$sortExpr} AS sort_time
@@ -114,6 +115,7 @@ final class KeaHostRepository
{$ipExpr} AS ipv4_address_text,
hostname,
user_context,
subnet_id,
'lease' AS source,
address AS sort_id,
{$expireExpr} AS sort_time
@@ -185,6 +187,7 @@ final class KeaHostRepository
{$ipExpr} AS ipv4_address_text,
hostname,
user_context,
dhcp4_subnet_id AS subnet_id,
'reservation' AS source,
host_id AS sort_id,
{$sortExpr} AS sort_time
@@ -220,16 +223,19 @@ final class KeaHostRepository
*/
public function create(string $mac, string $ip, int $subnetId, ?string $hostname = null, array $metadata = []): int
{
// dhcp_identifier_type 1 = HW_ADDRESS (Ethernet)
$macHex = $this->macToHex($mac);
$ipNumber = $this->ipv4ToNumber($ip);
$identifierExpr = $this->binaryFromHexExpression(':mac_hex');
$stmt = $this->pdo->prepare(
'INSERT INTO hosts (dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address, hostname)
VALUES (:mac, 1, :subnetId, :ip, :hostname)'
"INSERT INTO hosts (dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address, hostname)
VALUES ({$identifierExpr}, 1, :subnetId, :ip, :hostname)"
);
$stmt->execute([
'mac' => $mac,
'mac_hex' => $macHex,
'subnetId' => $subnetId,
'ip' => $ip,
'ip' => $ipNumber,
'hostname' => $hostname,
]);
@@ -241,6 +247,49 @@ final class KeaHostRepository
return $hostId;
}
public function reserveDisplayEntry(array $host, string $ip, array $metadata = []): int
{
$source = (string)($host['source'] ?? 'reservation');
$subnetId = (int)($host['subnet_id'] ?? 0);
if ($subnetId <= 0) {
throw new \RuntimeException('Subnet-ID fehlt. Ohne Subnet kann keine KEA-Reservierung angelegt werden.');
}
$hostname = (string)($host['hostname'] ?? '');
if ($source === 'reservation') {
$hostId = (int)($host['host_id'] ?? 0);
if ($hostId <= 0) {
throw new \RuntimeException('Reservierung hat keine gueltige Host-ID.');
}
$stmt = $this->pdo->prepare(
'UPDATE hosts
SET ipv4_address = :ip, hostname = :hostname, dhcp4_subnet_id = :subnet_id
WHERE host_id = :host_id'
);
$stmt->execute([
'ip' => $this->ipv4ToNumber($ip),
'hostname' => $hostname !== '' ? $hostname : null,
'subnet_id' => $subnetId,
'host_id' => $hostId,
]);
if ($this->metadata !== null) {
$this->metadata->saveForHost($hostId, (string)($host['dhcp_identifier'] ?? ''), $ip, $metadata);
}
return $hostId;
}
return $this->create(
(string)($host['dhcp_identifier'] ?? ''),
$ip,
$subnetId,
$hostname !== '' ? $hostname : null,
$metadata
);
}
private function withMetadata(array $hosts): array
{
if ($hosts === [] || $this->metadata === null) {
@@ -323,11 +372,50 @@ final class KeaHostRepository
$row['dhcp_identifier'] = $this->formatMac((string)($row['dhcp_identifier_hex'] ?? ''));
$row['ipv4_address'] = (string)($row['ipv4_address_text'] ?? '');
$row['hostname'] = (string)($row['hostname'] ?? '');
$contextName = $this->nameFromUserContext((string)($row['user_context'] ?? ''));
$row['display_name'] = $row['hostname'] !== '' ? $row['hostname'] : ($contextName !== '' ? $contextName : 'Unbekannt');
$row['metadata'] = [];
return $row;
}
private function nameFromUserContext(string $userContext): string
{
if (trim($userContext) === '') {
return '';
}
$decoded = json_decode($userContext, true);
if (!is_array($decoded)) {
return '';
}
return $this->findContextValue($decoded, ['hostname', 'host-name', 'client-hostname', 'device_name', 'name', 'label']);
}
private function findContextValue(array $data, array $keys): string
{
foreach ($keys as $key) {
if (isset($data[$key]) && is_scalar($data[$key])) {
$value = trim((string)$data[$key]);
if ($value !== '') {
return $value;
}
}
}
foreach ($data as $value) {
if (is_array($value)) {
$found = $this->findContextValue($value, $keys);
if ($found !== '') {
return $found;
}
}
}
return '';
}
private function formatMac(string $hex): string
{
$hex = strtolower(preg_replace('/[^a-fA-F0-9]/', '', $hex) ?? '');
@@ -338,6 +426,35 @@ final class KeaHostRepository
return implode(':', str_split($hex, 2));
}
private function macToHex(string $mac): string
{
$hex = strtolower(preg_replace('/[^a-fA-F0-9]/', '', $mac) ?? '');
if ($hex === '') {
throw new \RuntimeException('MAC-Adresse fehlt.');
}
return $hex;
}
private function binaryFromHexExpression(string $placeholder): string
{
return match ($this->driver()) {
'pgsql' => "decode({$placeholder}, 'hex')",
'mysql' => "UNHEX({$placeholder})",
default => $placeholder,
};
}
private function ipv4ToNumber(string $ip): string
{
$long = ip2long($ip);
if ($long === false) {
throw new \RuntimeException('Ungueltige IPv4-Adresse.');
}
return sprintf('%u', $long);
}
private function lastInsertIdSafe(): int
{
$driver = $this->driver();