#!/usr/bin/env python3 """ Proxmox Cluster Network Changer Migriert ein Proxmox-Cluster (inkl. Ceph) von einem Netzwerk in ein anderes. Behandelt Corosync, Ceph, /etc/network/interfaces und /etc/hosts. Kann auch mit gebrochenem Quorum umgehen (z.B. wenn ein Node bereits manuell geändert wurde). Muss als root auf einem Proxmox-Node ausgeführt werden. Verwendung: python3 main.py # Interaktiver Modus python3 main.py --dry-run # Nur anzeigen, nichts ändern python3 main.py --discover # Nur Discovery, keine Migration """ import argparse import os import shutil import subprocess import sys from ssh_manager import SSHManager from discovery import Discovery from planner import Planner from backup import Backup from migrator import Migrator from verifier import Verifier from rescue import RescueNetwork def load_env(env_file: str = ".env") -> dict[str, str]: """Load variables from a .env file (simple key=value parser).""" env = {} if not os.path.exists(env_file): return env with open(env_file, 'r') as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue if '=' not in line: continue key, _, value = line.partition('=') key = key.strip() # Strip surrounding quotes value = value.strip() if len(value) >= 2 and value[0] in ('"', "'") and value[-1] == value[0]: value = value[1:-1] env[key] = value return env def check_prerequisites(): """Check that we're running as root on a Proxmox node.""" if os.geteuid() != 0: print("FEHLER: Dieses Tool muss als root ausgeführt werden!") print("Bitte mit 'sudo python3 main.py' starten.") sys.exit(1) if not os.path.exists("/etc/pve") and not os.path.exists("/etc/corosync"): print("WARNUNG: Dies scheint kein Proxmox-Node zu sein.") print(" /etc/pve und /etc/corosync nicht gefunden.") answer = input("Trotzdem fortfahren? [j/N]: ").strip().lower() if answer not in ('j', 'ja', 'y', 'yes'): sys.exit(0) def main(): parser = argparse.ArgumentParser( description="Proxmox Cluster Network Changer - " "Migriert Cluster + Ceph in ein neues Netzwerk" ) parser.add_argument( "--dry-run", action="store_true", help="Nur anzeigen was geändert würde, nichts ändern" ) parser.add_argument( "--discover", action="store_true", help="Nur Discovery durchführen, keine Migration" ) parser.add_argument( "--ssh-key", type=str, default=None, help="Pfad zum SSH-Key (Standard: Default SSH-Key)" ) parser.add_argument( "--ssh-port", type=int, default=22, help="SSH-Port (Standard: 22)" ) parser.add_argument( "--rescue", action="store_true", help="Rescue-Modus: Emergency-Netzwerk einrichten wenn Nodes " "sich nicht erreichen können" ) parser.add_argument( "--rescue-commands", type=str, metavar="SUBNET", help="Nur Rescue-Befehle ausgeben ohne Migration " "(z.B. --rescue-commands 10.99.99.0/24)" ) parser.add_argument( "--env-file", type=str, default=".env", help="Pfad zur .env-Datei mit SSH_PASSWORD (Standard: .env)" ) args = parser.parse_args() print("=" * 60) print(" Proxmox Cluster Network Changer") print("=" * 60) check_prerequisites() # Load .env file env = load_env(args.env_file) ssh_password = env.get('SSH_PASSWORD') or os.environ.get('SSH_PASSWORD') ssh_user = env.get('SSH_USER') or os.environ.get('SSH_USER') or 'root' # Ensure sshpass is available when password auth is requested if ssh_password and not shutil.which("sshpass"): print("\n [SSH] sshpass nicht gefunden — wird für Passwort-Auth benötigt.") answer = input(" sshpass jetzt installieren? (apt install sshpass) [J/n]: ").strip().lower() if answer not in ('n', 'nein', 'no'): rc = subprocess.run( ["apt", "install", "-y", "sshpass"], capture_output=True, text=True, ).returncode if rc == 0: print(" sshpass installiert.") else: print(" FEHLER: Installation fehlgeschlagen!") print(" Bitte manuell installieren: apt install sshpass") sys.exit(1) else: print(" Ohne sshpass ist Passwort-Auth nicht möglich.") ssh_password = None # Initialize SSH manager ssh = SSHManager( ssh_user=ssh_user, ssh_key=args.ssh_key, ssh_port=args.ssh_port, ssh_password=ssh_password, ) if ssh.uses_password: print(f"\n [SSH] Passwort-Authentifizierung aktiv (via .env)") else: print(f"\n [SSH] Key-basierte Authentifizierung") rescue = RescueNetwork(ssh) # Quick mode: just print rescue commands and exit if args.rescue_commands: discovery = Discovery(ssh) print("\n[Corosync]") corosync = discovery.discover_corosync() if not corosync: print("\nFEHLER: Konnte Cluster-Konfiguration nicht lesen.") sys.exit(1) bridge_input = input(f"Bridge [{rescue.bridge}]: ").strip() bridge = bridge_input or rescue.bridge commands = rescue.get_rescue_commands(corosync, args.rescue_commands, bridge) print() print("=" * 60) print(" RESCUE BEFEHLE") print(f" Subnetz: {args.rescue_commands} | Bridge: {bridge}") print("=" * 60) print() for cmd_info in commands: print(f" {cmd_info['name']} ({cmd_info['current_ip']}):") print(f" {cmd_info['command']}") print() print(" Zum Entfernen:") for cmd_info in commands: print(f" {cmd_info['remove_command']} # {cmd_info['name']}") print() sys.exit(0) # Phase 1: Discovery discovery = Discovery(ssh) corosync, ceph, nodes, has_quorum = discovery.run() if not corosync: print("\nFEHLER: Konnte Cluster-Konfiguration nicht lesen. Abbruch.") sys.exit(1) # Check if rescue mode is needed unreachable = [n for n in nodes if not n.is_reachable and not n.is_local] use_rescue = args.rescue if unreachable and not use_rescue: print(f"\n {len(unreachable)} Node(s) nicht erreichbar.") answer = input(" Rescue-Netzwerk einrichten? [J/n]: ").strip().lower() if answer not in ('n', 'nein', 'no'): use_rescue = True if use_rescue: rescue_nodes = rescue.setup_interactive(corosync) if not rescue_nodes: sys.exit(1) # Re-run discovery with rescue IPs to read configs from all nodes print("\n [Rescue] Lese Konfigurationen über Rescue-Netzwerk...") nodes = discovery.discover_nodes_with_overrides( corosync, rescue_nodes ) # Re-check quorum has_quorum = discovery.check_quorum() # Re-read ceph ceph = discovery.discover_ceph() if args.discover: if rescue.active: rescue.cleanup(nodes) print("\n--- Discovery abgeschlossen (--discover Modus) ---") sys.exit(0) # Phase 2: Planning planner = Planner() plan = planner.plan(nodes, corosync, ceph, has_quorum) if not plan: if rescue.active: rescue.cleanup(nodes) sys.exit(0) plan.dry_run = args.dry_run # Generate all new config files configs = planner.generate_new_configs(plan) # Phase 3: Backup (skip in dry-run) if not args.dry_run: backup = Backup(ssh) if not backup.run(plan): print("\nBackup fehlgeschlagen! Trotzdem fortfahren?") answer = input("[j/N]: ").strip().lower() if answer not in ('j', 'ja', 'y', 'yes'): if rescue.active: rescue.cleanup(nodes) sys.exit(1) else: print("\n=== Phase 3: Backup (übersprungen im Dry-Run) ===") # Phase 4: Migration migrator = Migrator(ssh) success = migrator.run(plan, configs, dry_run=args.dry_run) if not success: print("\n[!] Migration hatte Fehler!") if not args.dry_run: print(" Prüfe Backups in /root/network-migration-backup-*/") if rescue.active: rescue.cleanup(nodes) sys.exit(1) # Cleanup rescue network (before verification, so we verify real connectivity) if rescue.active and not args.dry_run: rescue.cleanup(nodes) # Phase 5: Verification (skip in dry-run) if not args.dry_run: verifier = Verifier(ssh) verifier.run(plan) else: if rescue.active: rescue.cleanup(nodes) print("\n=== Phase 5: Verifikation (übersprungen im Dry-Run) ===") print("\nDry-Run abgeschlossen. Keine Änderungen vorgenommen.") if __name__ == "__main__": main()