Skip to content

Commit 7737653

Browse files
authored
Add LXD datasource (canonical#1040)
Add DataSourceLXD which knows how to talk to the dev-lxd socket to obtain all instance metadata API: https://linuxcontainers.org/lxd/docs/master/dev-lxd. This first branch is to deliver feature parity with the existing NoCloud datasource which is currently used to intialize LXC instances on first boot. Introduce a SocketConnectionPool and LXDSocketAdapter to support performing HTTP GETs on the following routes which are surfaced by the LXD host to all containers: http://unix.socket/1.0/meta-data http://unix.socket/1.0/config/user.user-data http://unix.socket/1.0/config/user.network-config http://unix.socket/1.0/config/user.vendor-data These 4 routes minimally replace the static content provided in the following nocloud-net seed files: /var/lib/cloud/nocloud-net/{meta-data,vendor-data,user-data,network-config} The intent of this commit is to set a foundation for LXD socket communication that will allow us to build network hot-plug features by eventually consuming LXD's websocket upgrade route 1.0/events to react to network, meta-data and user-data config changes over time. In the event that no custom network-config is provided, default to the same network-config definition provided by LXD to the NoCloud network-config seed file. Supplemental features above NoCloud datasource: surface all custom instance data config keys via cloud-init query ds which aids in discoverability of features/tags/labels as well as conditional #cloud-config jinja templates operations based on custom config options. TBD: better cloud-init query support for dot-delimited keys
1 parent b1beb53 commit 7737653

File tree

9 files changed

+707
-2
lines changed

9 files changed

+707
-2
lines changed

cloudinit/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
'datasource_list': [
2222
'NoCloud',
2323
'ConfigDrive',
24+
'LXD',
2425
'OpenNebula',
2526
'DigitalOcean',
2627
'Azure',

cloudinit/sources/DataSourceLXD.py

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
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

Comments
 (0)