# This file is part of cloud-init. See LICENSE file ...
import copy
import os
import textwrap
from typing import Optional, cast
from cloudinit import log as logging
from cloudinit import safeyaml, subp, util
from cloudinit.net import (
IPV6_DYNAMIC_TYPES,
SYS_CLASS_NET,
get_devicelist,
renderer,
subnet_is_ipv6,
)
from cloudinit.net.network_state import NET_CONFIG_TO_V2, NetworkState
KNOWN_SNAPD_CONFIG = b"""\
# This is the initial network config.
# It can be overwritten by cloud-init or console-conf.
network:
version: 2
ethernets:
all-en:
match:
name: "en*"
dhcp4: true
all-eth:
match:
name: "eth*"
dhcp4: true
"""
LOG = logging.getLogger(__name__)
def _get_params_dict_by_match(config, match):
return dict(
(key, value)
for (key, value) in config.items()
if key.startswith(match)
)
def _extract_addresses(config, entry, ifname, features=None):
"""This method parse a cloudinit.net.network_state dictionary (config) and
maps netstate keys/values into a dictionary (entry) to represent
netplan yaml.
An example config dictionary might look like:
{'mac_address': '52:54:00:12:34:00',
'name': 'interface0',
'subnets': [
{'address': '192.168.1.2/24',
'mtu': 1501,
'type': 'static'},
{'address': '2001:4800:78ff:1b:be76:4eff:fe06:1000",
'mtu': 1480,
'netmask': 64,
'type': 'static'}],
'type: physical',
'accept-ra': 'true'
}
An entry dictionary looks like:
{'set-name': 'interface0',
'match': {'macaddress': '52:54:00:12:34:00'},
'mtu': 1501}
After modification returns
{'set-name': 'interface0',
'match': {'macaddress': '52:54:00:12:34:00'},
'mtu': 1501,
'address': ['192.168.1.2/24', '2001:4800:78ff:1b:be76:4eff:fe06:1000"],
'ipv6-mtu': 1480}
"""
def _listify(obj, token=" "):
"Helper to convert strings to list of strings, handle single string"
if not obj or type(obj) not in [str]:
return obj
if token in obj:
return obj.split(token)
else:
return [
obj,
]
if features is None:
features = []
addresses = []
routes = []
nameservers = []
searchdomains = []
subnets = config.get("subnets", [])
if subnets is None:
subnets = []
for subnet in subnets:
sn_type = subnet.get("type")
if sn_type.startswith("dhcp"):
if sn_type == "dhcp":
sn_type += "4"
entry.update({sn_type: True})
elif sn_type in IPV6_DYNAMIC_TYPES:
entry.update({"dhcp6": True})
elif sn_type in ["static", "static6"]:
addr = "%s" % subnet.get("address")
if "prefix" in subnet:
addr += "/%d" % subnet.get("prefix")
if "gateway" in subnet and subnet.get("gateway"):
gateway = subnet.get("gateway")
if ":" in gateway:
entry.update({"gateway6": gateway})
else:
entry.update({"gateway4": gateway})
if "dns_nameservers" in subnet:
nameservers += _listify(subnet.get("dns_nameservers", []))
if "dns_search" in subnet:
searchdomains += _listify(subnet.get("dns_search", []))
if "mtu" in subnet:
mtukey = "mtu"
if subnet_is_ipv6(subnet) and "ipv6-mtu" in features:
mtukey = "ipv6-mtu"
entry.update({mtukey: subnet.get("mtu")})
for route in subnet.get("routes", []):
to_net = "%s/%s" % (route.get("network"), route.get("prefix"))
new_route = {
"via": route.get("gateway"),
"to": to_net,
}
if "metric" in route:
new_route.update({"metric": route.get("metric", 100)})
routes.append(new_route)
addresses.append(addr)
if "mtu" in config:
entry_mtu = entry.get("mtu")
if entry_mtu and config["mtu"] != entry_mtu:
LOG.warning(
"Network config: ignoring %s device-level mtu:%s because"
" ipv4 subnet-level mtu:%s provided.",
ifname,
config["mtu"],
entry_mtu,
)
else:
entry["mtu"] = config["mtu"]
if len(addresses) > 0:
entry.update({"addresses": addresses})
if len(routes) > 0:
entry.update({"routes": routes})
if len(nameservers) > 0:
ns = {"addresses": nameservers}
entry.update({"nameservers": ns})
if len(searchdomains) > 0:
ns = entry.get("nameservers", {})
ns.update({"search": searchdomains})
entry.update({"nameservers": ns})
if "accept-ra" in config and config["accept-ra"] is not None:
entry.update({"accept-ra": util.is_true(config.get("accept-ra"))})
def _extract_bond_slaves_by_name(interfaces, entry, bond_master):
bond_slave_names = sorted(
[
name
for (name, cfg) in interfaces.items()
if cfg.get("bond-master", None) == bond_master
]
)
if len(bond_slave_names) > 0:
entry.update({"interfaces": bond_slave_names})
def _clean_default(target=None):
# clean out any known default files and derived files in target
# LP: #1675576
tpath = subp.target_path(target, "etc/netplan/00-snapd-config.yaml")
if not os.path.isfile(tpath):
return
content = util.load_file(tpath, decode=False)
if content != KNOWN_SNAPD_CONFIG:
return
derived = [
subp.target_path(target, f)
for f in (
"run/systemd/network/10-netplan-all-en.network",
"run/systemd/network/10-netplan-all-eth.network",
"run/systemd/generator/netplan.stamp",
)
]
existing = [f for f in derived if os.path.isfile(f)]
LOG.debug(
"removing known config '%s' and derived existing files: %s",
tpath,
existing,
)
for f in [tpath] + existing:
os.unlink(f)
class Renderer(renderer.Renderer):
"""Renders network information in a /etc/netplan/network.yaml format."""
NETPLAN_GENERATE = ["netplan", "generate"]
NETPLAN_INFO = ["netplan", "info"]
def __init__(self, config=None):
if not config:
config = {}
self.netplan_path = config.get(
"netplan_path", "etc/netplan/50-cloud-init.yaml"
)
self.netplan_header = config.get("netplan_header", None)
self._postcmds = config.get("postcmds", False)
self.clean_default = config.get("clean_default", True)
self._features = config.get("features", None)
@property
def features(self):
if self._features is None:
try:
info_blob, _err = subp.subp(self.NETPLAN_INFO, capture=True)
info = util.load_yaml(info_blob)
self._features = info["netplan.io"]["features"]
except subp.ProcessExecutionError:
# if the info subcommand is not present then we don't have any
# new features
pass
except (TypeError, KeyError) as e:
LOG.debug("Failed to list features from netplan info: %s", e)
return self._features
def render_network_state(
self,
network_state: NetworkState,
templates: Optional[dict] = None,
target=None,
) -> None:
# check network state for version
# if v2, then extract network_state.config
# else render_v2_from_state
fpnplan = os.path.join(subp.target_path(target), self.netplan_path)
util.ensure_dir(os.path.dirname(fpnplan))
header = self.netplan_header if self.netplan_header else ""
# render from state
content = self._render_content(network_state)
if not header.endswith("\n"):
header += "\n"
util.write_file(fpnplan, header + content)
if self.clean_default:
_clean_default(target=target)
self._netplan_generate(run=self._postcmds)
self._net_setup_link(run=self._postcmds)
def _netplan_generate(self, run=False):
if not run:
LOG.debug("netplan generate postcmd disabled")
return
subp.subp(self.NETPLAN_GENERATE, capture=True)
def _net_setup_link(self, run=False):
"""To ensure device link properties are applied, we poke
udev to re-evaluate networkd .link files and call
the setup_link udev builtin command
"""
if not run:
LOG.debug("netplan net_setup_link postcmd disabled")
return
setup_lnk = ["udevadm", "test-builtin", "net_setup_link"]
# It's possible we can race a udev rename and attempt to run
# net_setup_link on a device that no longer exists. When this happens,
# we don't know what the device was renamed to, so re-gather the
# entire list of devices and try again.
last_exception = Exception
for _ in range(5):
try:
for iface in get_devicelist():
if os.path.islink(SYS_CLASS_NET + iface):
subp.subp(
setup_lnk + [SYS_CLASS_NET + iface], capture=True
)
break
except subp.ProcessExecutionError as e:
last_exception = e
else:
raise RuntimeError(
"'udevadm test-builtin net_setup_link' unable to run "
"successfully for all devices."
) from last_exception
def _render_content(self, network_state: NetworkState):
# if content already in netplan format, pass it back
if network_state.version == 2:
LOG.debug("V2 to V2 passthrough")
return safeyaml.dumps(
{"network": network_state.config},
explicit_start=False,
explicit_end=False,
)
ethernets = {}
wifis: dict = {}
bridges = {}
bonds = {}
vlans = {}
content = []
interfaces = network_state._network_state.get("interfaces", [])
nameservers = network_state.dns_nameservers
searchdomains = network_state.dns_searchdomains
for config in network_state.iter_interfaces():
ifname = config.get("name")
# filter None (but not False) entries up front
ifcfg = dict(
(key, value)
for (key, value) in config.items()
if value is not None
)
if_type = ifcfg.get("type")
if if_type == "physical":
# required_keys = ['name', 'mac_address']
eth = {
"set-name": ifname,
"match": ifcfg.get("match", None),
}
if eth["match"] is None:
macaddr = ifcfg.get("mac_address", None)
if macaddr is not None:
eth["match"] = {"macaddress": macaddr.lower()}
else:
del eth["match"]
del eth["set-name"]
_extract_addresses(ifcfg, eth, ifname, self.features)
ethernets.update({ifname: eth})
elif if_type == "bond":
# required_keys = ['name', 'bond_interfaces']
bond = {}
bond_config = {}
# extract bond params and drop the bond_ prefix as it's
# redundant in v2 yaml format
v2_bond_map = cast(dict, NET_CONFIG_TO_V2.get("bond"))
# Previous cast is needed to help mypy to know that the key is
# present in `NET_CONFIG_TO_V2`. This could probably be removed
# by using `Literal` when supported.
for match in ["bond_", "bond-"]:
bond_params = _get_params_dict_by_match(ifcfg, match)
for (param, value) in bond_params.items():
newname = v2_bond_map.get(param.replace("_", "-"))
if newname is None:
continue
bond_config.update({newname: value})
if len(bond_config) > 0:
bond.update({"parameters": bond_config})
if ifcfg.get("mac_address"):
bond["macaddress"] = ifcfg["mac_address"].lower()
slave_interfaces = ifcfg.get("bond-slaves")
if slave_interfaces == "none":
_extract_bond_slaves_by_name(interfaces, bond, ifname)
_extract_addresses(ifcfg, bond, ifname, self.features)
bonds.update({ifname: bond})
elif if_type == "bridge":
# required_keys = ['name', 'bridge_ports']
bridge_ports = ifcfg.get("bridge_ports")
# mypy wrong error. `copy(None)` is supported:
ports = sorted(copy.copy(bridge_ports)) # type: ignore
bridge: dict = {
"interfaces": ports,
}
# extract bridge params and drop the bridge prefix as it's
# redundant in v2 yaml format
match_prefix = "bridge_"
params = _get_params_dict_by_match(ifcfg, match_prefix)
br_config = {}
# v2 yaml uses different names for the keys
# and at least one value format change
v2_bridge_map = cast(dict, NET_CONFIG_TO_V2.get("bridge"))
# Previous cast is needed to help mypy to know that the key is
# present in `NET_CONFIG_TO_V2`. This could probably be removed
# by using `Literal` when supported.
for (param, value) in params.items():
newname = v2_bridge_map.get(param)
if newname is None:
continue
br_config.update({newname: value})
if newname in ["path-cost", "port-priority"]:
# <interface> <value> -> <interface>: int(<value>)
newvalue = {}
for val in value:
(port, portval) = val.split()
newvalue[port] = int(portval)
br_config.update({newname: newvalue})
if len(br_config) > 0:
bridge.update({"parameters": br_config})
if ifcfg.get("mac_address"):
bridge["macaddress"] = ifcfg["mac_address"].lower()
_extract_addresses(ifcfg, bridge, ifname, self.features)
bridges.update({ifname: bridge})
elif if_type == "vlan":
# required_keys = ['name', 'vlan_id', 'vlan-raw-device']
vlan = {
"id": ifcfg.get("vlan_id"),
"link": ifcfg.get("vlan-raw-device"),
}
macaddr = ifcfg.get("mac_address", None)
if macaddr is not None:
vlan["macaddress"] = macaddr.lower()
_extract_addresses(ifcfg, vlan, ifname, self.features)
vlans.update({ifname: vlan})
# inject global nameserver values under each all interface which
# has addresses and do not already have a DNS configuration
if nameservers or searchdomains:
nscfg = {"addresses": nameservers, "search": searchdomains}
for section in [ethernets, wifis, bonds, bridges, vlans]:
for _name, cfg in section.items():
if "nameservers" in cfg or "addresses" not in cfg:
continue
cfg.update({"nameservers": nscfg})
# workaround yaml dictionary key sorting when dumping
def _render_section(name, section):
if section:
dump = safeyaml.dumps(
{name: section},
explicit_start=False,
explicit_end=False,
noalias=True,
)
txt = textwrap.indent(dump, " " * 4)
return [txt]
return []
content.append("network:\n version: 2\n")
content += _render_section("ethernets", ethernets)
content += _render_section("wifis", wifis)
content += _render_section("bonds", bonds)
content += _render_section("bridges", bridges)
content += _render_section("vlans", vlans)
return "".join(content)
def available(target=None):
expected = ["netplan"]
search = ["/usr/sbin", "/sbin"]
for p in expected:
if not subp.which(p, search=search, target=target):
return False
return True
def network_state_to_netplan(network_state, header=None):
# render the provided network state, return a string of equivalent eni
netplan_path = "etc/network/50-cloud-init.yaml"
renderer = Renderer(
{
"netplan_path": netplan_path,
"netplan_header": header,
}
)
if not header:
header = ""
if not header.endswith("\n"):
header += "\n"
contents = renderer._render_content(network_state)
return header + contents
# vi: ts=4 expandtab
|