# Copyright (C) 2012 Canonical Ltd. # Copyright (C) 2012 Yahoo! Inc. # # Author: Scott Moser # Author: Joshua Harlow # # This file is part of cloud-init. See LICENSE file for license information. import abc import base64 import copy import functools import os from cloudinit import ec2_utils from cloudinit import log as logging from cloudinit import net, sources, subp, url_helper, util from cloudinit.sources import BrokenMetadata # See https://docs.openstack.org/user-guide/cli-config-drive.html LOG = logging.getLogger(__name__) FILES_V1 = { # Path <-> (metadata key name, translator function, default value) "etc/network/interfaces": ("network_config", lambda x: x, ""), "meta.js": ("meta_js", util.load_json, {}), "root/.ssh/authorized_keys": ("authorized_keys", lambda x: x, ""), } KEY_COPIES = ( # Cloud-init metadata names <-> (metadata key, is required) ("local-hostname", "hostname", False), ("instance-id", "uuid", True), ) # Versions and names taken from nova source nova/api/metadata/base.py OS_LATEST = "latest" OS_FOLSOM = "2012-08-10" OS_GRIZZLY = "2013-04-04" OS_HAVANA = "2013-10-17" OS_LIBERTY = "2015-10-15" # NEWTON_ONE adds 'devices' to md (sriov-pf-passthrough-neutron-port-vlan) OS_NEWTON_ONE = "2016-06-30" # NEWTON_TWO adds vendor_data2.json (vendordata-reboot) OS_NEWTON_TWO = "2016-10-06" # OS_OCATA adds 'vif' field to devices (sriov-pf-passthrough-neutron-port-vlan) OS_OCATA = "2017-02-22" # OS_ROCKY adds a vf_trusted field to devices (sriov-trusted-vfs) OS_ROCKY = "2018-08-27" # keep this in chronological order. new supported versions go at the end. OS_VERSIONS = ( OS_FOLSOM, OS_GRIZZLY, OS_HAVANA, OS_LIBERTY, OS_NEWTON_ONE, OS_NEWTON_TWO, OS_OCATA, OS_ROCKY, ) KNOWN_PHYSICAL_TYPES = ( None, "bgpovs", # not present in OpenStack upstream but used on OVH cloud. "bridge", "cascading", # not present in OpenStack upstream, used on OpenTelekomCloud "dvs", "ethernet", "hw_veb", "hyperv", "ovs", "phy", "tap", "vhostuser", "vif", ) class NonReadable(IOError): pass class SourceMixin(object): def _ec2_name_to_device(self, name): if not self.ec2_metadata: return None bdm = self.ec2_metadata.get("block-device-mapping", {}) for (ent_name, device) in bdm.items(): if name == ent_name: return device return None def get_public_ssh_keys(self): name = "public_keys" if self.version == 1: name = "public-keys" return sources.normalize_pubkey_data(self.metadata.get(name)) def _os_name_to_device(self, name): device = None try: criteria = "LABEL=%s" % (name) if name == "swap": criteria = "TYPE=%s" % (name) dev_entries = util.find_devs_with(criteria) if dev_entries: device = dev_entries[0] except subp.ProcessExecutionError: pass return device def _validate_device_name(self, device): if not device: return None if not device.startswith("/"): device = "/dev/%s" % device if os.path.exists(device): return device # Durn, try adjusting the mapping remapped = self._remap_device(os.path.basename(device)) if remapped: LOG.debug("Remapped device name %s => %s", device, remapped) return remapped return None def device_name_to_device(self, name): # Translate a 'name' to a 'physical' device if not name: return None # Try the ec2 mapping first names = [name] if name == "root": names.insert(0, "ami") if name == "ami": names.append("root") device = None LOG.debug("Using ec2 style lookup to find device %s", names) for n in names: device = self._ec2_name_to_device(n) device = self._validate_device_name(device) if device: break # Try the openstack way second if not device: LOG.debug("Using openstack style lookup to find device %s", names) for n in names: device = self._os_name_to_device(n) device = self._validate_device_name(device) if device: break # Ok give up... if not device: return None else: LOG.debug("Mapped %s to device %s", name, device) return device class BaseReader(metaclass=abc.ABCMeta): def __init__(self, base_path): self.base_path = base_path @abc.abstractmethod def _path_join(self, base, *add_ons): pass @abc.abstractmethod def _path_read(self, path, decode=False): pass @abc.abstractmethod def _fetch_available_versions(self): pass @abc.abstractmethod def _read_ec2_metadata(self): pass def _find_working_version(self): try: versions_available = self._fetch_available_versions() except Exception as e: LOG.debug( "Unable to read openstack versions from %s due to: %s", self.base_path, e, ) versions_available = [] # openstack.OS_VERSIONS is stored in chronological order, so # reverse it to check newest first. supported = [v for v in reversed(list(OS_VERSIONS))] selected_version = OS_LATEST for potential_version in supported: if potential_version not in versions_available: continue selected_version = potential_version break LOG.debug( "Selected version '%s' from %s", selected_version, versions_available, ) return selected_version def _read_content_path(self, item, decode=False): path = item.get("content_path", "").lstrip("/") path_pieces = path.split("/") valid_pieces = [p for p in path_pieces if len(p)] if not valid_pieces: raise BrokenMetadata("Item %s has no valid content path" % (item)) path = self._path_join(self.base_path, "openstack", *path_pieces) return self._path_read(path, decode=decode) def read_v2(self): """Reads a version 2 formatted location. Return a dict with metadata, userdata, ec2-metadata, dsmode, network_config, files and version (2). If not a valid location, raise a NonReadable exception. """ load_json_anytype = functools.partial( util.load_json, root_types=(dict, list, str) ) def datafiles(version): files = {} files["metadata"] = ( # File path to read self._path_join("openstack", version, "meta_data.json"), # Is it required? True, # Translator function (applied after loading) util.load_json, ) files["userdata"] = ( self._path_join("openstack", version, "user_data"), False, lambda x: x, ) files["vendordata"] = ( self._path_join("openstack", version, "vendor_data.json"), False, load_json_anytype, ) files["vendordata2"] = ( self._path_join("openstack", version, "vendor_data2.json"), False, load_json_anytype, ) files["networkdata"] = ( self._path_join("openstack", version, "network_data.json"), False, load_json_anytype, ) return files results = { "userdata": "", "version": 2, } data = datafiles(self._find_working_version()) for (name, (path, required, translator)) in data.items(): path = self._path_join(self.base_path, path) data = None found = False try: data = self._path_read(path) except IOError as e: if not required: LOG.debug( "Failed reading optional path %s due to: %s", path, e ) else: LOG.debug( "Failed reading mandatory path %s due to: %s", path, e ) else: found = True if required and not found: raise NonReadable("Missing mandatory path: %s" % path) if found and translator: try: data = translator(data) except Exception as e: raise BrokenMetadata( "Failed to process path %s: %s" % (path, e) ) from e if found: results[name] = data metadata = results["metadata"] if "random_seed" in metadata: random_seed = metadata["random_seed"] try: metadata["random_seed"] = base64.b64decode(random_seed) except (ValueError, TypeError) as e: raise BrokenMetadata( "Badly formatted metadata random_seed entry: %s" % e ) from e # load any files that were provided files = {} metadata_files = metadata.get("files", []) for item in metadata_files: if "path" not in item: continue path = item["path"] try: files[path] = self._read_content_path(item) except Exception as e: raise BrokenMetadata( "Failed to read provided file %s: %s" % (path, e) ) from e results["files"] = files # The 'network_config' item in metadata is a content pointer # to the network config that should be applied. It is just a # ubuntu/debian '/etc/network/interfaces' file. net_item = metadata.get("network_config", None) if net_item: try: content = self._read_content_path(net_item, decode=True) results["network_config"] = content except IOError as e: raise BrokenMetadata( "Failed to read network configuration: %s" % (e) ) from e # To openstack, user can specify meta ('nova boot --meta=key=value') # and those will appear under metadata['meta']. # if they specify 'dsmode' they're indicating the mode that they intend # for this datasource to operate in. try: results["dsmode"] = metadata["meta"]["dsmode"] except KeyError: pass # Read any ec2-metadata (if applicable) results["ec2-metadata"] = self._read_ec2_metadata() # Perform some misc. metadata key renames... for (target_key, source_key, is_required) in KEY_COPIES: if is_required and source_key not in metadata: raise BrokenMetadata("No '%s' entry in metadata" % source_key) if source_key in metadata: metadata[target_key] = metadata.get(source_key) return results class ConfigDriveReader(BaseReader): def __init__(self, base_path): super(ConfigDriveReader, self).__init__(base_path) self._versions = None def _path_join(self, base, *add_ons): components = [base] + list(add_ons) return os.path.join(*components) def _path_read(self, path, decode=False): return util.load_file(path, decode=decode) def _fetch_available_versions(self): if self._versions is None: path = self._path_join(self.base_path, "openstack") found = [ d for d in os.listdir(path) if os.path.isdir(os.path.join(path)) ] self._versions = sorted(found) return self._versions def _read_ec2_metadata(self): path = self._path_join( self.base_path, "ec2", "latest", "meta-data.json" ) if not os.path.exists(path): return {} else: try: return util.load_json(self._path_read(path)) except Exception as e: raise BrokenMetadata( "Failed to process path %s: %s" % (path, e) ) from e def read_v1(self): """Reads a version 1 formatted location. Return a dict with metadata, userdata, dsmode, files and version (1). If not a valid path, raise a NonReadable exception. """ found = {} for name in FILES_V1.keys(): path = self._path_join(self.base_path, name) if os.path.exists(path): found[name] = path if len(found) == 0: raise NonReadable("%s: no files found" % (self.base_path)) md = {} for (name, (key, translator, default)) in FILES_V1.items(): if name in found: path = found[name] try: contents = self._path_read(path) except IOError as e: raise BrokenMetadata("Failed to read: %s" % path) from e try: # Disable not-callable pylint check; pylint isn't able to # determine that every member of FILES_V1 has a callable in # the appropriate position md[key] = translator(contents) # pylint: disable=E1102 except Exception as e: raise BrokenMetadata( "Failed to process path %s: %s" % (path, e) ) from e else: md[key] = copy.deepcopy(default) keydata = md["authorized_keys"] meta_js = md["meta_js"] # keydata in meta_js is preferred over "injected" keydata = meta_js.get("public-keys", keydata) if keydata: lines = keydata.splitlines() md["public-keys"] = [ line for line in lines if len(line) and not line.startswith("#") ] # config-drive-v1 has no way for openstack to provide the instance-id # so we copy that into metadata from the user input if "instance-id" in meta_js: md["instance-id"] = meta_js["instance-id"] results = { "version": 1, "metadata": md, } # allow the user to specify 'dsmode' in a meta tag if "dsmode" in meta_js: results["dsmode"] = meta_js["dsmode"] # config-drive-v1 has no way of specifying user-data, so the user has # to cheat and stuff it in a meta tag also. results["userdata"] = meta_js.get("user-data", "") # this implementation does not support files other than # network/interfaces and authorized_keys... results["files"] = {} return results class MetadataReader(BaseReader): def __init__(self, base_url, ssl_details=None, timeout=5, retries=5): super(MetadataReader, self).__init__(base_url) self.ssl_details = ssl_details self.timeout = float(timeout) self.retries = int(retries) self._versions = None def _fetch_available_versions(self): # /openstack/ returns a newline separated list of versions if self._versions is not None: return self._versions found = [] version_path = self._path_join(self.base_path, "openstack") content = self._path_read(version_path, decode=True) for line in content.splitlines(): line = line.strip() if not line: continue found.append(line) self._versions = found return self._versions def _path_read(self, path, decode=False): def should_retry_cb(_request_args, cause): try: code = int(cause.code) if code >= 400: return False except (TypeError, ValueError): # Older versions of requests didn't have a code. pass return True response = url_helper.readurl( path, retries=self.retries, ssl_details=self.ssl_details, timeout=self.timeout, exception_cb=should_retry_cb, ) if decode: return response.contents.decode() else: return response.contents def _path_join(self, base, *add_ons): return url_helper.combine_url(base, *add_ons) def _read_ec2_metadata(self): return ec2_utils.get_instance_metadata( ssl_details=self.ssl_details, timeout=self.timeout, retries=self.retries, ) # Convert OpenStack ConfigDrive NetworkData json to network_config yaml def convert_net_json(network_json=None, known_macs=None): """Return a dictionary of network_config by parsing provided OpenStack ConfigDrive NetworkData json format OpenStack network_data.json provides a 3 element dictionary - "links" (links are network devices, physical or virtual) - "networks" (networks are ip network configurations for one or more links) - services (non-ip services, like dns) networks and links are combined via network items referencing specific links via a 'link_id' which maps to a links 'id' field. To convert this format to network_config yaml, we first iterate over the links and then walk the network list to determine if any of the networks utilize the current link; if so we generate a subnet entry for the device We also need to map network_data.json fields to network_config fields. For example, the network_data links 'id' field is equivalent to network_config 'name' field for devices. We apply more of this mapping to the various link types that we encounter. There are additional fields that are populated in the network_data.json from OpenStack that are not relevant to network_config yaml, so we enumerate a dictionary of valid keys for network_yaml and apply filtering to drop these superflous keys from the network_config yaml. """ if network_json is None: return None # dict of network_config key for filtering network_json valid_keys = { "physical": [ "name", "type", "mac_address", "subnets", "params", "mtu", ], "subnet": [ "type", "address", "netmask", "broadcast", "metric", "gateway", "pointopoint", "scope", "dns_nameservers", "dns_search", "routes", ], } links = network_json.get("links", []) networks = network_json.get("networks", []) services = network_json.get("services", []) link_updates = [] link_id_info = {} bond_name_fmt = "bond%d" bond_number = 0 config = [] for link in links: subnets = [] cfg = dict( (k, v) for k, v in link.items() if k in valid_keys["physical"] ) # 'name' is not in openstack spec yet, but we will support it if it is # present. The 'id' in the spec is currently implemented as the host # nic's name, meaning something like 'tap-adfasdffd'. We do not want # to name guest devices with such ugly names. if "name" in link: cfg["name"] = link["name"] link_mac_addr = None if link.get("ethernet_mac_address"): link_mac_addr = link.get("ethernet_mac_address").lower() link_id_info[link["id"]] = link_mac_addr curinfo = { "name": cfg.get("name"), "mac": link_mac_addr, "id": link["id"], "type": link["type"], } for network in [n for n in networks if n["link"] == link["id"]]: subnet = dict( (k, v) for k, v in network.items() if k in valid_keys["subnet"] ) if network["type"] == "ipv4_dhcp": subnet.update({"type": "dhcp4"}) elif network["type"] == "ipv6_dhcp": subnet.update({"type": "dhcp6"}) elif network["type"] in [ "ipv6_slaac", "ipv6_dhcpv6-stateless", "ipv6_dhcpv6-stateful", ]: subnet.update({"type": network["type"]}) elif network["type"] in ["ipv4", "static"]: subnet.update( { "type": "static", "address": network.get("ip_address"), } ) elif network["type"] in ["ipv6", "static6"]: cfg.update({"accept-ra": False}) subnet.update( { "type": "static6", "address": network.get("ip_address"), } ) # Enable accept_ra for stateful and legacy ipv6_dhcp types if network["type"] in ["ipv6_dhcpv6-stateful", "ipv6_dhcp"]: cfg.update({"accept-ra": True}) if network["type"] == "ipv4": subnet["ipv4"] = True if network["type"] == "ipv6": subnet["ipv6"] = True subnets.append(subnet) cfg.update({"subnets": subnets}) if link["type"] in ["bond"]: params = {} if link_mac_addr: params["mac_address"] = link_mac_addr for k, v in link.items(): if k == "bond_links": continue elif k.startswith("bond"): params.update({k: v}) # openstack does not provide a name for the bond. # they do provide an 'id', but that is possibly non-sensical. # so we just create our own name. link_name = bond_name_fmt % bond_number bond_number += 1 # bond_links reference links by their id, but we need to add # to the network config by their nic name. # store that in bond_links_needed, and update these later. link_updates.append( ( cfg, "bond_interfaces", "%s", copy.deepcopy(link["bond_links"]), ) ) cfg.update({"params": params, "name": link_name}) curinfo["name"] = link_name elif link["type"] in ["vlan"]: name = "%s.%s" % (link["vlan_link"], link["vlan_id"]) cfg.update( { "name": name, "vlan_id": link["vlan_id"], "mac_address": link["vlan_mac_address"], } ) link_updates.append((cfg, "vlan_link", "%s", link["vlan_link"])) link_updates.append( (cfg, "name", "%%s.%s" % link["vlan_id"], link["vlan_link"]) ) curinfo.update({"mac": link["vlan_mac_address"], "name": name}) else: if link["type"] not in KNOWN_PHYSICAL_TYPES: LOG.warning( "Unknown network_data link type (%s); treating as" " physical", link["type"], ) cfg.update({"type": "physical", "mac_address": link_mac_addr}) config.append(cfg) link_id_info[curinfo["id"]] = curinfo need_names = [ d for d in config if d.get("type") == "physical" and "name" not in d ] if need_names or link_updates: if known_macs is None: known_macs = net.get_interfaces_by_mac() # go through and fill out the link_id_info with names for _link_id, info in link_id_info.items(): if info.get("name"): continue if info.get("mac") in known_macs: info["name"] = known_macs[info["mac"]] for d in need_names: mac = d.get("mac_address") if not mac: raise ValueError("No mac_address or name entry for %s" % d) if mac not in known_macs: raise ValueError("Unable to find a system nic for %s" % d) d["name"] = known_macs[mac] for cfg, key, fmt, targets in link_updates: if isinstance(targets, (list, tuple)): cfg[key] = [ fmt % link_id_info[target]["name"] for target in targets ] else: cfg[key] = fmt % link_id_info[targets]["name"] # Infiniband interfaces may be referenced in network_data.json by a 6 byte # Ethernet MAC-style address, and we use that address to look up the # interface name above. Now ensure that the hardware address is set to the # full 20 byte address. ib_known_hwaddrs = net.get_ib_hwaddrs_by_interface() if ib_known_hwaddrs: for cfg in config: if cfg["name"] in ib_known_hwaddrs: cfg["mac_address"] = ib_known_hwaddrs[cfg["name"]] cfg["type"] = "infiniband" for service in services: cfg = service cfg.update({"type": "nameserver"}) config.append(cfg) return {"version": 1, "config": config} # vi: ts=4 expandtab