summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cloudinit/settings.py1
-rw-r--r--cloudinit/sources/DataSourceLXD.py358
-rw-r--r--cloudinit/sources/tests/test_lxd.py185
-rw-r--r--doc/rtd/topics/datasources.rst1
-rw-r--r--doc/rtd/topics/datasources/lxd.rst65
-rw-r--r--tests/integration_tests/clouds.py9
-rw-r--r--tests/integration_tests/datasources/test_lxd_discovery.py80
-rw-r--r--tests/unittests/test_datasource/test_common.py2
-rwxr-xr-xtools/ds-identify8
9 files changed, 707 insertions, 2 deletions
diff --git a/cloudinit/settings.py b/cloudinit/settings.py
index f69005ea..43c8fa24 100644
--- a/cloudinit/settings.py
+++ b/cloudinit/settings.py
@@ -21,6 +21,7 @@ CFG_BUILTIN = {
'datasource_list': [
'NoCloud',
'ConfigDrive',
+ 'LXD',
'OpenNebula',
'DigitalOcean',
'Azure',
diff --git a/cloudinit/sources/DataSourceLXD.py b/cloudinit/sources/DataSourceLXD.py
new file mode 100644
index 00000000..732b32ff
--- /dev/null
+++ b/cloudinit/sources/DataSourceLXD.py
@@ -0,0 +1,358 @@
+
+"""Datasource for LXD, reads /dev/lxd/sock representaton of instance data.
+
+Notes:
+ * This datasource replaces previous NoCloud datasource for LXD.
+ * Older LXD images may not have updates for cloud-init so NoCloud may
+ still be detected on those images.
+ * Detect LXD datasource when /dev/lxd/sock is an active socket file.
+ * Info on dev-lxd API: https://linuxcontainers.org/lxd/docs/master/dev-lxd
+ * TODO( Hotplug support using websockets API 1.0/events )
+"""
+
+import os
+
+import requests
+from requests.adapters import HTTPAdapter
+
+# pylint fails to import the two modules below.
+# These are imported via requests.packages rather than urllib3 because:
+# a.) the provider of the requests package should ensure that urllib3
+# contained in it is consistent/correct.
+# b.) cloud-init does not specifically have a dependency on urllib3
+#
+# For future reference, see:
+# https://github.com/kennethreitz/requests/pull/2375
+# https://github.com/requests/requests/issues/4104
+# pylint: disable=E0401
+from requests.packages.urllib3.connection import HTTPConnection
+from requests.packages.urllib3.connectionpool import HTTPConnectionPool
+
+import socket
+import stat
+
+from cloudinit import log as logging
+from cloudinit import sources, subp, util
+
+LOG = logging.getLogger(__name__)
+
+LXD_SOCKET_PATH = "/dev/lxd/sock"
+LXD_SOCKET_API_VERSION = "1.0"
+
+# Config key mappings to alias as top-level instance data keys
+CONFIG_KEY_ALIASES = {
+ "user.user-data": "user-data",
+ "user.network-config": "network-config",
+ "user.network_mode": "network_mode",
+ "user.vendor-data": "vendor-data"
+}
+
+
+def generate_fallback_network_config(network_mode: str = "") -> dict:
+ """Return network config V1 dict representing instance network config."""
+ network_v1 = {
+ "version": 1,
+ "config": [
+ {
+ "type": "physical", "name": "eth0",
+ "subnets": [{"type": "dhcp", "control": "auto"}]
+ }
+ ]
+ }
+ if subp.which("systemd-detect-virt"):
+ try:
+ virt_type, _ = subp.subp(['systemd-detect-virt'])
+ except subp.ProcessExecutionError as err:
+ LOG.warning(
+ "Unable to run systemd-detect-virt: %s."
+ " Rendering default network config.", err
+ )
+ return network_v1
+ if virt_type.strip() == "kvm": # instance.type VIRTUAL-MACHINE
+ arch = util.system_info()["uname"][4]
+ if arch == "ppc64le":
+ network_v1["config"][0]["name"] = "enp0s5"
+ elif arch == "s390x":
+ network_v1["config"][0]["name"] = "enc9"
+ else:
+ network_v1["config"][0]["name"] = "enp5s0"
+ if network_mode == "link-local":
+ network_v1["config"][0]["subnets"][0]["control"] = "manual"
+ elif network_mode not in ("", "dhcp"):
+ LOG.warning(
+ "Ignoring unexpected value user.network_mode: %s", network_mode
+ )
+ return network_v1
+
+
+class SocketHTTPConnection(HTTPConnection):
+ def __init__(self, socket_path):
+ super().__init__('localhost')
+ self.socket_path = socket_path
+
+ def connect(self):
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ self.sock.connect(self.socket_path)
+
+
+class SocketConnectionPool(HTTPConnectionPool):
+ def __init__(self, socket_path):
+ self.socket_path = socket_path
+ super().__init__('localhost')
+
+ def _new_conn(self):
+ return SocketHTTPConnection(self.socket_path)
+
+
+class LXDSocketAdapter(HTTPAdapter):
+ def get_connection(self, url, proxies=None):
+ return SocketConnectionPool(LXD_SOCKET_PATH)
+
+
+def _maybe_remove_top_network(cfg):
+ """If network-config contains top level 'network' key, then remove it.
+
+ Some providers of network configuration may provide a top level
+ 'network' key (LP: #1798117) even though it is not necessary.
+
+ Be friendly and remove it if it really seems so.
+
+ Return the original value if no change or the updated value if changed."""
+ if "network" not in cfg:
+ return cfg
+ network_val = cfg["network"]
+ bmsg = 'Top level network key in network-config %s: %s'
+ if not isinstance(network_val, dict):
+ LOG.debug(bmsg, "was not a dict", cfg)
+ return cfg
+ if len(list(cfg.keys())) != 1:
+ LOG.debug(bmsg, "had multiple top level keys", cfg)
+ return cfg
+ if network_val.get('config') == "disabled":
+ LOG.debug(bmsg, "was config/disabled", cfg)
+ elif not all(('config' in network_val, 'version' in network_val)):
+ LOG.debug(bmsg, "but missing 'config' or 'version'", cfg)
+ return cfg
+ LOG.debug(bmsg, "fixed by removing shifting network.", cfg)
+ return network_val
+
+
+def _raw_instance_data_to_dict(metadata_type: str, metadata_value) -> dict:
+ """Convert raw instance data from str, bytes, YAML to dict
+
+ :param metadata_type: string, one of as: meta-data, vendor-data, user-data
+ network-config
+
+ :param metadata_value: str, bytes or dict representing or instance-data.
+
+ :raises: InvalidMetaDataError on invalid instance-data content.
+ """
+ if isinstance(metadata_value, dict):
+ return metadata_value
+ if metadata_value is None:
+ return {}
+ try:
+ parsed_metadata = util.load_yaml(metadata_value)
+ except AttributeError as exc: # not str or bytes
+ raise sources.InvalidMetaDataException(
+ "Invalid {md_type}. Expected str, bytes or dict but found:"
+ " {value}".format(md_type=metadata_type, value=metadata_value)
+ ) from exc
+ if parsed_metadata is None:
+ raise sources.InvalidMetaDataException(
+ "Invalid {md_type} format. Expected YAML but found:"
+ " {value}".format(md_type=metadata_type, value=metadata_value)
+ )
+ return parsed_metadata
+
+
+class DataSourceLXD(sources.DataSource):
+
+ dsname = 'LXD'
+
+ _network_config = sources.UNSET
+ _crawled_metadata = sources.UNSET
+
+ sensitive_metadata_keys = (
+ 'merged_cfg', 'user.meta-data', 'user.vendor-data', 'user.user-data',
+ )
+
+ def _is_platform_viable(self) -> bool:
+ """Check platform environment to report if this datasource may run."""
+ return is_platform_viable()
+
+ def _get_data(self) -> bool:
+ """Crawl LXD socket API instance data and return True on success"""
+ if not self._is_platform_viable():
+ LOG.debug("Not an LXD datasource: No LXD socket found.")
+ return False
+
+ self._crawled_metadata = util.log_time(
+ logfunc=LOG.debug, msg='Crawl of metadata service',
+ func=read_metadata)
+ self.metadata = _raw_instance_data_to_dict(
+ "meta-data", self._crawled_metadata.get("meta-data")
+ )
+ if LXD_SOCKET_API_VERSION in self._crawled_metadata:
+ config = self._crawled_metadata[LXD_SOCKET_API_VERSION].get(
+ "config", {}
+ )
+ user_metadata = config.get("user.meta-data", {})
+ if user_metadata:
+ user_metadata = _raw_instance_data_to_dict(
+ "user.meta-data", user_metadata
+ )
+ if not isinstance(self.metadata, dict):
+ self.metadata = util.mergemanydict(
+ [util.load_yaml(self.metadata), user_metadata]
+ )
+ if "user-data" in self._crawled_metadata:
+ self.userdata_raw = self._crawled_metadata["user-data"]
+ if "network-config" in self._crawled_metadata:
+ self._network_config = _maybe_remove_top_network(
+ _raw_instance_data_to_dict(
+ "network-config", self._crawled_metadata["network-config"]
+ )
+ )
+ if "vendor-data" in self._crawled_metadata:
+ self.vendordata_raw = self._crawled_metadata["vendor-data"]
+ return True
+
+ def _get_subplatform(self) -> str:
+ """Return subplatform details for this datasource"""
+ return "LXD socket API v. {ver} ({socket})".format(
+ ver=LXD_SOCKET_API_VERSION, socket=LXD_SOCKET_PATH
+ )
+
+ def check_instance_id(self, sys_cfg) -> str:
+ """Return True if instance_id unchanged."""
+ response = read_metadata(metadata_only=True)
+ md = response.get("meta-data", {})
+ if not isinstance(md, dict):
+ md = util.load_yaml(md)
+ return md.get("instance-id") == self.metadata.get("instance-id")
+
+ @property
+ def network_config(self) -> dict:
+ """Network config read from LXD socket config/user.network-config.
+
+ If none is present, then we generate fallback configuration.
+ """
+ if self._network_config == sources.UNSET:
+ if self._crawled_metadata.get("network-config"):
+ self._network_config = self._crawled_metadata.get(
+ "network-config"
+ )
+ else:
+ network_mode = self._crawled_metadata.get("network_mode", "")
+ self._network_config = generate_fallback_network_config(
+ network_mode
+ )
+ return self._network_config
+
+
+def is_platform_viable() -> bool:
+ """Return True when this platform appears to have an LXD socket."""
+ if os.path.exists(LXD_SOCKET_PATH):
+ return stat.S_ISSOCK(os.lstat(LXD_SOCKET_PATH).st_mode)
+ return False
+
+
+def read_metadata(
+ api_version: str = LXD_SOCKET_API_VERSION, metadata_only: bool = False
+) -> dict:
+ """Fetch metadata from the /dev/lxd/socket routes.
+
+ Perform a number of HTTP GETs on known routes on the devlxd socket API.
+ Minimally all containers must respond to http://lxd/1.0/meta-data when
+ the LXD configuration setting `security.devlxd` is true.
+
+ When `security.devlxd` is false, no /dev/lxd/socket file exists. This
+ datasource will return False from `is_platform_viable` in that case.
+
+ Perform a GET of <LXD_SOCKET_API_VERSION>/config` and walk all `user.*`
+ configuration keys, storing all keys and values under a dict key
+ LXD_SOCKET_API_VERSION: config {...}.
+
+ In the presence of the following optional user config keys,
+ create top level aliases:
+ - user.user-data -> user-data
+ - user.vendor-data -> vendor-data
+ - user.network-config -> network-config
+
+ :return:
+ A dict with the following mandatory key: meta-data.
+ Optional keys: user-data, vendor-data, network-config, network_mode
+
+ Below <LXD_SOCKET_API_VERSION> is a dict representation of all raw
+ configuration keys and values provided to the container surfaced by
+ the socket under the /1.0/config/ route.
+ """
+ md = {}
+ lxd_url = "http://lxd"
+ version_url = lxd_url + "/" + api_version + "/"
+ with requests.Session() as session:
+ session.mount(version_url, LXDSocketAdapter())
+ # Raw meta-data as text
+ md_route = "{route}/meta-data".format(route=version_url)
+ response = session.get(md_route)
+ LOG.debug("[GET] [HTTP:%d] %s", response.status_code, md_route)
+ if not response.ok:
+ raise sources.InvalidMetaDataException(
+ "Invalid HTTP response [{code}] from {route}: {resp}".format(
+ code=response.status_code,
+ route=md_route,
+ resp=response.txt
+ )
+ )
+
+ md["meta-data"] = response.text
+ if metadata_only:
+ return md # Skip network-data, vendor-data, user-data
+
+ config_url = version_url + "config"
+ # Represent all advertized/available config routes under
+ # the dict path {LXD_SOCKET_API_VERSION: {config: {...}}.
+ LOG.debug("[GET] %s", config_url)
+ config_routes = session.get(config_url).json()
+ md[LXD_SOCKET_API_VERSION] = {
+ "config": {},
+ "meta-data": md["meta-data"]
+ }
+ for config_route in config_routes:
+ url = "http://lxd{route}".format(route=config_route)
+ LOG.debug("[GET] %s", url)
+ response = session.get(url)
+ if response.ok:
+ cfg_key = config_route.rpartition("/")[-1]
+ # Leave raw data values/format unchanged to represent it in
+ # instance-data.json for cloud-init query or jinja template
+ # use.
+ md[LXD_SOCKET_API_VERSION]["config"][cfg_key] = response.text
+ # Promote common CONFIG_KEY_ALIASES to top-level keys.
+ if cfg_key in CONFIG_KEY_ALIASES:
+ md[CONFIG_KEY_ALIASES[cfg_key]] = response.text
+ else:
+ LOG.debug("Skipping %s on invalid response", url)
+ return md
+
+
+# Used to match classes to dependencies
+datasources = [
+ (DataSourceLXD, (sources.DEP_FILESYSTEM,)),
+]
+
+
+# Return a list of data sources that match this set of dependencies
+def get_datasource_list(depends):
+ return sources.list_from_depends(depends, datasources)
+
+
+if __name__ == "__main__":
+ import argparse
+
+ description = """Query LXD metadata and emit a JSON object."""
+ parser = argparse.ArgumentParser(description=description)
+ parser.parse_args()
+ print(util.json_dumps(read_metadata()))
+# vi: ts=4 expandtab
diff --git a/cloudinit/sources/tests/test_lxd.py b/cloudinit/sources/tests/test_lxd.py
new file mode 100644
index 00000000..c2027616
--- /dev/null
+++ b/cloudinit/sources/tests/test_lxd.py
@@ -0,0 +1,185 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+from collections import namedtuple
+from copy import deepcopy
+import stat
+from unittest import mock
+import yaml
+
+import pytest
+
+from cloudinit.sources import DataSourceLXD as lxd, UNSET
+DS_PATH = "cloudinit.sources.DataSourceLXD."
+
+
+LStatResponse = namedtuple("lstatresponse", "st_mode")
+
+
+NETWORK_V1 = {
+ "version": 1,
+ "config": [
+ {
+ "type": "physical", "name": "eth0",
+ "subnets": [{"type": "dhcp", "control": "auto"}]
+ }
+ ]
+}
+NETWORK_V1_MANUAL = deepcopy(NETWORK_V1)
+NETWORK_V1_MANUAL["config"][0]["subnets"][0]["control"] = "manual"
+
+
+def _add_network_v1_device(devname) -> dict:
+ """Helper to inject device name into default network v1 config."""
+ network_cfg = deepcopy(NETWORK_V1)
+ network_cfg["config"][0]["name"] = devname
+ return network_cfg
+
+
+LXD_V1_METADATA = {
+ "meta-data": "instance-id: my-lxc\nlocal-hostname: my-lxc\n\n",
+ "network-config": NETWORK_V1,
+ "user-data": "#cloud-config\npackages: [sl]\n",
+ "vendor-data": "#cloud-config\nruncmd: ['echo vendor-data']\n",
+ "1.0": {
+ "meta-data": "instance-id: my-lxc\nlocal-hostname: my-lxc\n\n",
+ "config": {
+ "user.user-data":
+ "instance-id: my-lxc\nlocal-hostname: my-lxc\n\n",
+ "user.vendor-data":
+ "#cloud-config\nruncmd: ['echo vendor-data']\n",
+ "user.network-config": yaml.safe_dump(NETWORK_V1),
+ }
+ }
+}
+
+
+@pytest.fixture
+def lxd_metadata():
+ return LXD_V1_METADATA
+
+
+@pytest.yield_fixture
+def lxd_ds(request, paths, lxd_metadata):
+ """
+ Return an instantiated DataSourceLXD.
+
+ This also performs the mocking required for the default test case:
+ * ``is_platform_viable`` returns True,
+ * ``read_metadata`` returns ``LXD_V1_METADATA``
+
+ (This uses the paths fixture for the required helpers.Paths object)
+ """
+ with mock.patch(DS_PATH + "is_platform_viable", return_value=True):
+ with mock.patch(DS_PATH + "read_metadata", return_value=lxd_metadata):
+ yield lxd.DataSourceLXD(
+ sys_cfg={}, distro=mock.Mock(), paths=paths
+ )
+
+
+class TestGenerateFallbackNetworkConfig:
+
+ @pytest.mark.parametrize(
+ "uname_machine,systemd_detect_virt,network_mode,expected", (
+ # None for systemd_detect_virt returns None from which
+ ({}, None, "", NETWORK_V1),
+ ({}, None, "dhcp", NETWORK_V1),
+ # invalid network_mode logs warning
+ ({}, None, "bogus", NETWORK_V1),
+ ({}, None, "link-local", NETWORK_V1_MANUAL),
+ ("anything", "lxc\n", "", NETWORK_V1),
+ # `uname -m` on kvm determines devname
+ ("x86_64", "kvm\n", "", _add_network_v1_device("enp5s0")),
+ ("ppc64le", "kvm\n", "", _add_network_v1_device("enp0s5")),
+ ("s390x", "kvm\n", "", _add_network_v1_device("enc9"))
+ )
+ )
+ @mock.patch(DS_PATH + "util.system_info")
+ @mock.patch(DS_PATH + "subp.subp")
+ @mock.patch(DS_PATH + "subp.which")
+ def test_net_v2_based_on_network_mode_virt_type_and_uname_machine(
+ self,
+ m_which,
+ m_subp,
+ m_system_info,
+ uname_machine,
+ systemd_detect_virt,
+ network_mode,
+ expected,
+ caplog
+ ):
+ """Return network config v2 based on uname -m, systemd-detect-virt.
+
+ LXC config network_mode of "link-local" will determine whether to set
+ "activation-mode: manual", leaving the interface down.
+ """
+ if systemd_detect_virt is None:
+ m_which.return_value = None
+ m_system_info.return_value = {"uname": ["", "", "", "", uname_machine]}
+ m_subp.return_value = (systemd_detect_virt, "")
+ assert expected == lxd.generate_fallback_network_config(
+ network_mode=network_mode
+ )
+ if systemd_detect_virt is None:
+ assert 0 == m_subp.call_count
+ assert 0 == m_system_info.call_count
+ else:
+ assert [
+ mock.call(["systemd-detect-virt"])
+ ] == m_subp.call_args_list
+ if systemd_detect_virt != "kvm\n":
+ assert 0 == m_system_info.call_count
+ else:
+ assert 1 == m_system_info.call_count
+ if network_mode not in ("dhcp", "", "link-local"):
+ assert "Ignoring unexpected value user.network_mode: {}".format(
+ network_mode
+ ) in caplog.text
+
+
+class TestDataSourceLXD:
+ def test_platform_info(self, lxd_ds):
+ assert "LXD" == lxd_ds.dsname
+ assert "lxd" == lxd_ds.cloud_name
+ assert "lxd" == lxd_ds.platform_type
+
+ def test_subplatform(self, lxd_ds):
+ assert "LXD socket API v. 1.0 (/dev/lxd/sock)" == lxd_ds.subplatform
+
+ def test__get_data(self, lxd_ds):
+ """get_data calls read_metadata, setting appropiate instance attrs."""
+ assert UNSET == lxd_ds._crawled_metadata
+ assert UNSET == lxd_ds._network_config
+ assert None is lxd_ds.userdata_raw
+ assert True is lxd_ds._get_data()
+ assert LXD_V1_METADATA == lxd_ds._crawled_metadata
+ # network-config is dumped from YAML
+ assert NETWORK_V1 == lxd_ds._network_config
+ # Any user-data and vendor-data are saved as raw
+ assert LXD_V1_METADATA["user-data"] == lxd_ds.userdata_raw
+ assert LXD_V1_METADATA["vendor-data"] == lxd_ds.vendordata_raw
+
+
+class TestIsPlatformViable:
+ @pytest.mark.parametrize(
+ "exists,lstat_mode,expected", (
+ (False, None, False),
+ (True, stat.S_IFREG, False),
+ (True, stat.S_IFSOCK, True),
+ )
+ )
+ @mock.patch(DS_PATH + "os.lstat")
+ @mock.patch(DS_PATH + "os.path.exists")
+ def test_expected_viable(
+ self, m_exists, m_lstat, exists, lstat_mode, expected
+ ):
+ """Return True only when LXD_SOCKET_PATH exists and is a socket."""
+ m_exists.return_value = exists
+ m_lstat.return_value = LStatResponse(lstat_mode)
+ assert expected is lxd.is_platform_viable()
+ m_exists.assert_has_calls([mock.call(lxd.LXD_SOCKET_PATH)])
+ if exists:
+ m_lstat.assert_has_calls([mock.call(lxd.LXD_SOCKET_PATH)])
+ else:
+ assert 0 == m_lstat.call_count
+
+# vi: ts=4 expandtab
diff --git a/doc/rtd/topics/datasources.rst b/doc/rtd/topics/datasources.rst
index f5aee1c2..0ebc0f32 100644
--- a/doc/rtd/topics/datasources.rst
+++ b/doc/rtd/topics/datasources.rst
@@ -39,6 +39,7 @@ The following is a list of documents for each supported datasource:
datasources/exoscale.rst
datasources/fallback.rst
datasources/gce.rst
+ datasources/lxd.rst
datasources/maas.rst
datasources/nocloud.rst
datasources/opennebula.rst
diff --git a/doc/rtd/topics/datasources/lxd.rst b/doc/rtd/topics/datasources/lxd.rst
new file mode 100644
index 00000000..3991a4dd
--- /dev/null
+++ b/doc/rtd/topics/datasources/lxd.rst
@@ -0,0 +1,65 @@
+.. _datasource_lxd:
+
+LXD
+===
+
+The data source ``LXD`` allows the user to provide custom user-data,
+vendor-data, meta-data and network-config to the instance without running
+a network service (or even without having a network at all). This datasource
+performs HTTP GETs against the `LXD socket device`_ which is provided to each
+running LXD container and VM as ``/dev/lxd/sock`` and represents all
+instance-metadata as versioned HTTP routes such as:
+
+ - 1.0/meta-data
+ - 1.0/config/user.meta-data
+ - 1.0/config/user.vendor-data
+ - 1.0/config/user.user-data
+ - 1.0/config/user.<any-custom-key>
+
+The LXD socket device ``/dev/lxd/sock`` is only present on containers and VMs
+when the instance configuration has ``security.devlxd=true`` (default).
+Disabling ``security.devlxd`` configuration setting at initial launch will
+ensure that cloud-init uses the :ref:`datasource_nocloud` datasource.
+Disabling ``security.devlxd`` ove the life of the container will result in
+warnings from cloud-init and cloud-init will keep the originally detected LXD
+datasource.
+
+The LXD datasource provides cloud-init the opportunity to react to meta-data,
+vendor-data, user-data and network-config changes and render the updated
+configuration across a system reboot.
+
+One can manipulate what meta-data, vendor-data or user-data is provided to
+the launched container using the LXD profiles or
+``lxc launch ... -c <key>="<value>"`` at initial container launch using one of
+the following keys:
+
+ - user.meta-data: YAML metadata which will be appended to base meta-data
+ - user.vendor-data: YAML which overrides any meta-data values
+ - user.network-config: YAML representing either :ref:`network_config_v1` or
+ :ref:`network_config_v2` format
+ - user.user-data: YAML which takes preference and overrides both meta-data
+ and vendor-data values
+ - user.any-key: Custom user configuration key and value pairs can be passed to
+ cloud-init. Those keys/values will be present in instance-data which can be
+ used by both `#template: jinja` #cloud-config templates and
+ the `cloud-init query` command.
+
+
+By default, network configuration from this datasource will be:
+
+.. code:: yaml
+
+ version: 1
+ config:
+ - type: physical
+ name: eth0
+ subnets:
+ - type: dhcp
+ control: auto
+
+This datasource is intended to replace :ref:`datasource_nocloud`
+datasource for LXD instances with a more direct support for LXD APIs instead
+of static NoCloud seed files.
+
+.. _LXD socket device: https://linuxcontainers.org/lxd/docs/master/dev-lxd
+.. vi: textwidth=78
diff --git a/tests/integration_tests/clouds.py b/tests/integration_tests/clouds.py
index 32fdc91e..dee2adff 100644
--- a/tests/integration_tests/clouds.py
+++ b/tests/integration_tests/clouds.py
@@ -1,7 +1,10 @@
# This file is part of cloud-init. See LICENSE file for license information.
from abc import ABC, abstractmethod
+import datetime
import logging
import os.path
+import random
+import string
from uuid import UUID
from pycloudlib import (
@@ -309,8 +312,12 @@ class _LxdIntegrationCloud(IntegrationCloud):
except KeyError:
profile_list = self._get_or_set_profile_list(release)
+ prefix = datetime.datetime.utcnow().strftime("cloudinit-%m%d-%H%M%S")
+ default_name = prefix + "".join(
+ random.choices(string.ascii_lowercase + string.digits, k=8)
+ )
pycloudlib_instance = self.cloud_instance.init(
- launch_kwargs.pop('name', None),
+ launch_kwargs.pop('name', default_name),
release,
profile_list=profile_list,
**launch_kwargs
diff --git a/tests/integration_tests/datasources/test_lxd_discovery.py b/tests/integration_tests/datasources/test_lxd_discovery.py
new file mode 100644
index 00000000..f7d8bfc3
--- /dev/null
+++ b/tests/integration_tests/datasources/test_lxd_discovery.py
@@ -0,0 +1,80 @@
+import json
+import pytest
+import yaml
+
+from tests.integration_tests.clouds import IntegrationCloud
+from tests.integration_tests.conftest import get_validated_source
+from tests.integration_tests.util import verify_clean_log
+
+
+def _setup_custom_image(session_cloud: IntegrationCloud):
+ """Like `setup_image` in conftest.py, but with customized content."""
+ source = get_validated_source(session_cloud)
+ if not source.installs_new_version():
+ return
+ client = session_cloud.launch()
+
+ # Insert our "detect_lxd_ds" file here
+ client.write_to_file(
+ '/etc/cloud/cloud.cfg.d/99-detect-lxd.cfg',
+ 'datasource_list: [LXD]\n',
+ )
+
+ client.execute('rm -f /etc/netplan/50-cloud-init.yaml')
+
+ client.install_new_cloud_init(source)
+ # Even if we're keeping instances, we don't want to keep this
+ # one around as it was just for image creation
+ client.destroy()
+
+
+# This test should be able to work on any cloud whose datasource specifies
+# a NETWORK dependency
+@pytest.mark.lxd_container
+@pytest.mark.lxd_vm
+@pytest.mark.ubuntu # Because netplan
+def test_lxd_datasource_discovery(session_cloud: IntegrationCloud):
+ """Test that DataSourceLXD is detected instead of NoCloud."""
+ _setup_custom_image(session_cloud)
+ nic_dev = "eth0"
+ if session_cloud.settings.PLATFORM == "lxd_vm":
+ nic_dev = "enp5s0"
+
+ with session_cloud.launch() as client:
+ result = client.execute('cloud-init status --long')
+ if not result.ok:
+ raise AssertionError('cloud-init failed:\n%s',
+ result.stderr)
+ if "DataSourceLXD" not in result.stdout:
+ raise AssertionError(
+ 'cloud-init did not discover DataSourceLXD', result.stdout
+ )
+ netplan_yaml = client.execute('cat /etc/netplan/50-cloud-init.yaml')
+ netplan_cfg = yaml.safe_load(netplan_yaml)
+ assert {
+ 'network': {'ethernets': {nic_dev: {'dhcp4': True}}, 'version': 2}
+ } == netplan_cfg
+ log = client.read_from_file('/var/log/cloud-init.log')
+ verify_clean_log(log)
+ result = client.execute('cloud-id')
+ if "lxd" != result.stdout:
+ raise AssertionError(
+ "cloud-id didn't report lxd. Result: %s", result.stdout
+ )
+ # Validate config instance data represented
+ data = json.loads(client.read_from_file(
+ '/run/cloud-init/instance-data.json')
+ )
+ v1 = data["v1"]
+ ds_cfg = data["ds"]
+ assert "lxd" == v1["platform"]
+ assert "LXD socket API v. 1.0 (/dev/lxd/sock)" == v1["subplatform"]
+ ds_cfg = json.loads(client.execute('cloud-init query ds').stdout)
+ assert ["config", "meta_data"] == sorted(list(ds_cfg["1.0"].keys()))
+ assert ["user.meta_data"] == list(ds_cfg["1.0"]["config"].keys())
+ assert {"public-keys": v1["public_ssh_keys"][0]} == (
+ yaml.safe_load(ds_cfg["1.0"]["config"]["user.meta_data"])
+ )
+ assert (
+ "#cloud-config\ninstance-id" in ds_cfg["1.0"]["meta_data"]
+ )
diff --git a/tests/unittests/test_datasource/test_common.py b/tests/unittests/test_datasource/test_common.py
index 00f0a78c..17d53160 100644
--- a/tests/unittests/test_datasource/test_common.py
+++ b/tests/unittests/test_datasource/test_common.py
@@ -18,6 +18,7 @@ from cloudinit.sources import (
DataSourceGCE as GCE,
DataSourceHetzner as Hetzner,
DataSourceIBMCloud as IBMCloud,
+ DataSourceLXD as LXD,
DataSourceMAAS as MAAS,
DataSourceNoCloud as NoCloud,
DataSourceOpenNebula as OpenNebula,
@@ -42,6 +43,7 @@ DEFAULT_LOCAL = [
DigitalOcean.DataSourceDigitalOcean,
Hetzner.DataSourceHetzner,
IBMCloud.DataSourceIBMCloud,
+ LXD.DataSourceLXD,
NoCloud.DataSourceNoCloud,
OpenNebula.DataSourceOpenNebula,
Oracle.DataSourceOracle,
diff --git a/tools/ds-identify b/tools/ds-identify
index c2f710e9..30d4b0f6 100755
--- a/tools/ds-identify
+++ b/tools/ds-identify
@@ -123,7 +123,7 @@ DS_MAYBE=2
DI_DSNAME=""
# this has to match the builtin list in cloud-init, it is what will
# be searched if there is no setting found in config.
-DI_DSLIST_DEFAULT="MAAS ConfigDrive NoCloud AltCloud Azure Bigstep \
+DI_DSLIST_DEFAULT="MAAS ConfigDrive NoCloud LXD AltCloud Azure Bigstep \
CloudSigma CloudStack DigitalOcean Vultr AliYun Ec2 GCE OpenNebula OpenStack \
OVF SmartOS Scaleway Hetzner IBMCloud Oracle Exoscale RbxCloud UpCloud VMware"
DI_DSLIST=""
@@ -802,6 +802,12 @@ dscheck_MAAS() {
return ${DS_NOT_FOUND}
}
+# LXD datasource requires active /dev/lxd/sock
+# https://linuxcontainers.org/lxd/docs/master/dev-lxd
+dscheck_LXD() {
+ [ -S /dev/lxd/sock ] && return ${DS_FOUND} || return ${DS_NOT_FOUND}
+}
+
dscheck_NoCloud() {
local fslabel="cidata CIDATA" d=""
case " ${DI_KERNEL_CMDLINE} " in