migration.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. # -*- coding: utf-8 -*-
  2. """
  3. Konfigurationsmigrationen für Versionsübergänge.
  4. Ermöglicht strukturierte Migrationen zwischen Konfigurationsversionen
  5. mit Rollback-Unterstützung.
  6. """
  7. from __future__ import annotations
  8. import copy
  9. import json
  10. from abc import ABC, abstractmethod
  11. from dataclasses import dataclass, field
  12. from datetime import datetime
  13. from pathlib import Path
  14. from typing import Any, Callable, Sequence
  15. class MigrationError(Exception):
  16. """
  17. Exception bei Migrationsfehlern.
  18. """
  19. def __init__(
  20. self,
  21. message: str,
  22. migration_name: str = "",
  23. from_version: str = "",
  24. to_version: str = "",
  25. original_error: Exception | None = None,
  26. ) -> None:
  27. """
  28. Initialisiert den MigrationError.
  29. Args:
  30. message: Fehlermeldung.
  31. migration_name: Name der fehlgeschlagenen Migration.
  32. from_version: Ausgangsversion.
  33. to_version: Zielversion.
  34. original_error: Ursprünglicher Fehler.
  35. """
  36. super().__init__(message)
  37. self.migration_name = migration_name
  38. self.from_version = from_version
  39. self.to_version = to_version
  40. self.original_error = original_error
  41. @dataclass
  42. class MigrationResult:
  43. """
  44. Ergebnis einer Migration.
  45. """
  46. success: bool
  47. """Ob die Migration erfolgreich war."""
  48. from_version: str
  49. """Ausgangsversion."""
  50. to_version: str
  51. """Zielversion."""
  52. migrations_applied: list[str] = field(default_factory=list)
  53. """Liste der angewendeten Migrationen."""
  54. config: dict[str, Any] = field(default_factory=dict)
  55. """Resultierende Konfiguration."""
  56. errors: list[str] = field(default_factory=list)
  57. """Fehlermeldungen falls nicht erfolgreich."""
  58. backup_path: str | None = None
  59. """Pfad zum Backup falls erstellt."""
  60. executed_at: datetime = field(default_factory=datetime.now)
  61. """Zeitpunkt der Ausführung."""
  62. class ConfigMigration(ABC):
  63. """
  64. Abstrakte Basisklasse für Konfigurationsmigrationen.
  65. Jede Migration definiert einen Übergang von einer Version
  66. zur nächsten.
  67. Example:
  68. class Migration_1_0_to_2_0(ConfigMigration):
  69. from_version = "1.0"
  70. to_version = "2.0"
  71. def up(self, config):
  72. # Alt: {"server": {"host": "..."}}
  73. # Neu: {"network": {"host": "..."}}
  74. if "server" in config:
  75. config["network"] = config.pop("server")
  76. return config
  77. def down(self, config):
  78. if "network" in config:
  79. config["server"] = config.pop("network")
  80. return config
  81. """
  82. from_version: str = ""
  83. """Ausgangsversion."""
  84. to_version: str = ""
  85. """Zielversion."""
  86. description: str = ""
  87. """Beschreibung der Migration."""
  88. @property
  89. def name(self) -> str:
  90. """Name der Migration."""
  91. return f"{self.from_version}_to_{self.to_version}"
  92. @abstractmethod
  93. def up(self, config: dict[str, Any]) -> dict[str, Any]:
  94. """
  95. Führt die Migration vorwärts aus.
  96. Args:
  97. config: Aktuelle Konfiguration.
  98. Returns:
  99. Migrierte Konfiguration.
  100. """
  101. pass
  102. def down(self, config: dict[str, Any]) -> dict[str, Any]:
  103. """
  104. Führt die Migration rückwärts aus (Rollback).
  105. Args:
  106. config: Aktuelle Konfiguration.
  107. Returns:
  108. Zurückmigrierte Konfiguration.
  109. Note:
  110. Optional. Nicht alle Migrationen sind umkehrbar.
  111. """
  112. raise NotImplementedError(
  113. f"Migration {self.name} unterstützt kein Rollback"
  114. )
  115. def validate_before(self, config: dict[str, Any]) -> bool:
  116. """
  117. Validiert die Konfiguration vor der Migration.
  118. Args:
  119. config: Zu prüfende Konfiguration.
  120. Returns:
  121. True wenn gültig.
  122. """
  123. return True
  124. def validate_after(self, config: dict[str, Any]) -> bool:
  125. """
  126. Validiert die Konfiguration nach der Migration.
  127. Args:
  128. config: Zu prüfende Konfiguration.
  129. Returns:
  130. True wenn gültig.
  131. """
  132. return True
  133. class SimpleMigration(ConfigMigration):
  134. """
  135. Einfache Migration mit Funktionen statt Klassen.
  136. """
  137. def __init__(
  138. self,
  139. from_version: str,
  140. to_version: str,
  141. up_func: Callable[[dict[str, Any]], dict[str, Any]],
  142. down_func: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
  143. description: str = "",
  144. ) -> None:
  145. """
  146. Initialisiert die einfache Migration.
  147. Args:
  148. from_version: Ausgangsversion.
  149. to_version: Zielversion.
  150. up_func: Vorwärts-Migrationsfunktion.
  151. down_func: Optionale Rückwärts-Migrationsfunktion.
  152. description: Beschreibung.
  153. """
  154. self.from_version = from_version
  155. self.to_version = to_version
  156. self._up_func = up_func
  157. self._down_func = down_func
  158. self.description = description
  159. def up(self, config: dict[str, Any]) -> dict[str, Any]:
  160. """Führt die Migration vorwärts aus."""
  161. return self._up_func(config)
  162. def down(self, config: dict[str, Any]) -> dict[str, Any]:
  163. """Führt die Migration rückwärts aus."""
  164. if self._down_func:
  165. return self._down_func(config)
  166. raise NotImplementedError(
  167. f"Migration {self.name} unterstützt kein Rollback"
  168. )
  169. class ConfigMigrator:
  170. """
  171. Verwaltet und führt Konfigurationsmigrationen aus.
  172. Findet automatisch den Migrationspfad zwischen Versionen
  173. und führt die notwendigen Migrationen aus.
  174. Example:
  175. migrator = ConfigMigrator()
  176. # Migrationen registrieren
  177. migrator.register(Migration_1_0_to_2_0())
  178. migrator.register(Migration_2_0_to_3_0())
  179. # Migrieren
  180. result = migrator.migrate(
  181. config=old_config,
  182. from_version="1.0",
  183. to_version="3.0"
  184. )
  185. """
  186. VERSION_KEY = "_config_version"
  187. """Schlüssel für die Versionsangabe in der Konfiguration."""
  188. def __init__(
  189. self,
  190. version_key: str | None = None,
  191. backup_dir: str | Path | None = None,
  192. ) -> None:
  193. """
  194. Initialisiert den Migrator.
  195. Args:
  196. version_key: Schlüssel für die Versionsangabe.
  197. backup_dir: Verzeichnis für Backups.
  198. """
  199. self._version_key = version_key or self.VERSION_KEY
  200. self._backup_dir = Path(backup_dir) if backup_dir else None
  201. self._migrations: dict[str, ConfigMigration] = {}
  202. self._version_graph: dict[str, dict[str, str]] = {}
  203. def register(self, migration: ConfigMigration) -> "ConfigMigrator":
  204. """
  205. Registriert eine Migration.
  206. Args:
  207. migration: Die zu registrierende Migration.
  208. Returns:
  209. Self für Method-Chaining.
  210. """
  211. key = f"{migration.from_version}:{migration.to_version}"
  212. self._migrations[key] = migration
  213. # Graph aktualisieren
  214. if migration.from_version not in self._version_graph:
  215. self._version_graph[migration.from_version] = {}
  216. self._version_graph[migration.from_version][migration.to_version] = key
  217. return self
  218. def register_simple(
  219. self,
  220. from_version: str,
  221. to_version: str,
  222. up_func: Callable[[dict[str, Any]], dict[str, Any]],
  223. down_func: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
  224. description: str = "",
  225. ) -> "ConfigMigrator":
  226. """
  227. Registriert eine einfache Migration.
  228. Args:
  229. from_version: Ausgangsversion.
  230. to_version: Zielversion.
  231. up_func: Vorwärts-Funktion.
  232. down_func: Rückwärts-Funktion.
  233. description: Beschreibung.
  234. Returns:
  235. Self für Method-Chaining.
  236. """
  237. migration = SimpleMigration(
  238. from_version=from_version,
  239. to_version=to_version,
  240. up_func=up_func,
  241. down_func=down_func,
  242. description=description,
  243. )
  244. return self.register(migration)
  245. def _find_path(
  246. self,
  247. from_version: str,
  248. to_version: str,
  249. ) -> list[str] | None:
  250. """
  251. Findet den Migrationspfad zwischen zwei Versionen.
  252. Args:
  253. from_version: Ausgangsversion.
  254. to_version: Zielversion.
  255. Returns:
  256. Liste von Migrationsschlüsseln oder None.
  257. """
  258. if from_version == to_version:
  259. return []
  260. # BFS für kürzesten Pfad
  261. from collections import deque
  262. queue = deque([(from_version, [])])
  263. visited = {from_version}
  264. while queue:
  265. current, path = queue.popleft()
  266. if current not in self._version_graph:
  267. continue
  268. for next_version, migration_key in self._version_graph[current].items():
  269. if next_version == to_version:
  270. return path + [migration_key]
  271. if next_version not in visited:
  272. visited.add(next_version)
  273. queue.append((next_version, path + [migration_key]))
  274. return None
  275. def _create_backup(
  276. self,
  277. config: dict[str, Any],
  278. version: str,
  279. ) -> str | None:
  280. """Erstellt ein Backup der Konfiguration."""
  281. if not self._backup_dir:
  282. return None
  283. self._backup_dir.mkdir(parents=True, exist_ok=True)
  284. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  285. backup_path = self._backup_dir / f"config_v{version}_{timestamp}.json"
  286. with open(backup_path, "w", encoding="utf-8") as f:
  287. json.dump(config, f, indent=2)
  288. return str(backup_path)
  289. def get_version(self, config: dict[str, Any]) -> str | None:
  290. """
  291. Ermittelt die Version einer Konfiguration.
  292. Args:
  293. config: Die Konfiguration.
  294. Returns:
  295. Versionsstring oder None.
  296. """
  297. return config.get(self._version_key)
  298. def set_version(
  299. self,
  300. config: dict[str, Any],
  301. version: str,
  302. ) -> dict[str, Any]:
  303. """
  304. Setzt die Version in einer Konfiguration.
  305. Args:
  306. config: Die Konfiguration.
  307. version: Die zu setzende Version.
  308. Returns:
  309. Aktualisierte Konfiguration.
  310. """
  311. config = dict(config)
  312. config[self._version_key] = version
  313. return config
  314. def migrate(
  315. self,
  316. config: dict[str, Any],
  317. from_version: str | None = None,
  318. to_version: str = "",
  319. create_backup: bool = True,
  320. dry_run: bool = False,
  321. ) -> MigrationResult:
  322. """
  323. Migriert eine Konfiguration.
  324. Args:
  325. config: Die zu migrierende Konfiguration.
  326. from_version: Ausgangsversion (oder aus config lesen).
  327. to_version: Zielversion.
  328. create_backup: Ob ein Backup erstellt werden soll.
  329. dry_run: Ob nur simuliert werden soll.
  330. Returns:
  331. MigrationResult mit Ergebnis.
  332. """
  333. # Kopie erstellen
  334. config = copy.deepcopy(config)
  335. # Version ermitteln
  336. if from_version is None:
  337. from_version = self.get_version(config)
  338. if from_version is None:
  339. return MigrationResult(
  340. success=False,
  341. from_version="",
  342. to_version=to_version,
  343. errors=["Keine Ausgangsversion gefunden"],
  344. )
  345. # Pfad finden
  346. path = self._find_path(from_version, to_version)
  347. if path is None:
  348. return MigrationResult(
  349. success=False,
  350. from_version=from_version,
  351. to_version=to_version,
  352. errors=[
  353. f"Kein Migrationspfad von {from_version} nach {to_version}"
  354. ],
  355. )
  356. if not path:
  357. return MigrationResult(
  358. success=True,
  359. from_version=from_version,
  360. to_version=to_version,
  361. config=config,
  362. )
  363. # Backup erstellen
  364. backup_path = None
  365. if create_backup and not dry_run:
  366. backup_path = self._create_backup(config, from_version)
  367. # Migrationen ausführen
  368. applied: list[str] = []
  369. errors: list[str] = []
  370. current_config = config
  371. for migration_key in path:
  372. migration = self._migrations[migration_key]
  373. try:
  374. # Validierung vor Migration
  375. if not migration.validate_before(current_config):
  376. errors.append(
  377. f"Vor-Validierung für {migration.name} fehlgeschlagen"
  378. )
  379. break
  380. # Migration ausführen
  381. if not dry_run:
  382. current_config = migration.up(current_config)
  383. # Validierung nach Migration
  384. if not migration.validate_after(current_config):
  385. errors.append(
  386. f"Nach-Validierung für {migration.name} fehlgeschlagen"
  387. )
  388. break
  389. applied.append(migration.name)
  390. except Exception as e:
  391. errors.append(f"Migration {migration.name} fehlgeschlagen: {e}")
  392. break
  393. # Version aktualisieren
  394. if not errors and not dry_run:
  395. current_config = self.set_version(current_config, to_version)
  396. return MigrationResult(
  397. success=len(errors) == 0,
  398. from_version=from_version,
  399. to_version=to_version if not errors else "",
  400. migrations_applied=applied,
  401. config=current_config,
  402. errors=errors,
  403. backup_path=backup_path,
  404. )
  405. def rollback(
  406. self,
  407. config: dict[str, Any],
  408. steps: int = 1,
  409. ) -> MigrationResult:
  410. """
  411. Führt ein Rollback durch.
  412. Args:
  413. config: Aktuelle Konfiguration.
  414. steps: Anzahl der Migrationsschritte zurück.
  415. Returns:
  416. MigrationResult mit Ergebnis.
  417. Note:
  418. Erfordert reversible Migrationen.
  419. """
  420. current_version = self.get_version(config)
  421. if not current_version:
  422. return MigrationResult(
  423. success=False,
  424. from_version="",
  425. to_version="",
  426. errors=["Keine Version in Konfiguration gefunden"],
  427. )
  428. # Finde vorherige Versionen
  429. # Dies ist eine vereinfachte Implementierung
  430. # In Produktion würde man eine Historie führen
  431. return MigrationResult(
  432. success=False,
  433. from_version=current_version,
  434. to_version="",
  435. errors=["Rollback nicht implementiert"],
  436. )
  437. def list_migrations(self) -> list[dict[str, str]]:
  438. """
  439. Listet alle registrierten Migrationen.
  440. Returns:
  441. Liste von Migrations-Infos.
  442. """
  443. return [
  444. {
  445. "name": m.name,
  446. "from": m.from_version,
  447. "to": m.to_version,
  448. "description": m.description,
  449. }
  450. for m in self._migrations.values()
  451. ]
  452. def get_available_versions(self) -> set[str]:
  453. """
  454. Gibt alle bekannten Versionen zurück.
  455. Returns:
  456. Set von Versionsstrings.
  457. """
  458. versions: set[str] = set()
  459. for migration in self._migrations.values():
  460. versions.add(migration.from_version)
  461. versions.add(migration.to_version)
  462. return versions