Files
napalm-proxmox/napalm_proxmox/utils.py
Christian ManivongandClaude Sonnet 4.6 b1ba991e6d feat: set_hostname — update /etc/hostname, /etc/hosts, cert, Postfix
Implements set_hostname on ProxmoxSystemMixin:
1. Writes /etc/hostname (short name, base64-safe transfer)
2. Replaces old hostname in /etc/hosts via Python regex + base64
3. Updates /etc/mailname if present
4. Updates Postfix myhostname via postconf -e if installed
5. Applies hostname immediately at runtime via hostname(1)
6. Regenerates Proxmox node TLS certificate via pvecm updatecerts -f
   (falls back to pvenode cert create → pveproxy restart)

Accepts bare hostname or FQDN. A reboot is required for the Proxmox
node name to update in the web UI / cluster — the driver logs this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 16:30:45 +02:00

248 lines
7.9 KiB
Python

"""Utility helpers for the Proxmox NAPALM driver.
Covers:
- Classic Linux networking (/etc/network/interfaces)
- Proxmox SDN (zones, vnets, subnets)
- Open vSwitch (OVS bridges, bonds, internal ports)
"""
from __future__ import annotations
import re
from ipaddress import IPv4Interface, IPv6Interface, ip_interface
from typing import Any
from netaddr import EUI, AddrFormatError
# ---------------------------------------------------------------------------
# MAC address helpers
# ---------------------------------------------------------------------------
_MAC_RE = re.compile(
r"(?:[0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}"
r"|[0-9a-fA-F]{12}"
)
def normalize_mac(mac: str) -> str:
"""Return lower-case colon-separated MAC or empty string."""
if not mac:
return ""
try:
eui = EUI(mac)
eui.dialect = type(
"_ColonDialect",
(),
{"word_sep": ":", "word_fmt": "%.2x", "word_size": 8, "num_words": 6},
)
return str(eui).lower()
except (AddrFormatError, ValueError):
# Best-effort normalisation
cleaned = mac.replace("-", ":").strip().lower()
return cleaned
# ---------------------------------------------------------------------------
# IP / prefix helpers
# ---------------------------------------------------------------------------
def prefix_length(cidr: str) -> int:
"""Return prefix length from a CIDR string such as '192.168.1.0/24' → 24."""
try:
return int(cidr.split("/")[1])
except (IndexError, ValueError):
# No slash present — treat as host route
return 128 if ":" in cidr else 32
def parse_cidr(cidr: str) -> tuple[str, int]:
"""Return (ip_str, prefix_len) from a CIDR address."""
try:
iface = ip_interface(cidr)
return str(iface.ip), iface.network.prefixlen
except ValueError:
return cidr, -1
def addresses_from_node_network(iface_data: dict[str, Any]) -> dict[str, dict]:
"""Build an interfaces_ip sub-dict from a Proxmox node/network entry.
Proxmox returns separate fields: ``address``, ``netmask``, ``address6``,
``netmask6``. We convert those to the NAPALM ipv4/ipv6 keyed format.
"""
result: dict[str, dict] = {}
addr4 = iface_data.get("address", "")
mask4 = iface_data.get("netmask", "")
if addr4 and mask4:
try:
prefix = IPv4Interface(f"{addr4}/{mask4}").network.prefixlen
result.setdefault("ipv4", {})[addr4] = {"prefix_length": prefix}
except ValueError:
pass
addr6 = iface_data.get("address6", "")
mask6 = iface_data.get("netmask6", "")
if addr6 and mask6:
try:
prefix = IPv6Interface(f"{addr6}/{mask6}").network.prefixlen
result.setdefault("ipv6", {})[addr6] = {"prefix_length": prefix}
except ValueError:
pass
# Also handle CIDR-style fields (e.g. from SDN)
cidr4 = iface_data.get("cidr", "")
if cidr4 and "/" in cidr4 and "ipv4" not in result:
ip, plen = parse_cidr(cidr4)
if plen >= 0:
result.setdefault("ipv4", {})[ip] = {"prefix_length": plen}
cidr6 = iface_data.get("cidr6", "")
if cidr6 and "/" in cidr6 and "ipv6" not in result:
ip, plen = parse_cidr(cidr6)
if plen >= 0:
result.setdefault("ipv6", {})[ip] = {"prefix_length": plen}
return result
# ---------------------------------------------------------------------------
# Interface type helpers (classic Linux vs OVS vs SDN bridge)
# ---------------------------------------------------------------------------
OVS_TYPES = {"OVSBridge", "OVSBond", "OVSIntPort", "OVSPort"}
SDN_BRIDGE_RE = re.compile(r"^vnet\d+$|^vmbr\d+$|^sdnbr\d+$")
def is_ovs_interface(iface_data: dict[str, Any]) -> bool:
return iface_data.get("type", "") in OVS_TYPES
def is_sdn_vnet(name: str) -> bool:
return bool(re.fullmatch(r"vnet\d+", name))
def speed_mbps(iface_data: dict[str, Any]) -> float:
"""Extract link speed (Mbit/s) from Proxmox interface data."""
raw = iface_data.get("speed", -1)
if raw is None or raw == "":
return -1.0
try:
val = float(raw)
# Proxmox reports speed in Mbit/s already for physical interfaces
return val if val > 0 else -1.0
except (TypeError, ValueError):
return -1.0
# ---------------------------------------------------------------------------
# ARP / neighbour table helpers
# ---------------------------------------------------------------------------
def parse_arp_output(raw: str) -> list[dict[str, Any]]:
"""Parse Linux ``arp -n`` text output into NAPALM ARP dicts.
Expected format::
Address HWtype HWaddress Flags Iface
192.168.1.1 ether aa:bb:cc:dd:ee:ff C eth0
"""
entries = []
for line in raw.splitlines():
line = line.strip()
if not line or line.startswith("Address") or line.startswith("?"):
continue
parts = line.split()
if len(parts) < 5:
continue
ip_addr, _, mac, _, iface = parts[0], parts[1], parts[2], parts[3], parts[4]
if mac in ("(incomplete)", "<incomplete>"):
continue
entries.append(
{
"interface": iface,
"mac": normalize_mac(mac),
"ip": ip_addr,
"age": -1.0,
}
)
return entries
# ---------------------------------------------------------------------------
# SDN helpers
# ---------------------------------------------------------------------------
ZONE_TYPE_MAP = {
"simple": "L3VRF",
"vlan": "L2VPN",
"qinq": "L2VPN",
"vxlan": "L2VPN",
"evpn": "L3VRF",
}
def sdn_zone_to_network_instance(zone: dict[str, Any]) -> dict[str, Any]:
"""Convert a Proxmox SDN zone entry to a NAPALM network instance dict."""
zone_id = zone.get("zone", zone.get("name", "unknown"))
zone_type = zone.get("type", "simple")
return {
"name": zone_id,
"type": ZONE_TYPE_MAP.get(zone_type, "DEFAULT_INSTANCE"),
"state": {
"route_distinguisher": zone.get("vrf-vxlan", None),
},
"interfaces": {"interface": {}},
}
# ---------------------------------------------------------------------------
# OVS helpers
# ---------------------------------------------------------------------------
def parse_ovs_vsctl_show(raw: str) -> dict[str, Any]:
"""Very lightweight parser for ``ovs-vsctl show`` output.
Returns a mapping of bridge_name → {ports: [port_name, …]}.
"""
bridges: dict[str, Any] = {}
current_bridge: str | None = None
for line in raw.splitlines():
stripped = line.strip()
m_bridge = re.match(r'^Bridge\s+"?([^\s"]+)"?', stripped)
if m_bridge:
current_bridge = m_bridge.group(1)
bridges[current_bridge] = {"ports": []}
continue
m_port = re.match(r'^Port\s+"?([^\s"]+)"?', stripped)
if m_port and current_bridge:
port_name = m_port.group(1)
if port_name != current_bridge: # skip the bridge self-port
bridges[current_bridge]["ports"].append(port_name)
return bridges
def parse_ovs_ofctl_dump_flows(raw: str) -> list[dict[str, Any]]:
"""Parse ``ovs-ofctl dump-flows <bridge>`` into a list of flow dicts.
Used to populate get_mac_address_table for OVS bridges.
"""
flows = []
for line in raw.splitlines():
line = line.strip()
if not line or line.startswith("NXST_FLOW") or line.startswith("OFPST_FLOW"):
continue
# Extract dl_src / dl_dst / in_port / output
mac_m = re.search(r"dl_src=([\da-f:]+)", line)
port_m = re.search(r"in_port=(\d+)", line)
if mac_m:
flows.append(
{
"mac": normalize_mac(mac_m.group(1)),
"in_port": port_m.group(1) if port_m else "",
}
)
return flows