"""Phase 2: Plan the migration - IP mapping and config generation.""" import ipaddress from models import NodeInfo, CorosyncConfig, CephConfig, MigrationPlan from config_parser import ( generate_corosync_conf, generate_ceph_conf, generate_network_interfaces_multi, generate_hosts, ) class Planner: """Plans the network migration with user input.""" def plan(self, nodes: list[NodeInfo], corosync: CorosyncConfig, ceph: CephConfig | None, has_quorum: bool) -> MigrationPlan | None: """Interactive planning with the user.""" plan = MigrationPlan( nodes=nodes, corosync_config=corosync, ceph_config=ceph, quorum_available=has_quorum, ) print("\n=== Phase 2: Migration planen ===\n") # Auto-detect bridges and networks self._detect_bridges(plan, ceph) # Get new management network print("[Management-Netzwerk (Corosync)]") if plan.old_network: print(f" Aktuell: {plan.old_network}") plan.new_network = self._ask_new_network( " Neues Management-Netzwerk (z.B. 172.0.2.0/16): " ) if not plan.new_network: return None new_net = ipaddress.ip_network(plan.new_network, strict=False) plan.new_gateway = self._ask_gateway(new_net) # Management IP mapping print("\n[Management IP-Mapping]") print(" Für jeden Node wird eine neue Management-IP benötigt.\n") for node in nodes: suggested_ip = self._suggest_new_ip(node.current_ip, plan.new_network) print(f" {node.name}: {node.current_ip} -> ", end="") user_input = input(f"[{suggested_ip}]: ").strip() node.new_ip = user_input or suggested_ip print(f" => {node.new_ip}") # Ceph network planning if ceph: self._plan_ceph(plan, nodes, ceph) # Show preview self._show_preview(plan) # Confirm confirm = input("\nMigration durchführen? [j/N]: ").strip().lower() if confirm not in ('j', 'ja', 'y', 'yes'): print("Abgebrochen.") return None return plan def _detect_bridges(self, plan: MigrationPlan, ceph: CephConfig | None): """Auto-detect which bridges carry which networks.""" nodes = plan.nodes if not nodes: return print("[Netzwerk-Erkennung]") # Find management bridge (carries corosync IP) for node in nodes: if not node.interfaces: continue for iface in node.interfaces: if not iface.address or not iface.cidr: continue try: iface_net = ipaddress.ip_network( f'{iface.address}/{iface.cidr}', strict=False ) mgmt_ip = ipaddress.ip_address(node.current_ip) if mgmt_ip in iface_net: plan.old_network = str(iface_net) plan.detected_bridges[iface.name] = str(iface_net) print(f" {iface.name}: Management/Corosync ({iface_net})") break except ValueError: continue if plan.old_network: break # Fallback for old_network if not plan.old_network and nodes: for cidr_guess in [24, 16, 8]: net = ipaddress.ip_network( f'{nodes[0].current_ip}/{cidr_guess}', strict=False ) if all( ipaddress.ip_address(n.current_ip) in net for n in nodes ): plan.old_network = str(net) print(f" Management-Netzwerk (geschätzt): {net}") break if not plan.old_network: print(" [!] Management-Netzwerk nicht erkannt") # Find ceph bridges (if on separate NICs) if ceph and nodes: for node in nodes: if not node.interfaces: continue for iface in node.interfaces: if not iface.address or not iface.cidr: continue if iface.name in plan.detected_bridges: continue try: iface_net = ipaddress.ip_network( f'{iface.address}/{iface.cidr}', strict=False ) label = None if ceph.public_network: ceph_pub = ipaddress.ip_network( ceph.public_network, strict=False ) if iface_net.overlaps(ceph_pub): label = "Ceph Public" if ceph.cluster_network: ceph_cls = ipaddress.ip_network( ceph.cluster_network, strict=False ) if iface_net.overlaps(ceph_cls): label = (label + " + Cluster") if label else "Ceph Cluster" if label: plan.detected_bridges[iface.name] = str(iface_net) print(f" {iface.name}: {label} ({iface_net})") except ValueError: continue if len(plan.detected_bridges) > 1: break if not plan.detected_bridges: print(" Keine Bridges erkannt (Interfaces nicht lesbar?)") print() def _plan_ceph(self, plan: MigrationPlan, nodes: list[NodeInfo], ceph: CephConfig): """Plan Ceph network changes, handling separate networks.""" ceph_pub_net = ceph.public_network ceph_cls_net = ceph.cluster_network # Check if ceph networks differ from management ceph_public_same = True ceph_cluster_same = True if ceph_pub_net and plan.old_network: try: ceph_pub = ipaddress.ip_network(ceph_pub_net, strict=False) mgmt = ipaddress.ip_network(plan.old_network, strict=False) ceph_public_same = ceph_pub.overlaps(mgmt) except ValueError: pass if ceph_cls_net and plan.old_network: try: ceph_cls = ipaddress.ip_network(ceph_cls_net, strict=False) mgmt = ipaddress.ip_network(plan.old_network, strict=False) ceph_cluster_same = ceph_cls.overlaps(mgmt) except ValueError: pass print("\n[Ceph Netzwerke]") print(f" Aktuelles Public Network: {ceph_pub_net}") print(f" Aktuelles Cluster Network: {ceph_cls_net}") if ceph_public_same and ceph_cluster_same: print(" -> Ceph nutzt das gleiche Netzwerk wie Management") print(" Wird automatisch mit umgezogen.\n") default_pub = plan.new_network user_input = input( f" Neues Ceph Public Network [{default_pub}]: " ).strip() plan.ceph_new_public_network = user_input or default_pub user_input = input( f" Neues Ceph Cluster Network [{plan.ceph_new_public_network}]: " ).strip() plan.ceph_new_cluster_network = user_input or plan.ceph_new_public_network else: # Separate Ceph networks if not ceph_public_same: print(f"\n [!] Ceph Public Network ({ceph_pub_net}) liegt auf" f" separater NIC!") pub_net = self._ask_new_network( f" Neues Ceph Public Network: " ) plan.ceph_new_public_network = pub_net or plan.new_network self._ask_ceph_ips( nodes, ceph, plan.ceph_new_public_network, "Public", ceph_pub_net ) else: plan.ceph_new_public_network = plan.new_network if not ceph_cluster_same: if ceph_cls_net != ceph_pub_net: print(f"\n [!] Ceph Cluster Network ({ceph_cls_net}) liegt auf" f" separater NIC!") cls_net = self._ask_new_network( f" Neues Ceph Cluster Network: " ) plan.ceph_new_cluster_network = cls_net or plan.ceph_new_public_network self._ask_ceph_ips( nodes, ceph, plan.ceph_new_cluster_network, "Cluster", ceph_cls_net ) else: plan.ceph_new_cluster_network = plan.ceph_new_public_network else: plan.ceph_new_cluster_network = plan.new_network def _ask_ceph_ips(self, nodes: list[NodeInfo], ceph: CephConfig, new_network: str, network_type: str, old_network: str): """Ask for per-node Ceph IPs when on a separate network.""" print(f"\n [Ceph {network_type} IP-Mapping]") print(f" Altes Netz: {old_network} -> Neues Netz: {new_network}\n") old_net = ipaddress.ip_network(old_network, strict=False) for node in nodes: # Find the node's current IP on this ceph network old_ceph_ip = None for iface in node.interfaces: if not iface.address: continue try: if ipaddress.ip_address(iface.address) in old_net: old_ceph_ip = iface.address break except ValueError: continue if not old_ceph_ip: # Try MON hosts for mon_ip in ceph.mon_hosts: try: if ipaddress.ip_address(mon_ip) in old_net: old_ceph_ip = mon_ip break except ValueError: continue if not old_ceph_ip: print(f" {node.name}: Keine {network_type}-IP gefunden," f" übersprungen") continue suggested = self._suggest_new_ip(old_ceph_ip, new_network) print(f" {node.name}: {old_ceph_ip} -> ", end="") user_input = input(f"[{suggested}]: ").strip() new_ceph_ip = user_input or suggested print(f" => {new_ceph_ip}") node.extra_ip_mapping[old_ceph_ip] = new_ceph_ip def _ask_new_network(self, prompt: str) -> str | None: """Ask for a new network.""" while True: network = input(prompt).strip() if not network: print(" Abgebrochen.") return None try: ipaddress.ip_network(network, strict=False) return network except ValueError as e: print(f" Ungültiges Netzwerk: {e}") def _ask_gateway(self, network: ipaddress.IPv4Network) -> str: """Ask for the gateway in the new network.""" suggested = str(list(network.hosts())[0]) user_input = input(f" Neues Gateway [{suggested}]: ").strip() return user_input or suggested def _suggest_new_ip(self, old_ip: str, new_network: str) -> str: """Suggest a new IP by keeping the host part from the old IP.""" old = ipaddress.ip_address(old_ip) new_net = ipaddress.ip_network(new_network, strict=False) old_host = int(old) & 0xFF if new_net.prefixlen <= 16: old_host = int(old) & 0xFFFF new_ip = ipaddress.ip_address(int(new_net.network_address) | old_host) return str(new_ip) def _build_full_ip_mapping(self, plan: MigrationPlan) -> dict[str, str]: """Build complete IP mapping including management + ceph IPs.""" ip_mapping = {} for node in plan.nodes: if node.new_ip: ip_mapping[node.current_ip] = node.new_ip for old_ip, new_ip in node.extra_ip_mapping.items(): ip_mapping[old_ip] = new_ip return ip_mapping def _show_preview(self, plan: MigrationPlan): """Show a preview of all planned changes.""" print("\n" + "=" * 60) print(" MIGRATION PREVIEW") print("=" * 60) ip_mapping = self._build_full_ip_mapping(plan) print(f"\n Management: {plan.old_network} -> {plan.new_network}") print(f" Gateway: {plan.new_gateway}") print(f" Quorum verfügbar: {'Ja' if plan.quorum_available else 'NEIN'}") if plan.detected_bridges: print(f"\n [Erkannte Bridges]") for bridge, subnet in plan.detected_bridges.items(): print(f" {bridge}: {subnet}") print("\n [Node IP-Mapping]") for node in plan.nodes: status = "erreichbar" if node.is_reachable else "NICHT ERREICHBAR" print(f" {node.name}: {node.current_ip} -> {node.new_ip}" f" ({status})") for old_ip, new_ip in node.extra_ip_mapping.items(): print(f" + {old_ip} -> {new_ip}") if plan.ceph_config: print("\n [Ceph Netzwerke]") print(f" Public: {plan.ceph_config.public_network}" f" -> {plan.ceph_new_public_network}") print(f" Cluster: {plan.ceph_config.cluster_network}" f" -> {plan.ceph_new_cluster_network}") if plan.ceph_config.mon_hosts: print(f" MON Hosts: {', '.join(plan.ceph_config.mon_hosts)}") new_mons = [ip_mapping.get(h, h) for h in plan.ceph_config.mon_hosts] print(f" -> {', '.join(new_mons)}") print("\n [Dateien die geändert werden]") print(" - /etc/network/interfaces (auf jedem Node)") print(" - /etc/hosts (auf jedem Node)") print(" - /etc/corosync/corosync.conf (auf jedem Node)") if plan.ceph_config: if plan.quorum_available: print(" - /etc/pve/ceph.conf (über Cluster-FS)") else: print(" - /etc/ceph/ceph.conf (direkt, da kein Quorum)") if not plan.quorum_available: print("\n [!] WARNUNG: Kein Quorum verfügbar!") print(" Es wird 'pvecm expected 1' verwendet um Quorum" " zu erzwingen.") print(" Ceph-Config wird direkt auf jedem Node geschrieben.") print("\n" + "=" * 60) def generate_new_configs(self, plan: MigrationPlan) -> dict: """Generate all new configuration file contents. Returns dict with: 'corosync': new corosync.conf content 'ceph': new ceph.conf content (or None) 'nodes': {node_name: {'interfaces': content, 'hosts': content}} """ ip_mapping = self._build_full_ip_mapping(plan) configs = { 'corosync': None, 'ceph': None, 'nodes': {}, } # Generate new corosync.conf if plan.corosync_config: configs['corosync'] = generate_corosync_conf( plan.corosync_config, ip_mapping ) # Generate new ceph.conf if plan.ceph_config: configs['ceph'] = generate_ceph_conf( plan.ceph_config, ip_mapping, plan.ceph_new_public_network, plan.ceph_new_cluster_network, ) # Generate per-node configs new_mgmt_cidr = ipaddress.ip_network( plan.new_network, strict=False ).prefixlen # Detect old gateway from any reachable node old_gateway = None if plan.old_network: mgmt_net = ipaddress.ip_network(plan.old_network, strict=False) for node in plan.nodes: for iface in node.interfaces: if iface.gateway: try: gw_ip = ipaddress.ip_address(iface.gateway) if gw_ip in mgmt_net: old_gateway = iface.gateway break except ValueError: continue if old_gateway: break for node in plan.nodes: if not node.new_ip or not node.network_interfaces_content: continue # Build list of IP replacements for this node # Each: (old_ip, new_ip, new_cidr, old_gateway, new_gateway) replacements = [] # Management IP replacements.append(( node.current_ip, node.new_ip, new_mgmt_cidr, old_gateway, plan.new_gateway, )) # Extra IPs (ceph on separate NICs) for old_ip, new_ip in node.extra_ip_mapping.items(): extra_cidr = new_mgmt_cidr # fallback # Try to get CIDR from new ceph network for net_str in [plan.ceph_new_public_network, plan.ceph_new_cluster_network]: if net_str: try: extra_cidr = ipaddress.ip_network( net_str, strict=False ).prefixlen break except ValueError: pass replacements.append((old_ip, new_ip, extra_cidr, None, None)) node_configs = {} # Network interfaces - apply ALL replacements node_configs['interfaces'] = generate_network_interfaces_multi( node.network_interfaces_content, replacements ) # /etc/hosts node_configs['hosts'] = generate_hosts( node.hosts_content, ip_mapping ) configs['nodes'][node.name] = node_configs return configs