|
| 1 | + |
| 2 | +"""Datasource for LXD, reads /dev/lxd/sock representaton of instance data. |
| 3 | +
|
| 4 | +Notes: |
| 5 | + * This datasource replaces previous NoCloud datasource for LXD. |
| 6 | + * Older LXD images may not have updates for cloud-init so NoCloud may |
| 7 | + still be detected on those images. |
| 8 | + * Detect LXD datasource when /dev/lxd/sock is an active socket file. |
| 9 | + * Info on dev-lxd API: https://linuxcontainers.org/lxd/docs/master/dev-lxd |
| 10 | + * TODO( Hotplug support using websockets API 1.0/events ) |
| 11 | +""" |
| 12 | + |
| 13 | +import os |
| 14 | + |
| 15 | +import requests |
| 16 | +from requests.adapters import HTTPAdapter |
| 17 | + |
| 18 | +# pylint fails to import the two modules below. |
| 19 | +# These are imported via requests.packages rather than urllib3 because: |
| 20 | +# a.) the provider of the requests package should ensure that urllib3 |
| 21 | +# contained in it is consistent/correct. |
| 22 | +# b.) cloud-init does not specifically have a dependency on urllib3 |
| 23 | +# |
| 24 | +# For future reference, see: |
| 25 | +# https://github.com/kennethreitz/requests/pull/2375 |
| 26 | +# https://github.com/requests/requests/issues/4104 |
| 27 | +# pylint: disable=E0401 |
| 28 | +from requests.packages.urllib3.connection import HTTPConnection |
| 29 | +from requests.packages.urllib3.connectionpool import HTTPConnectionPool |
| 30 | + |
| 31 | +import socket |
| 32 | +import stat |
| 33 | + |
| 34 | +from cloudinit import log as logging |
| 35 | +from cloudinit import sources, subp, util |
| 36 | + |
| 37 | +LOG = logging.getLogger(__name__) |
| 38 | + |
| 39 | +LXD_SOCKET_PATH = "/dev/lxd/sock" |
| 40 | +LXD_SOCKET_API_VERSION = "1.0" |
| 41 | + |
| 42 | +# Config key mappings to alias as top-level instance data keys |
| 43 | +CONFIG_KEY_ALIASES = { |
| 44 | + "user.user-data": "user-data", |
| 45 | + "user.network-config": "network-config", |
| 46 | + "user.network_mode": "network_mode", |
| 47 | + "user.vendor-data": "vendor-data" |
| 48 | +} |
| 49 | + |
| 50 | + |
| 51 | +def generate_fallback_network_config(network_mode: str = "") -> dict: |
| 52 | + """Return network config V1 dict representing instance network config.""" |
| 53 | + network_v1 = { |
| 54 | + "version": 1, |
| 55 | + "config": [ |
| 56 | + { |
| 57 | + "type": "physical", "name": "eth0", |
| 58 | + "subnets": [{"type": "dhcp", "control": "auto"}] |
| 59 | + } |
| 60 | + ] |
| 61 | + } |
| 62 | + if subp.which("systemd-detect-virt"): |
| 63 | + try: |
| 64 | + virt_type, _ = subp.subp(['systemd-detect-virt']) |
| 65 | + except subp.ProcessExecutionError as err: |
| 66 | + LOG.warning( |
| 67 | + "Unable to run systemd-detect-virt: %s." |
| 68 | + " Rendering default network config.", err |
| 69 | + ) |
| 70 | + return network_v1 |
| 71 | + if virt_type.strip() == "kvm": # instance.type VIRTUAL-MACHINE |
| 72 | + arch = util.system_info()["uname"][4] |
| 73 | + if arch == "ppc64le": |
| 74 | + network_v1["config"][0]["name"] = "enp0s5" |
| 75 | + elif arch == "s390x": |
| 76 | + network_v1["config"][0]["name"] = "enc9" |
| 77 | + else: |
| 78 | + network_v1["config"][0]["name"] = "enp5s0" |
| 79 | + if network_mode == "link-local": |
| 80 | + network_v1["config"][0]["subnets"][0]["control"] = "manual" |
| 81 | + elif network_mode not in ("", "dhcp"): |
| 82 | + LOG.warning( |
| 83 | + "Ignoring unexpected value user.network_mode: %s", network_mode |
| 84 | + ) |
| 85 | + return network_v1 |
| 86 | + |
| 87 | + |
| 88 | +class SocketHTTPConnection(HTTPConnection): |
| 89 | + def __init__(self, socket_path): |
| 90 | + super().__init__('localhost') |
| 91 | + self.socket_path = socket_path |
| 92 | + |
| 93 | + def connect(self): |
| 94 | + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) |
| 95 | + self.sock.connect(self.socket_path) |
| 96 | + |
| 97 | + |
| 98 | +class SocketConnectionPool(HTTPConnectionPool): |
| 99 | + def __init__(self, socket_path): |
| 100 | + self.socket_path = socket_path |
| 101 | + super().__init__('localhost') |
| 102 | + |
| 103 | + def _new_conn(self): |
| 104 | + return SocketHTTPConnection(self.socket_path) |
| 105 | + |
| 106 | + |
| 107 | +class LXDSocketAdapter(HTTPAdapter): |
| 108 | + def get_connection(self, url, proxies=None): |
| 109 | + return SocketConnectionPool(LXD_SOCKET_PATH) |
| 110 | + |
| 111 | + |
| 112 | +def _maybe_remove_top_network(cfg): |
| 113 | + """If network-config contains top level 'network' key, then remove it. |
| 114 | +
|
| 115 | + Some providers of network configuration may provide a top level |
| 116 | + 'network' key (LP: #1798117) even though it is not necessary. |
| 117 | +
|
| 118 | + Be friendly and remove it if it really seems so. |
| 119 | +
|
| 120 | + Return the original value if no change or the updated value if changed.""" |
| 121 | + if "network" not in cfg: |
| 122 | + return cfg |
| 123 | + network_val = cfg["network"] |
| 124 | + bmsg = 'Top level network key in network-config %s: %s' |
| 125 | + if not isinstance(network_val, dict): |
| 126 | + LOG.debug(bmsg, "was not a dict", cfg) |
| 127 | + return cfg |
| 128 | + if len(list(cfg.keys())) != 1: |
| 129 | + LOG.debug(bmsg, "had multiple top level keys", cfg) |
| 130 | + return cfg |
| 131 | + if network_val.get('config') == "disabled": |
| 132 | + LOG.debug(bmsg, "was config/disabled", cfg) |
| 133 | + elif not all(('config' in network_val, 'version' in network_val)): |
| 134 | + LOG.debug(bmsg, "but missing 'config' or 'version'", cfg) |
| 135 | + return cfg |
| 136 | + LOG.debug(bmsg, "fixed by removing shifting network.", cfg) |
| 137 | + return network_val |
| 138 | + |
| 139 | + |
| 140 | +def _raw_instance_data_to_dict(metadata_type: str, metadata_value) -> dict: |
| 141 | + """Convert raw instance data from str, bytes, YAML to dict |
| 142 | +
|
| 143 | + :param metadata_type: string, one of as: meta-data, vendor-data, user-data |
| 144 | + network-config |
| 145 | +
|
| 146 | + :param metadata_value: str, bytes or dict representing or instance-data. |
| 147 | +
|
| 148 | + :raises: InvalidMetaDataError on invalid instance-data content. |
| 149 | + """ |
| 150 | + if isinstance(metadata_value, dict): |
| 151 | + return metadata_value |
| 152 | + if metadata_value is None: |
| 153 | + return {} |
| 154 | + try: |
| 155 | + parsed_metadata = util.load_yaml(metadata_value) |
| 156 | + except AttributeError as exc: # not str or bytes |
| 157 | + raise sources.InvalidMetaDataException( |
| 158 | + "Invalid {md_type}. Expected str, bytes or dict but found:" |
| 159 | + " {value}".format(md_type=metadata_type, value=metadata_value) |
| 160 | + ) from exc |
| 161 | + if parsed_metadata is None: |
| 162 | + raise sources.InvalidMetaDataException( |
| 163 | + "Invalid {md_type} format. Expected YAML but found:" |
| 164 | + " {value}".format(md_type=metadata_type, value=metadata_value) |
| 165 | + ) |
| 166 | + return parsed_metadata |
| 167 | + |
| 168 | + |
| 169 | +class DataSourceLXD(sources.DataSource): |
| 170 | + |
| 171 | + dsname = 'LXD' |
| 172 | + |
| 173 | + _network_config = sources.UNSET |
| 174 | + _crawled_metadata = sources.UNSET |
| 175 | + |
| 176 | + sensitive_metadata_keys = ( |
| 177 | + 'merged_cfg', 'user.meta-data', 'user.vendor-data', 'user.user-data', |
| 178 | + ) |
| 179 | + |
| 180 | + def _is_platform_viable(self) -> bool: |
| 181 | + """Check platform environment to report if this datasource may run.""" |
| 182 | + return is_platform_viable() |
| 183 | + |
| 184 | + def _get_data(self) -> bool: |
| 185 | + """Crawl LXD socket API instance data and return True on success""" |
| 186 | + if not self._is_platform_viable(): |
| 187 | + LOG.debug("Not an LXD datasource: No LXD socket found.") |
| 188 | + return False |
| 189 | + |
| 190 | + self._crawled_metadata = util.log_time( |
| 191 | + logfunc=LOG.debug, msg='Crawl of metadata service', |
| 192 | + func=read_metadata) |
| 193 | + self.metadata = _raw_instance_data_to_dict( |
| 194 | + "meta-data", self._crawled_metadata.get("meta-data") |
| 195 | + ) |
| 196 | + if LXD_SOCKET_API_VERSION in self._crawled_metadata: |
| 197 | + config = self._crawled_metadata[LXD_SOCKET_API_VERSION].get( |
| 198 | + "config", {} |
| 199 | + ) |
| 200 | + user_metadata = config.get("user.meta-data", {}) |
| 201 | + if user_metadata: |
| 202 | + user_metadata = _raw_instance_data_to_dict( |
| 203 | + "user.meta-data", user_metadata |
| 204 | + ) |
| 205 | + if not isinstance(self.metadata, dict): |
| 206 | + self.metadata = util.mergemanydict( |
| 207 | + [util.load_yaml(self.metadata), user_metadata] |
| 208 | + ) |
| 209 | + if "user-data" in self._crawled_metadata: |
| 210 | + self.userdata_raw = self._crawled_metadata["user-data"] |
| 211 | + if "network-config" in self._crawled_metadata: |
| 212 | + self._network_config = _maybe_remove_top_network( |
| 213 | + _raw_instance_data_to_dict( |
| 214 | + "network-config", self._crawled_metadata["network-config"] |
| 215 | + ) |
| 216 | + ) |
| 217 | + if "vendor-data" in self._crawled_metadata: |
| 218 | + self.vendordata_raw = self._crawled_metadata["vendor-data"] |
| 219 | + return True |
| 220 | + |
| 221 | + def _get_subplatform(self) -> str: |
| 222 | + """Return subplatform details for this datasource""" |
| 223 | + return "LXD socket API v. {ver} ({socket})".format( |
| 224 | + ver=LXD_SOCKET_API_VERSION, socket=LXD_SOCKET_PATH |
| 225 | + ) |
| 226 | + |
| 227 | + def check_instance_id(self, sys_cfg) -> str: |
| 228 | + """Return True if instance_id unchanged.""" |
| 229 | + response = read_metadata(metadata_only=True) |
| 230 | + md = response.get("meta-data", {}) |
| 231 | + if not isinstance(md, dict): |
| 232 | + md = util.load_yaml(md) |
| 233 | + return md.get("instance-id") == self.metadata.get("instance-id") |
| 234 | + |
| 235 | + @property |
| 236 | + def network_config(self) -> dict: |
| 237 | + """Network config read from LXD socket config/user.network-config. |
| 238 | +
|
| 239 | + If none is present, then we generate fallback configuration. |
| 240 | + """ |
| 241 | + if self._network_config == sources.UNSET: |
| 242 | + if self._crawled_metadata.get("network-config"): |
| 243 | + self._network_config = self._crawled_metadata.get( |
| 244 | + "network-config" |
| 245 | + ) |
| 246 | + else: |
| 247 | + network_mode = self._crawled_metadata.get("network_mode", "") |
| 248 | + self._network_config = generate_fallback_network_config( |
| 249 | + network_mode |
| 250 | + ) |
| 251 | + return self._network_config |
| 252 | + |
| 253 | + |
| 254 | +def is_platform_viable() -> bool: |
| 255 | + """Return True when this platform appears to have an LXD socket.""" |
| 256 | + if os.path.exists(LXD_SOCKET_PATH): |
| 257 | + return stat.S_ISSOCK(os.lstat(LXD_SOCKET_PATH).st_mode) |
| 258 | + return False |
| 259 | + |
| 260 | + |
| 261 | +def read_metadata( |
| 262 | + api_version: str = LXD_SOCKET_API_VERSION, metadata_only: bool = False |
| 263 | +) -> dict: |
| 264 | + """Fetch metadata from the /dev/lxd/socket routes. |
| 265 | +
|
| 266 | + Perform a number of HTTP GETs on known routes on the devlxd socket API. |
| 267 | + Minimally all containers must respond to http://lxd/1.0/meta-data when |
| 268 | + the LXD configuration setting `security.devlxd` is true. |
| 269 | +
|
| 270 | + When `security.devlxd` is false, no /dev/lxd/socket file exists. This |
| 271 | + datasource will return False from `is_platform_viable` in that case. |
| 272 | +
|
| 273 | + Perform a GET of <LXD_SOCKET_API_VERSION>/config` and walk all `user.*` |
| 274 | + configuration keys, storing all keys and values under a dict key |
| 275 | + LXD_SOCKET_API_VERSION: config {...}. |
| 276 | +
|
| 277 | + In the presence of the following optional user config keys, |
| 278 | + create top level aliases: |
| 279 | + - user.user-data -> user-data |
| 280 | + - user.vendor-data -> vendor-data |
| 281 | + - user.network-config -> network-config |
| 282 | +
|
| 283 | + :return: |
| 284 | + A dict with the following mandatory key: meta-data. |
| 285 | + Optional keys: user-data, vendor-data, network-config, network_mode |
| 286 | +
|
| 287 | + Below <LXD_SOCKET_API_VERSION> is a dict representation of all raw |
| 288 | + configuration keys and values provided to the container surfaced by |
| 289 | + the socket under the /1.0/config/ route. |
| 290 | + """ |
| 291 | + md = {} |
| 292 | + lxd_url = "http://lxd" |
| 293 | + version_url = lxd_url + "/" + api_version + "/" |
| 294 | + with requests.Session() as session: |
| 295 | + session.mount(version_url, LXDSocketAdapter()) |
| 296 | + # Raw meta-data as text |
| 297 | + md_route = "{route}/meta-data".format(route=version_url) |
| 298 | + response = session.get(md_route) |
| 299 | + LOG.debug("[GET] [HTTP:%d] %s", response.status_code, md_route) |
| 300 | + if not response.ok: |
| 301 | + raise sources.InvalidMetaDataException( |
| 302 | + "Invalid HTTP response [{code}] from {route}: {resp}".format( |
| 303 | + code=response.status_code, |
| 304 | + route=md_route, |
| 305 | + resp=response.txt |
| 306 | + ) |
| 307 | + ) |
| 308 | + |
| 309 | + md["meta-data"] = response.text |
| 310 | + if metadata_only: |
| 311 | + return md # Skip network-data, vendor-data, user-data |
| 312 | + |
| 313 | + config_url = version_url + "config" |
| 314 | + # Represent all advertized/available config routes under |
| 315 | + # the dict path {LXD_SOCKET_API_VERSION: {config: {...}}. |
| 316 | + LOG.debug("[GET] %s", config_url) |
| 317 | + config_routes = session.get(config_url).json() |
| 318 | + md[LXD_SOCKET_API_VERSION] = { |
| 319 | + "config": {}, |
| 320 | + "meta-data": md["meta-data"] |
| 321 | + } |
| 322 | + for config_route in config_routes: |
| 323 | + url = "http://lxd{route}".format(route=config_route) |
| 324 | + LOG.debug("[GET] %s", url) |
| 325 | + response = session.get(url) |
| 326 | + if response.ok: |
| 327 | + cfg_key = config_route.rpartition("/")[-1] |
| 328 | + # Leave raw data values/format unchanged to represent it in |
| 329 | + # instance-data.json for cloud-init query or jinja template |
| 330 | + # use. |
| 331 | + md[LXD_SOCKET_API_VERSION]["config"][cfg_key] = response.text |
| 332 | + # Promote common CONFIG_KEY_ALIASES to top-level keys. |
| 333 | + if cfg_key in CONFIG_KEY_ALIASES: |
| 334 | + md[CONFIG_KEY_ALIASES[cfg_key]] = response.text |
| 335 | + else: |
| 336 | + LOG.debug("Skipping %s on invalid response", url) |
| 337 | + return md |
| 338 | + |
| 339 | + |
| 340 | +# Used to match classes to dependencies |
| 341 | +datasources = [ |
| 342 | + (DataSourceLXD, (sources.DEP_FILESYSTEM,)), |
| 343 | +] |
| 344 | + |
| 345 | + |
| 346 | +# Return a list of data sources that match this set of dependencies |
| 347 | +def get_datasource_list(depends): |
| 348 | + return sources.list_from_depends(depends, datasources) |
| 349 | + |
| 350 | + |
| 351 | +if __name__ == "__main__": |
| 352 | + import argparse |
| 353 | + |
| 354 | + description = """Query LXD metadata and emit a JSON object.""" |
| 355 | + parser = argparse.ArgumentParser(description=description) |
| 356 | + parser.parse_args() |
| 357 | + print(util.json_dumps(read_metadata())) |
| 358 | +# vi: ts=4 expandtab |
0 commit comments