proxmox-cluster-network-cha.../planner.py

469 lines
18 KiB
Python

"""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