summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJames Falcon <james.falcon@canonical.com>2021-03-19 14:32:13 -0500
committergit-ubuntu importer <ubuntu-devel-discuss@lists.ubuntu.com>2021-03-19 22:01:08 +0000
commite28f927082d79158a90faeef2b1506d3461d4946 (patch)
treec0d93a5b0772271dc5ee6db86b63008d79346775
parent6834043e1074fd6198d1993a735880f245e95274 (diff)
downloadcloud-init-git-e28f927082d79158a90faeef2b1506d3461d4946.tar.gz
21.1-19-gbad84ad4-0ubuntu1 (patches unapplied)
Imported using git-ubuntu import.
-rw-r--r--.travis.yml1
-rw-r--r--cloudinit/config/cc_keys_to_console.py22
-rw-r--r--cloudinit/config/cc_set_hostname.py4
-rwxr-xr-xcloudinit/config/cc_set_passwords.py5
-rw-r--r--cloudinit/config/tests/test_set_passwords.py40
-rw-r--r--cloudinit/distros/arch.py29
-rw-r--r--cloudinit/net/__init__.py62
-rw-r--r--cloudinit/net/tests/test_init.py119
-rwxr-xr-xcloudinit/sources/DataSourceAzure.py113
-rw-r--r--cloudinit/sources/DataSourceEc2.py3
-rw-r--r--cloudinit/sources/helpers/tests/test_openstack.py5
-rw-r--r--cloudinit/sources/tests/test_oracle.py4
-rw-r--r--cloudinit/stages.py18
-rw-r--r--cloudinit/tests/test_util.py56
-rw-r--r--cloudinit/util.py38
-rw-r--r--debian/changelog32
-rw-r--r--debian/cloud-init.postinst17
-rw-r--r--doc/examples/part-handler.txt1
-rw-r--r--doc/rtd/topics/datasources/nocloud.rst2
-rw-r--r--integration-requirements.txt2
-rw-r--r--tests/integration_tests/bugs/test_lp1912844.py103
-rw-r--r--tests/integration_tests/clouds.py86
-rw-r--r--tests/integration_tests/conftest.py23
-rw-r--r--tests/integration_tests/integration_settings.py8
-rw-r--r--tests/integration_tests/modules/test_apt.py53
-rw-r--r--tests/integration_tests/modules/test_set_password.py24
-rw-r--r--tests/integration_tests/modules/test_users_groups.py45
-rw-r--r--tests/integration_tests/test_logging.py22
-rw-r--r--tests/unittests/test_datasource/test_aliyun.py30
-rw-r--r--tests/unittests/test_datasource/test_azure.py42
-rw-r--r--tests/unittests/test_datasource/test_configdrive.py8
-rw-r--r--tests/unittests/test_handler/test_handler_locale.py23
-rw-r--r--tests/unittests/test_net.py20
-rw-r--r--tests/unittests/test_util.py4
-rw-r--r--tools/.github-cla-signers3
-rw-r--r--tox.ini5
36 files changed, 956 insertions, 116 deletions
diff --git a/.travis.yml b/.travis.yml
index f15752bc..690ab644 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -120,6 +120,7 @@ matrix:
fi
# Use sudo to get a new shell where we're in the sbuild group
- sudo -E su $USER -c 'sbuild --nolog --no-run-lintian --verbose --dist=xenial cloud-init_*.dsc'
+ - ssh-keygen -P "" -q -f ~/.ssh/id_rsa
- sg lxd -c 'CLOUD_INIT_CLOUD_INIT_SOURCE="$(ls *.deb)" tox -e integration-tests-ci' &
- |
SECONDS=0
diff --git a/cloudinit/config/cc_keys_to_console.py b/cloudinit/config/cc_keys_to_console.py
index 646d1f67..d72b5244 100644
--- a/cloudinit/config/cc_keys_to_console.py
+++ b/cloudinit/config/cc_keys_to_console.py
@@ -9,14 +9,17 @@
"""
Keys to Console
---------------
-**Summary:** control which SSH keys may be written to console
-
-For security reasons it may be desirable not to write SSH fingerprints and keys
-to the console. To avoid the fingerprint of types of SSH keys being written to
-console the ``ssh_fp_console_blacklist`` config key can be used. By default all
-types of keys will have their fingerprints written to console. To avoid keys
-of a key type being written to console the ``ssh_key_console_blacklist`` config
-key can be used. By default ``ssh-dss`` keys are not written to console.
+**Summary:** control which SSH host keys may be written to console
+
+For security reasons it may be desirable not to write SSH host keys and their
+fingerprints to the console. To avoid either being written to the console the
+``emit_keys_to_console`` config key under the main ``ssh`` config key can be
+used. To avoid the fingerprint of types of SSH host keys being written to
+console the ``ssh_fp_console_blacklist`` config key can be used. By default
+all types of keys will have their fingerprints written to console. To avoid
+host keys of a key type being written to console the
+``ssh_key_console_blacklist`` config key can be used. By default ``ssh-dss``
+host keys are not written to console.
**Internal name:** ``cc_keys_to_console``
@@ -26,6 +29,9 @@ key can be used. By default ``ssh-dss`` keys are not written to console.
**Config keys**::
+ ssh:
+ emit_keys_to_console: false
+
ssh_fp_console_blacklist: <list of key types>
ssh_key_console_blacklist: <list of key types>
"""
diff --git a/cloudinit/config/cc_set_hostname.py b/cloudinit/config/cc_set_hostname.py
index 1d23d80d..d4017478 100644
--- a/cloudinit/config/cc_set_hostname.py
+++ b/cloudinit/config/cc_set_hostname.py
@@ -18,8 +18,8 @@ A hostname and fqdn can be provided by specifying a full domain name under the
``fqdn`` key. Alternatively, a hostname can be specified using the ``hostname``
key, and the fqdn of the cloud wil be used. If a fqdn specified with the
``hostname`` key, it will be handled properly, although it is better to use
-the ``fqdn`` config key. If both ``fqdn`` and ``hostname`` are set, ``fqdn``
-will be used.
+the ``fqdn`` config key. If both ``fqdn`` and ``hostname`` are set,
+it is distro dependent whether ``hostname`` or ``fqdn`` is used.
This module will run in the init-local stage before networking is configured
if the hostname is set by metadata or user data on the local system.
diff --git a/cloudinit/config/cc_set_passwords.py b/cloudinit/config/cc_set_passwords.py
index d6b5682d..433de751 100755
--- a/cloudinit/config/cc_set_passwords.py
+++ b/cloudinit/config/cc_set_passwords.py
@@ -78,7 +78,6 @@ password.
"""
import re
-import sys
from cloudinit.distros import ug_util
from cloudinit import log as logging
@@ -214,7 +213,9 @@ def handle(_name, cfg, cloud, log, args):
if len(randlist):
blurb = ("Set the following 'random' passwords\n",
'\n'.join(randlist))
- sys.stderr.write("%s\n%s\n" % blurb)
+ util.multi_log(
+ "%s\n%s\n" % blurb, stderr=False, fallback_to_stdout=False
+ )
if expire:
expired_users = []
diff --git a/cloudinit/config/tests/test_set_passwords.py b/cloudinit/config/tests/test_set_passwords.py
index daa1ef51..bbe2ee8f 100644
--- a/cloudinit/config/tests/test_set_passwords.py
+++ b/cloudinit/config/tests/test_set_passwords.py
@@ -74,10 +74,6 @@ class TestSetPasswordsHandle(CiTestCase):
with_logs = True
- def setUp(self):
- super(TestSetPasswordsHandle, self).setUp()
- self.add_patch('cloudinit.config.cc_set_passwords.sys.stderr', 'm_err')
-
def test_handle_on_empty_config(self, *args):
"""handle logs that no password has changed when config is empty."""
cloud = self.tmp_cloud(distro='ubuntu')
@@ -129,10 +125,12 @@ class TestSetPasswordsHandle(CiTestCase):
mock.call(['pw', 'usermod', 'ubuntu', '-p', '01-Jan-1970'])],
m_subp.call_args_list)
+ @mock.patch(MODPATH + "util.multi_log")
@mock.patch(MODPATH + "util.is_BSD")
@mock.patch(MODPATH + "subp.subp")
- def test_handle_on_chpasswd_list_creates_random_passwords(self, m_subp,
- m_is_bsd):
+ def test_handle_on_chpasswd_list_creates_random_passwords(
+ self, m_subp, m_is_bsd, m_multi_log
+ ):
"""handle parses command set random passwords."""
m_is_bsd.return_value = False
cloud = self.tmp_cloud(distro='ubuntu')
@@ -146,10 +144,32 @@ class TestSetPasswordsHandle(CiTestCase):
self.assertIn(
'DEBUG: Handling input for chpasswd as list.',
self.logs.getvalue())
- self.assertNotEqual(
- [mock.call(['chpasswd'],
- '\n'.join(valid_random_pwds) + '\n')],
- m_subp.call_args_list)
+
+ self.assertEqual(1, m_subp.call_count)
+ args, _kwargs = m_subp.call_args
+ self.assertEqual(["chpasswd"], args[0])
+
+ stdin = args[1]
+ user_pass = {
+ user: password
+ for user, password
+ in (line.split(":") for line in stdin.splitlines())
+ }
+
+ self.assertEqual(1, m_multi_log.call_count)
+ self.assertEqual(
+ mock.call(mock.ANY, stderr=False, fallback_to_stdout=False),
+ m_multi_log.call_args
+ )
+
+ self.assertEqual(set(["root", "ubuntu"]), set(user_pass.keys()))
+ written_lines = m_multi_log.call_args[0][0].splitlines()
+ for password in user_pass.values():
+ for line in written_lines:
+ if password in line:
+ break
+ else:
+ self.fail("Password not emitted to console")
# vi: ts=4 expandtab
diff --git a/cloudinit/distros/arch.py b/cloudinit/distros/arch.py
index 378a6daa..f8385f7f 100644
--- a/cloudinit/distros/arch.py
+++ b/cloudinit/distros/arch.py
@@ -23,7 +23,7 @@ LOG = logging.getLogger(__name__)
class Distro(distros.Distro):
- locale_conf_fn = "/etc/locale.gen"
+ locale_gen_fn = "/etc/locale.gen"
network_conf_dir = "/etc/netctl"
resolve_conf_fn = "/etc/resolv.conf"
init_cmd = ['systemctl'] # init scripts
@@ -43,16 +43,20 @@ class Distro(distros.Distro):
cfg['ssh_svcname'] = 'sshd'
def apply_locale(self, locale, out_fn=None):
- if not out_fn:
- out_fn = self.locale_conf_fn
- subp.subp(['locale-gen', '-G', locale], capture=False)
- # "" provides trailing newline during join
+ if out_fn is not None and out_fn != "/etc/locale.conf":
+ LOG.warning("Invalid locale_configfile %s, only supported "
+ "value is /etc/locale.conf", out_fn)
lines = [
util.make_header(),
- 'LANG="%s"' % (locale),
+ # Hard-coding the charset isn't ideal, but there is no other way.
+ '%s UTF-8' % (locale),
"",
]
- util.write_file(out_fn, "\n".join(lines))
+ util.write_file(self.locale_gen_fn, "\n".join(lines))
+ subp.subp(['locale-gen'], capture=False)
+ # In the future systemd can handle locale-gen stuff:
+ # https://github.com/systemd/systemd/pull/9864
+ subp.subp(['localectl', 'set-locale', locale], capture=False)
def install_packages(self, pkglist):
self.update_package_sources()
@@ -137,6 +141,17 @@ class Distro(distros.Distro):
return default
return hostname
+ # hostname (inetutils) isn't installed per default on arch, so we use
+ # hostnamectl which is installed per default (systemd).
+ def _apply_hostname(self, hostname):
+ LOG.debug("Non-persistently setting the system hostname to %s",
+ hostname)
+ try:
+ subp.subp(['hostnamectl', '--transient', 'set-hostname', hostname])
+ except subp.ProcessExecutionError:
+ util.logexc(LOG, "Failed to non-persistently adjust the system "
+ "hostname to %s", hostname)
+
def set_timezone(self, tz):
distros.set_etc_timezone(tz=tz, tz_file=self._find_tz_file(tz))
diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py
index de65e7af..385b7bcc 100644
--- a/cloudinit/net/__init__.py
+++ b/cloudinit/net/__init__.py
@@ -6,6 +6,7 @@
# This file is part of cloud-init. See LICENSE file for license information.
import errno
+import functools
import ipaddress
import logging
import os
@@ -19,6 +20,19 @@ from cloudinit.url_helper import UrlError, readurl
LOG = logging.getLogger(__name__)
SYS_CLASS_NET = "/sys/class/net/"
DEFAULT_PRIMARY_INTERFACE = 'eth0'
+OVS_INTERNAL_INTERFACE_LOOKUP_CMD = [
+ "ovs-vsctl",
+ "--format",
+ "csv",
+ "--no-headings",
+ "--timeout",
+ "10",
+ "--columns",
+ "name",
+ "find",
+ "interface",
+ "type=internal",
+]
def natural_sort_key(s, _nsre=re.compile('([0-9]+)')):
@@ -133,6 +147,52 @@ def master_is_openvswitch(devname):
return os.path.exists(ovs_path)
+@functools.lru_cache(maxsize=None)
+def openvswitch_is_installed() -> bool:
+ """Return a bool indicating if Open vSwitch is installed in the system."""
+ ret = bool(subp.which("ovs-vsctl"))
+ if not ret:
+ LOG.debug(
+ "ovs-vsctl not in PATH; not detecting Open vSwitch interfaces"
+ )
+ return ret
+
+
+@functools.lru_cache(maxsize=None)
+def get_ovs_internal_interfaces() -> list:
+ """Return a list of the names of OVS internal interfaces on the system.
+
+ These will all be strings, and are used to exclude OVS-specific interface
+ from cloud-init's network configuration handling.
+ """
+ try:
+ out, _err = subp.subp(OVS_INTERNAL_INTERFACE_LOOKUP_CMD)
+ except subp.ProcessExecutionError as exc:
+ if "database connection failed" in exc.stderr:
+ LOG.info(
+ "Open vSwitch is not yet up; no interfaces will be detected as"
+ " OVS-internal"
+ )
+ return []
+ raise
+ else:
+ return out.splitlines()
+
+
+def is_openvswitch_internal_interface(devname: str) -> bool:
+ """Returns True if this is an OVS internal interface.
+
+ If OVS is not installed or not yet running, this will return False.
+ """
+ if not openvswitch_is_installed():
+ return False
+ ovs_bridges = get_ovs_internal_interfaces()
+ if devname in ovs_bridges:
+ LOG.debug("Detected %s as an OVS interface", devname)
+ return True
+ return False
+
+
def is_netfailover(devname, driver=None):
""" netfailover driver uses 3 nics, master, primary and standby.
this returns True if the device is either the primary or standby
@@ -884,6 +944,8 @@ def get_interfaces(blacklist_drivers=None) -> list:
# skip nics that have no mac (00:00....)
if name != 'lo' and mac == zero_mac[:len(mac)]:
continue
+ if is_openvswitch_internal_interface(name):
+ continue
# skip nics that have drivers blacklisted
driver = device_driver(name)
if driver in blacklist_drivers:
diff --git a/cloudinit/net/tests/test_init.py b/cloudinit/net/tests/test_init.py
index 0535387a..946f8ee2 100644
--- a/cloudinit/net/tests/test_init.py
+++ b/cloudinit/net/tests/test_init.py
@@ -391,6 +391,10 @@ class TestGetDeviceList(CiTestCase):
self.assertCountEqual(['eth0', 'eth1'], net.get_devicelist())
+@mock.patch(
+ "cloudinit.net.is_openvswitch_internal_interface",
+ mock.Mock(return_value=False),
+)
class TestGetInterfaceMAC(CiTestCase):
def setUp(self):
@@ -1224,6 +1228,121 @@ class TestNetFailOver(CiTestCase):
self.assertFalse(net.is_netfailover(devname, driver))
+class TestOpenvswitchIsInstalled:
+ """Test cloudinit.net.openvswitch_is_installed.
+
+ Uses the ``clear_lru_cache`` local autouse fixture to allow us to test
+ despite the ``lru_cache`` decorator on the unit under test.
+ """
+
+ @pytest.fixture(autouse=True)
+ def clear_lru_cache(self):
+ net.openvswitch_is_installed.cache_clear()
+
+ @pytest.mark.parametrize(
+ "expected,which_return", [(True, "/some/path"), (False, None)]
+ )
+ @mock.patch("cloudinit.net.subp.which")
+ def test_mirrors_which_result(self, m_which, expected, which_return):
+ m_which.return_value = which_return
+ assert expected == net.openvswitch_is_installed()
+
+ @mock.patch("cloudinit.net.subp.which")
+ def test_only_calls_which_once(self, m_which):
+ net.openvswitch_is_installed()
+ net.openvswitch_is_installed()
+ assert 1 == m_which.call_count
+
+
+@mock.patch("cloudinit.net.subp.subp", return_value=("", ""))
+class TestGetOVSInternalInterfaces:
+ """Test cloudinit.net.get_ovs_internal_interfaces.
+
+ Uses the ``clear_lru_cache`` local autouse fixture to allow us to test
+ despite the ``lru_cache`` decorator on the unit under test.
+ """
+ @pytest.fixture(autouse=True)
+ def clear_lru_cache(self):
+ net.get_ovs_internal_interfaces.cache_clear()
+
+ def test_command_used(self, m_subp):
+ """Test we use the correct command when we call subp"""
+ net.get_ovs_internal_interfaces()
+
+ assert [
+ mock.call(net.OVS_INTERNAL_INTERFACE_LOOKUP_CMD)
+ ] == m_subp.call_args_list
+
+ def test_subp_contents_split_and_returned(self, m_subp):
+ """Test that the command output is appropriately mangled."""
+ stdout = "iface1\niface2\niface3\n"
+ m_subp.return_value = (stdout, "")
+
+ assert [
+ "iface1",
+ "iface2",
+ "iface3",
+ ] == net.get_ovs_internal_interfaces()
+
+ def test_database_connection_error_handled_gracefully(self, m_subp):
+ """Test that the error indicating OVS is down is handled gracefully."""
+ m_subp.side_effect = ProcessExecutionError(
+ stderr="database connection failed"
+ )
+
+ assert [] == net.get_ovs_internal_interfaces()
+
+ def test_other_errors_raised(self, m_subp):
+ """Test that only database connection errors are handled."""
+ m_subp.side_effect = ProcessExecutionError()
+
+ with pytest.raises(ProcessExecutionError):
+ net.get_ovs_internal_interfaces()
+
+ def test_only_runs_once(self, m_subp):
+ """Test that we cache the value."""
+ net.get_ovs_internal_interfaces()
+ net.get_ovs_internal_interfaces()
+
+ assert 1 == m_subp.call_count
+
+
+@mock.patch("cloudinit.net.get_ovs_internal_interfaces")
+@mock.patch("cloudinit.net.openvswitch_is_installed")
+class TestIsOpenVSwitchInternalInterface:
+ def test_false_if_ovs_not_installed(
+ self, m_openvswitch_is_installed, _m_get_ovs_internal_interfaces
+ ):
+ """Test that OVS' absence returns False."""
+ m_openvswitch_is_installed.return_value = False
+
+ assert not net.is_openvswitch_internal_interface("devname")
+
+ @pytest.mark.parametrize(
+ "detected_interfaces,devname,expected_return",
+ [
+ ([], "devname", False),
+ (["notdevname"], "devname", False),
+ (["devname"], "devname", True),
+ (["some", "other", "devices", "and", "ours"], "ours", True),
+ ],
+ )
+ def test_return_value_based_on_detected_interfaces(
+ self,
+ m_openvswitch_is_installed,
+ m_get_ovs_internal_interfaces,
+ detected_interfaces,
+ devname,
+ expected_return,
+ ):
+ """Test that the detected interfaces are used correctly."""
+ m_openvswitch_is_installed.return_value = True
+ m_get_ovs_internal_interfaces.return_value = detected_interfaces
+ assert expected_return == net.is_openvswitch_internal_interface(
+ devname
+ )
+
+
class TestIsIpAddress:
"""Tests for net.is_ip_address.
diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py
index cee630f7..6cae9e82 100755
--- a/cloudinit/sources/DataSourceAzure.py
+++ b/cloudinit/sources/DataSourceAzure.py
@@ -78,17 +78,15 @@ AGENT_SEED_DIR = '/var/lib/waagent'
# In the event where the IMDS primary server is not
# available, it takes 1s to fallback to the secondary one
IMDS_TIMEOUT_IN_SECONDS = 2
-IMDS_URL = "http://169.254.169.254/metadata/"
-IMDS_VER = "2019-06-01"
-IMDS_VER_PARAM = "api-version={}".format(IMDS_VER)
+IMDS_URL = "http://169.254.169.254/metadata"
+IMDS_VER_MIN = "2019-06-01"
+IMDS_VER_WANT = "2020-09-01"
class metadata_type(Enum):
- compute = "{}instance?{}".format(IMDS_URL, IMDS_VER_PARAM)
- network = "{}instance/network?{}".format(IMDS_URL,
- IMDS_VER_PARAM)
- reprovisiondata = "{}reprovisiondata?{}".format(IMDS_URL,
- IMDS_VER_PARAM)
+ compute = "{}/instance".format(IMDS_URL)
+ network = "{}/instance/network".format(IMDS_URL)
+ reprovisiondata = "{}/reprovisiondata".format(IMDS_URL)
PLATFORM_ENTROPY_SOURCE = "/sys/firmware/acpi/tables/OEM0"
@@ -349,6 +347,8 @@ class DataSourceAzure(sources.DataSource):
self.update_events['network'].add(EventType.BOOT)
self._ephemeral_dhcp_ctx = None
+ self.failed_desired_api_version = False
+
def __str__(self):
root = sources.DataSource.__str__(self)
return "%s [seed=%s]" % (root, self.seed)
@@ -520,8 +520,10 @@ class DataSourceAzure(sources.DataSource):
self._wait_for_all_nics_ready()
ret = self._reprovision()
- imds_md = get_metadata_from_imds(
- self.fallback_interface, retries=10)
+ imds_md = self.get_imds_data_with_api_fallback(
+ self.fallback_interface,
+ retries=10
+ )
(md, userdata_raw, cfg, files) = ret
self.seed = cdev
crawled_data.update({
@@ -652,6 +654,57 @@ class DataSourceAzure(sources.DataSource):
self.ds_cfg['data_dir'], crawled_data['files'], dirmode=0o700)
return True
+ @azure_ds_telemetry_reporter
+ def get_imds_data_with_api_fallback(
+ self,
+ fallback_nic,
+ retries,
+ md_type=metadata_type.compute):
+ """
+ Wrapper for get_metadata_from_imds so that we can have flexibility
+ in which IMDS api-version we use. If a particular instance of IMDS
+ does not have the api version that is desired, we want to make
+ this fault tolerant and fall back to a good known minimum api
+ version.
+ """
+
+ if not self.failed_desired_api_version:
+ for _ in range(retries):
+ try:
+ LOG.info(
+ "Attempting IMDS api-version: %s",
+ IMDS_VER_WANT
+ )
+ return get_metadata_from_imds(
+ fallback_nic=fallback_nic,
+ retries=0,
+ md_type=md_type,
+ api_version=IMDS_VER_WANT
+ )
+ except UrlError as err:
+ LOG.info(
+ "UrlError with IMDS api-version: %s",
+ IMDS_VER_WANT
+ )
+ if err.code == 400:
+ log_msg = "Fall back to IMDS api-version: {}".format(
+ IMDS_VER_MIN
+ )
+ report_diagnostic_event(
+ log_msg,
+ logger_func=LOG.info
+ )
+ self.failed_desired_api_version = True
+ break
+
+ LOG.info("Using IMDS api-version: %s", IMDS_VER_MIN)
+ return get_metadata_from_imds(
+ fallback_nic=fallback_nic,
+ retries=retries,
+ md_type=md_type,
+ api_version=IMDS_VER_MIN
+ )
+
def device_name_to_device(self, name):
return self.ds_cfg['disk_aliases'].get(name)
@@ -880,10 +933,11 @@ class DataSourceAzure(sources.DataSource):
# primary nic is being attached first helps here. Otherwise each nic
# could add several seconds of delay.
try:
- imds_md = get_metadata_from_imds(
+ imds_md = self.get_imds_data_with_api_fallback(
ifname,
5,
- metadata_type.network)
+ metadata_type.network
+ )
except Exception as e:
LOG.warning(
"Failed to get network metadata using nic %s. Attempt to "
@@ -1017,7 +1071,10 @@ class DataSourceAzure(sources.DataSource):
def _poll_imds(self):
"""Poll IMDS for the new provisioning data until we get a valid
response. Then return the returned JSON object."""
- url = metadata_type.reprovisiondata.value
+ url = "{}?api-version={}".format(
+ metadata_type.reprovisiondata.value,
+ IMDS_VER_MIN
+ )
headers = {"Metadata": "true"}
nl_sock = None
report_ready = bool(not os.path.isfile(REPORTED_READY_MARKER_FILE))
@@ -2059,7 +2116,8 @@ def _generate_network_config_from_fallback_config() -> dict:
@azure_ds_telemetry_reporter
def get_metadata_from_imds(fallback_nic,
retries,
- md_type=metadata_type.compute):
+ md_type=metadata_type.compute,
+ api_version=IMDS_VER_MIN):
"""Query Azure's instance metadata service, returning a dictionary.
If network is not up, setup ephemeral dhcp on fallback_nic to talk to the
@@ -2069,13 +2127,16 @@ def get_metadata_from_imds(fallback_nic,
@param fallback_nic: String. The name of the nic which requires active
network in order to query IMDS.
@param retries: The number of retries of the IMDS_URL.
+ @param md_type: Metadata type for IMDS request.
+ @param api_version: IMDS api-version to use in the request.
@return: A dict of instance metadata containing compute and network
info.
"""
kwargs = {'logfunc': LOG.debug,
'msg': 'Crawl of Azure Instance Metadata Service (IMDS)',
- 'func': _get_metadata_from_imds, 'args': (retries, md_type,)}
+ 'func': _get_metadata_from_imds,
+ 'args': (retries, md_type, api_version,)}
if net.is_up(fallback_nic):
return util.log_time(**kwargs)
else:
@@ -2091,20 +2152,26 @@ def get_metadata_from_imds(fallback_nic,
@azure_ds_telemetry_reporter
-def _get_metadata_from_imds(retries, md_type=metadata_type.compute):
-
- url = md_type.value
+def _get_metadata_from_imds(
+ retries,
+ md_type=metadata_type.compute,
+ api_version=IMDS_VER_MIN):
+ url = "{}?api-version={}".format(md_type.value, api_version)
headers = {"Metadata": "true"}
try:
response = readurl(
url, timeout=IMDS_TIMEOUT_IN_SECONDS, headers=headers,
retries=retries, exception_cb=retry_on_url_exc)
except Exception as e:
- report_diagnostic_event(
- 'Ignoring IMDS instance metadata. '
- 'Get metadata from IMDS failed: %s' % e,
- logger_func=LOG.warning)
- return {}
+ # pylint:disable=no-member
+ if isinstance(e, UrlError) and e.code == 400:
+ raise
+ else:
+ report_diagnostic_event(
+ 'Ignoring IMDS instance metadata. '
+ 'Get metadata from IMDS failed: %s' % e,
+ logger_func=LOG.warning)
+ return {}
try:
from json.decoder import JSONDecodeError
json_decode_error = JSONDecodeError
diff --git a/cloudinit/sources/DataSourceEc2.py b/cloudinit/sources/DataSourceEc2.py
index 1930a509..a2105dc7 100644
--- a/cloudinit/sources/DataSourceEc2.py
+++ b/cloudinit/sources/DataSourceEc2.py
@@ -765,13 +765,14 @@ def convert_ec2_metadata_network_config(
netcfg['ethernets'][nic_name] = dev_config
return netcfg
# Apply network config for all nics and any secondary IPv4/v6 addresses
+ nic_idx = 0
for mac, nic_name in sorted(macs_to_nics.items()):
nic_metadata = macs_metadata.get(mac)
if not nic_metadata:
continue # Not a physical nic represented in metadata
# device-number is zero-indexed, we want it 1-indexed for the
# multiplication on the following line
- nic_idx = int(nic_metadata['device-number']) + 1
+ nic_idx = int(nic_metadata.get('device-number', nic_idx)) + 1
dhcp_override = {'route-metric': nic_idx * 100}
dev_config = {'dhcp4': True, 'dhcp4-overrides': dhcp_override,
'dhcp6': False,
diff --git a/cloudinit/sources/helpers/tests/test_openstack.py b/cloudinit/sources/helpers/tests/test_openstack.py
index 2bde1e3f..95fb9743 100644
--- a/cloudinit/sources/helpers/tests/test_openstack.py
+++ b/cloudinit/sources/helpers/tests/test_openstack.py
@@ -1,10 +1,15 @@
# This file is part of cloud-init. See LICENSE file for license information.
# ./cloudinit/sources/helpers/tests/test_openstack.py
+from unittest import mock
from cloudinit.sources.helpers import openstack
from cloudinit.tests import helpers as test_helpers
+@mock.patch(
+ "cloudinit.net.is_openvswitch_internal_interface",
+ mock.Mock(return_value=False)
+)
class TestConvertNetJson(test_helpers.CiTestCase):
def test_phy_types(self):
diff --git a/cloudinit/sources/tests/test_oracle.py b/cloudinit/sources/tests/test_oracle.py
index a7bbdfd9..dcf33b9b 100644
--- a/cloudinit/sources/tests/test_oracle.py
+++ b/cloudinit/sources/tests/test_oracle.py
@@ -173,6 +173,10 @@ class TestIsPlatformViable(test_helpers.CiTestCase):
m_read_dmi_data.assert_has_calls([mock.call('chassis-asset-tag')])
+@mock.patch(
+ "cloudinit.net.is_openvswitch_internal_interface",
+ mock.Mock(return_value=False)
+)
class TestNetworkConfigFromOpcImds:
def test_no_secondary_nics_does_not_mutate_input(self, oracle_ds):
oracle_ds._vnics_data = [{}]
diff --git a/cloudinit/stages.py b/cloudinit/stages.py
index 3ef4491c..5bacc85d 100644
--- a/cloudinit/stages.py
+++ b/cloudinit/stages.py
@@ -364,12 +364,12 @@ class Init(object):
'userdata')
self._store_processeddata(self.datasource.get_userdata(),
'userdata')
- self._store_rawdata(self.datasource.get_vendordata_raw(),
- 'vendordata')
+ self._store_raw_vendordata(self.datasource.get_vendordata_raw(),
+ 'vendordata')
self._store_processeddata(self.datasource.get_vendordata(),
'vendordata')
- self._store_rawdata(self.datasource.get_vendordata2_raw(),
- 'vendordata2')
+ self._store_raw_vendordata(self.datasource.get_vendordata2_raw(),
+ 'vendordata2')
self._store_processeddata(self.datasource.get_vendordata2(),
'vendordata2')
@@ -397,6 +397,16 @@ class Init(object):
data = b''
util.write_file(self._get_ipath('%s_raw' % datasource), data, 0o600)
+ def _store_raw_vendordata(self, data, datasource):
+ # Only these data types
+ if data is not None and type(data) not in [bytes, str, list]:
+ raise TypeError("vendordata_raw is unsupported type '%s'" %
+ str(type(data)))
+ # This data may be a list, convert it to a string if so
+ if isinstance(data, list):
+ data = util.json_dumps(data)
+ self._store_rawdata(data, datasource)
+
def _store_processeddata(self, processed_data, datasource):
# processed is a Mime message, so write as string.
if processed_data is None:
diff --git a/cloudinit/tests/test_util.py b/cloudinit/tests/test_util.py
index b7a302f1..e811917e 100644
--- a/cloudinit/tests/test_util.py
+++ b/cloudinit/tests/test_util.py
@@ -851,4 +851,60 @@ class TestEnsureFile:
assert "ab" == kwargs["omode"]
+@mock.patch("cloudinit.util.grp.getgrnam")
+@mock.patch("cloudinit.util.os.setgid")
+@mock.patch("cloudinit.util.os.umask")
+class TestRedirectOutputPreexecFn:
+ """This tests specifically the preexec_fn used in redirect_output."""
+
+ @pytest.fixture(params=["outfmt", "errfmt"])
+ def preexec_fn(self, request):
+ """A fixture to gather the preexec_fn used by redirect_output.
+
+ This enables simpler direct testing of it, and parameterises any tests
+ using it to cover both the stdout and stderr code paths.
+ """
+ test_string = "| piped output to invoke subprocess"
+ if request.param == "outfmt":
+ args = (test_string, None)
+ elif request.param == "errfmt":
+ args = (None, test_string)
+ with mock.patch("cloudinit.util.subprocess.Popen") as m_popen:
+ util.redirect_output(*args)
+
+ assert 1 == m_popen.call_count
+ _args, kwargs = m_popen.call_args
+ assert "preexec_fn" in kwargs, "preexec_fn not passed to Popen"
+ return kwargs["preexec_fn"]
+
+ def test_preexec_fn_sets_umask(
+ self, m_os_umask, _m_setgid, _m_getgrnam, preexec_fn
+ ):
+ """preexec_fn should set a mask that avoids world-readable files."""
+ preexec_fn()
+
+ assert [mock.call(0o037)] == m_os_umask.call_args_list
+
+ def test_preexec_fn_sets_group_id_if_adm_group_present(
+ self, _m_os_umask, m_setgid, m_getgrnam, preexec_fn
+ ):
+ """We should setgrp to adm if present, so files are owned by them."""
+ fake_group = mock.Mock(gr_gid=mock.sentinel.gr_gid)
+ m_getgrnam.return_value = fake_group
+
+ preexec_fn()
+
+ assert [mock.call("adm")] == m_getgrnam.call_args_list
+ assert [mock.call(mock.sentinel.gr_gid)] == m_setgid.call_args_list
+
+ def test_preexec_fn_handles_absent_adm_group_gracefully(
+ self, _m_os_umask, m_setgid, m_getgrnam, preexec_fn
+ ):
+ """We should handle an absent adm group gracefully."""
+ m_getgrnam.side_effect = KeyError("getgrnam(): name not found: 'adm'")
+
+ preexec_fn()
+
+ assert 0 == m_setgid.call_count
+
# vi: ts=4 expandtab
diff --git a/cloudinit/util.py b/cloudinit/util.py
index 769f3425..4e0a72db 100644
--- a/cloudinit/util.py
+++ b/cloudinit/util.py
@@ -359,7 +359,7 @@ def find_modules(root_dir):
def multi_log(text, console=True, stderr=True,
- log=None, log_level=logging.DEBUG):
+ log=None, log_level=logging.DEBUG, fallback_to_stdout=True):
if stderr:
sys.stderr.write(text)
if console:
@@ -368,7 +368,7 @@ def multi_log(text, console=True, stderr=True,
with open(conpath, 'w') as wfh:
wfh.write(text)
wfh.flush()
- else:
+ elif fallback_to_stdout:
# A container may lack /dev/console (arguably a container bug). If
# it does not exist, then write output to stdout. this will result
# in duplicate stderr and stdout messages if stderr was True.
@@ -623,6 +623,26 @@ def redirect_output(outfmt, errfmt, o_out=None, o_err=None):
if not o_err:
o_err = sys.stderr
+ # pylint: disable=subprocess-popen-preexec-fn
+ def set_subprocess_umask_and_gid():
+ """Reconfigure umask and group ID to create output files securely.
+
+ This is passed to subprocess.Popen as preexec_fn, so it is executed in
+ the context of the newly-created process. It:
+
+ * sets the umask of the process so created files aren't world-readable
+ * if an adm group exists in the system, sets that as the process' GID
+ (so that the created file(s) are owned by root:adm)
+ """
+ os.umask(0o037)
+ try:
+ group_id = grp.getgrnam("adm").gr_gid
+ except KeyError:
+ # No adm group, don't set a group
+ pass
+ else:
+ os.setgid(group_id)
+
if outfmt:
LOG.debug("Redirecting %s to %s", o_out, outfmt)
(mode, arg) = outfmt.split(" ", 1)
@@ -632,7 +652,12 @@ def redirect_output(outfmt, errfmt, o_out=None, o_err=None):
owith = "wb"
new_fp = open(arg, owith)
elif mode == "|":
- proc = subprocess.Popen(arg, shell=True, stdin=subprocess.PIPE)
+ proc = subprocess.Popen(
+ arg,
+ shell=True,
+ stdin=subprocess.PIPE,
+ preexec_fn=set_subprocess_umask_and_gid,
+ )
new_fp = proc.stdin
else:
raise TypeError("Invalid type for output format: %s" % outfmt)
@@ -654,7 +679,12 @@ def redirect_output(outfmt, errfmt, o_out=None, o_err=None):
owith = "wb"
new_fp = open(arg, owith)
elif mode == "|":
- proc = subprocess.Popen(arg, shell=True, stdin=subprocess.PIPE)
+ proc = subprocess.Popen(
+ arg,
+ shell=True,
+ stdin=subprocess.PIPE,
+ preexec_fn=set_subprocess_umask_and_gid,
+ )
new_fp = proc.stdin
else:
raise TypeError("Invalid type for error format: %s" % errfmt)
diff --git a/debian/changelog b/debian/changelog
index 38d0314e..96a2688c 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,35 @@
+cloud-init (21.1-19-gbad84ad4-0ubuntu1) hirsute; urgency=medium
+ * d/cloud-init.postinst: Change output log permissions on upgrade
+ (LP: #1918303)
+ * New upstream snapshot.
+ - .travis.yml: generate an SSH key before running tests (#848)
+ - write passwords only to serial console, lock down cloud-init-output.log
+ (#847) (LP: #1918303)
+ - Fix apt default integration test (#845)
+ - integration_tests: bump pycloudlib dependency (#846)
+ - commit f35181fa970453ba6c7c14575b12185533391b97 [eb3095]
+ - archlinux: Fix broken locale logic (#841)
+ [Kristian Klausen] (LP: #1402406)
+ - Integration test for #783 (#832)
+ - integration_tests: mount more paths IN_PLACE (#838)
+ - Fix requiring device-number on EC2 derivatives (#836) (LP: #1917875)
+ - Remove the vi comment from the part-handler example (#835)
+ - net: exclude OVS internal interfaces in get_interfaces (#829)
+ (LP: #1912844)
+ - tox.ini: pass OS_* environment variables to integration tests (#830)
+ - integration_tests: add OpenStack as a platform (#804)
+ - Add flexibility to IMDS api-version (#793) [Thomas Stringer]
+ - Fix the TestApt tests using apt-key on Xenial and Hirsute (#823)
+ [Paride Legovini] (LP: #1916629)
+ - doc: remove duplicate "it" from nocloud.rst (#825) [V.I. Wood]
+ - archlinux: Use hostnamectl to set the transient hostname (#797)
+ [Kristian Klausen]
+ - cc_keys_to_console.py: Add documentation for recently added config key
+ (#824) [dermotbradley]
+ - Update cc_set_hostname documentation (#818) [Toshi Aoyama]
+
+ -- James Falcon <james.falcon@canonical.com> Fri, 19 Mar 2021 14:32:13 -0500
+
cloud-init (21.1-0ubuntu1) hirsute; urgency=medium
* New upstream release.
diff --git a/debian/cloud-init.postinst b/debian/cloud-init.postinst
index bb1535e8..683ba86d 100644
--- a/debian/cloud-init.postinst
+++ b/debian/cloud-init.postinst
@@ -327,6 +327,22 @@ fix_lp1889555() {
db_set grub-pc/install_devices_empty "false"
}
+change_cloud_init_output_log_permissions() {
+ # As a consequence of LP: #1918303
+ local oldver="$1" last_bad_ver="21.1-0ubuntu1"
+ dpkg --compare-versions "$oldver" le-nl "$last_bad_ver" || return 0
+
+ output_file="/var/log/cloud-init-output.log"
+ if [ -f "$output_file" ]; then
+ if getent group adm > /dev/null; then
+ chown root:adm $output_file
+ else
+ chown root $output_file
+ fi
+ chmod 640 $output_file
+ fi
+}
+
if [ "$1" = "configure" ]; then
if db_get cloud-init/datasources; then
@@ -358,6 +374,7 @@ EOF
cleanup_ureadahead "$2"
fix_lp1889555 "$2"
+ change_cloud_init_output_log_permissions "$2"
fi
#DEBHELPER#
diff --git a/doc/examples/part-handler.txt b/doc/examples/part-handler.txt
index a6e66415..1484e1a0 100644
--- a/doc/examples/part-handler.txt
+++ b/doc/examples/part-handler.txt
@@ -1,5 +1,4 @@
#part-handler
-# vi: syntax=python ts=4
def list_types():
# return a list of mime-types that are handled by this module
diff --git a/doc/rtd/topics/datasources/nocloud.rst b/doc/rtd/topics/datasources/nocloud.rst
index 0ca79102..edb41e2a 100644
--- a/doc/rtd/topics/datasources/nocloud.rst
+++ b/doc/rtd/topics/datasources/nocloud.rst
@@ -117,7 +117,7 @@ yaml formatted data in a file named ``network-config``. If found,
this file will override a ``network-interfaces`` file.
See an example below. Note specifically that this file does not
-have a top level ``network`` key as it it is already assumed to
+have a top level ``network`` key as it is already assumed to
be network configuration based on the filename.
.. code:: yaml
diff --git a/integration-requirements.txt b/integration-requirements.txt
index 6b596426..95891356 100644
--- a/integration-requirements.txt
+++ b/integration-requirements.txt
@@ -1,5 +1,5 @@
# PyPI requirements for cloud-init integration testing
# https://cloudinit.readthedocs.io/en/latest/topics/integration_tests.html
#
-pycloudlib @ git+https://github.com/canonical/pycloudlib.git@da8445325875674394ffd85aaefaa3d2d0e0020d
+pycloudlib @ git+https://github.com/canonical/pycloudlib.git@96b146ee1beb99b8e44e36525e18a9a20e00c3f2
pytest
diff --git a/tests/integration_tests/bugs/test_lp1912844.py b/tests/integration_tests/bugs/test_lp1912844.py
new file mode 100644
index 00000000..efafae50
--- /dev/null
+++ b/tests/integration_tests/bugs/test_lp1912844.py
@@ -0,0 +1,103 @@
+"""Integration test for LP: #1912844
+
+cloud-init should ignore OVS-internal interfaces when performing its own
+interface determination: these interfaces are handled fully by OVS, so
+cloud-init should never need to touch them.
+
+This test is a semi-synthetic reproducer for the bug. It uses a similar
+network configuration, tweaked slightly to DHCP in a way that will succeed even
+on "failed" boots. The exact bug doesn't reproduce with the NoCloud
+datasource, because it runs at init-local time (whereas the MAAS datasource,
+from the report, runs only at init (network) time): this means that the
+networking code runs before OVS creates its interfaces (which happens after
+init-local but, of course, before networking is up), and so doesn't generate
+the traceback that they cause. We work around this by calling
+``get_interfaces_by_mac` directly in the test code.
+"""
+import pytest
+
+from tests.integration_tests import random_mac_address
+
+MAC_ADDRESS = random_mac_address()
+
+NETWORK_CONFIG = """\
+bonds:
+ bond0:
+ interfaces:
+ - enp5s0
+ macaddress: {0}
+ mtu: 1500
+bridges:
+ ovs-br:
+ interfaces:
+ - bond0
+ macaddress: {0}
+ mtu: 1500
+ openvswitch: {{}}
+ dhcp4: true
+ethernets:
+ enp5s0:
+ mtu: 1500
+ set-name: enp5s0
+ match:
+ macaddress: {0}
+version: 2
+vlans:
+ ovs-br.100:
+ id: 100
+ link: ovs-br
+ mtu: 1500
+ ovs-br.200:
+ id: 200
+ link: ovs-br
+ mtu: 1500
+""".format(MAC_ADDRESS)
+
+
+SETUP_USER_DATA = """\
+#cloud-config
+packages:
+- openvswitch-switch
+"""
+
+
+@pytest.fixture
+def ovs_enabled_session_cloud(session_cloud):
+ """A session_cloud wrapper, to use an OVS-enabled image for tests.
+
+ This implementation is complicated by wanting to use ``session_cloud``s
+ snapshot cleanup/retention logic, to avoid having to reimplement that here.
+ """
+ old_snapshot_id = session_cloud.snapshot_id
+ with session_cloud.launch(
+ user_data=SETUP_USER_DATA,
+ ) as instance:
+ instance.instance.clean()
+ session_cloud.snapshot_id = instance.snapshot()
+
+ yield session_cloud
+
+ try:
+ session_cloud.delete_snapshot()
+ finally:
+ session_cloud.snapshot_id = old_snapshot_id
+
+
+@pytest.mark.lxd_vm
+def test_get_interfaces_by_mac_doesnt_traceback(ovs_enabled_session_cloud):
+ """Launch our OVS-enabled image and confirm the bug doesn't reproduce."""
+ launch_kwargs = {
+ "config_dict": {
+ "user.network-config": NETWORK_CONFIG,
+ "volatile.eth0.hwaddr": MAC_ADDRESS,
+ },
+ }
+ with ovs_enabled_session_cloud.launch(
+ launch_kwargs=launch_kwargs,
+ ) as client:
+ result = client.execute(
+ "python3 -c"
+ "'from cloudinit.net import get_interfaces_by_mac;"
+ "get_interfaces_by_mac()'"
+ )
+ assert result.ok
diff --git a/tests/integration_tests/clouds.py b/tests/integration_tests/clouds.py
index 9527a413..a6026309 100644
--- a/tests/integration_tests/clouds.py
+++ b/tests/integration_tests/clouds.py
@@ -1,8 +1,18 @@
# This file is part of cloud-init. See LICENSE file for license information.
from abc import ABC, abstractmethod
import logging
-
-from pycloudlib import EC2, GCE, Azure, OCI, LXDContainer, LXDVirtualMachine
+import os.path
+from uuid import UUID
+
+from pycloudlib import (
+ EC2,
+ GCE,
+ Azure,
+ OCI,
+ LXDContainer,
+ LXDVirtualMachine,
+ Openstack,
+)
from pycloudlib.lxd.instance import LXDInstance
import cloudinit
@@ -253,23 +263,32 @@ class _LxdIntegrationCloud(IntegrationCloud):
@staticmethod
def _mount_source(instance: LXDInstance):
- target_path = '/usr/lib/python3/dist-packages/cloudinit'
- format_variables = {
- 'name': instance.name,
- 'source_path': cloudinit.__path__[0],
- 'container_path': target_path,
- }
- log.info(
- 'Mounting source %(source_path)s directly onto LXD container/vm '
- 'named %(name)s at %(container_path)s',
- format_variables
- )
- command = (
- 'lxc config device add {name} host-cloud-init disk '
- 'source={source_path} '
- 'path={container_path}'
- ).format(**format_variables)
- subp(command.split())
+ cloudinit_path = cloudinit.__path__[0]
+ mounts = [
+ (cloudinit_path, '/usr/lib/python3/dist-packages/cloudinit'),
+ (os.path.join(cloudinit_path, '..', 'config', 'cloud.cfg.d'),
+ '/etc/cloud/cloud.cfg.d'),
+ (os.path.join(cloudinit_path, '..', 'templates'),
+ '/etc/cloud/templates'),
+ ]
+ for (n, (source_path, target_path)) in enumerate(mounts):
+ format_variables = {
+ 'name': instance.name,
+ 'source_path': os.path.realpath(source_path),
+ 'container_path': target_path,
+ 'idx': n,
+ }
+ log.info(
+ 'Mounting source %(source_path)s directly onto LXD'
+ ' container/VM named %(name)s at %(container_path)s',
+ format_variables
+ )
+ command = (
+ 'lxc config device add {name} host-cloud-init-{idx} disk '
+ 'source={source_path} '
+ 'path={container_path}'
+ ).format(**format_variables)
+ subp(command.split())
def _perform_launch(self, launch_kwargs):
launch_kwargs['inst_type'] = launch_kwargs.pop('instance_type', None)
@@ -311,3 +330,32 @@ class LxdVmCloud(_LxdIntegrationCloud):
self._profile_list = self.cloud_instance.build_necessary_profiles(
release)
return self._profile_list
+
+
+class OpenstackCloud(IntegrationCloud):
+ datasource = 'openstack'
+ integration_instance_cls = IntegrationInstance
+
+ def _get_cloud_instance(self):
+ if not integration_settings.OPENSTACK_NETWORK:
+ raise Exception(
+ 'OPENSTACK_NETWORK must be set to a valid Openstack network. '
+ 'If using the openstack CLI, try `openstack network list`'
+ )
+ return Openstack(
+ tag='openstack-integration-test',
+ network=integration_settings.OPENSTACK_NETWORK,
+ )
+
+ def _get_initial_image(self):
+ image = ImageSpecification.from_os_image()
+ try:
+ UUID(image.image_id)
+ except ValueError as e:
+ raise Exception(
+ 'When using Openstack, `OS_IMAGE` MUST be specified with '
+ 'a 36-character UUID image ID. Passing in a release name is '
+ 'not valid here.\n'
+ 'OS image id: {}'.format(image.image_id)
+ ) from e
+ return image.image_id
diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py
index 61ad8a71..6f4ce8d3 100644
--- a/tests/integration_tests/conftest.py
+++ b/tests/integration_tests/conftest.py
@@ -20,6 +20,7 @@ from tests.integration_tests.clouds import (
LxdVmCloud,
OciCloud,
_LxdIntegrationCloud,
+ OpenstackCloud,
)
from tests.integration_tests.instances import (
CloudInitSource,
@@ -38,11 +39,23 @@ platforms = {
'oci': OciCloud,
'lxd_container': LxdContainerCloud,
'lxd_vm': LxdVmCloud,
+ 'openstack': OpenstackCloud,
}
os_list = ["ubuntu"]
session_start_time = datetime.datetime.now().strftime('%y%m%d%H%M%S')
+XENIAL_LXD_VM_EXEC_MSG = """\
+The default xenial images do not support `exec` for LXD VMs.
+
+Specify an image known to work using:
+
+ OS_IMAGE=<image id>::ubuntu::xenial
+
+You can re-run specifically tests that require this by passing `-m
+lxd_use_exec` to pytest.
+"""
+
def pytest_runtest_setup(item):
"""Skip tests on unsupported clouds.
@@ -214,6 +227,16 @@ def _client(request, fixture_utils, session_cloud: IntegrationCloud):
if lxd_use_exec is not None:
if not isinstance(session_cloud, _LxdIntegrationCloud):
pytest.skip("lxd_use_exec requires LXD")
+ if isinstance(session_cloud, LxdVmCloud):
+ image_spec = ImageSpecification.from_os_image()
+ if image_spec.release == image_spec.image_id == "xenial":
+ # Why fail instead of skip? We expect that skipped tests will
+ # be run in a different one of our usual battery of test runs
+ # (e.g. LXD-only tests are skipped on EC2 but will run in our
+ # normal LXD test runs). This is not true of this test: it
+ # can't run in our usual xenial LXD VM test run, and it may not
+ # run anywhere else. A failure flags up this discrepancy.
+ pytest.fail(XENIAL_LXD_VM_EXEC_MSG)
launch_kwargs["execute_via_ssh"] = False
with session_cloud.launch(
diff --git a/tests/integration_tests/integration_settings.py b/tests/integration_tests/integration_settings.py
index 157d34ad..0703be58 100644
--- a/tests/integration_tests/integration_settings.py
+++ b/tests/integration_tests/integration_settings.py
@@ -21,6 +21,7 @@ RUN_UNSTABLE = False
# ec2
# gce
# oci
+# openstack
PLATFORM = 'lxd_container'
# The cloud-specific instance type to run. E.g., a1.medium on AWS
@@ -90,6 +91,13 @@ PUBLIC_SSH_KEY = None
KEYPAIR_NAME = None
##################################################################
+# OPENSTACK SETTINGS
+##################################################################
+# Network to use for Openstack. Should be one of the names/ids found
+# in `openstack network list`
+OPENSTACK_NETWORK = None
+
+##################################################################
# USER SETTINGS OVERRIDES
##################################################################
# Bring in any user-file defined settings
diff --git a/tests/integration_tests/modules/test_apt.py b/tests/integration_tests/modules/test_apt.py
index 5e3d474c..54711fc0 100644
--- a/tests/integration_tests/modules/test_apt.py
+++ b/tests/integration_tests/modules/test_apt.py
@@ -86,25 +86,11 @@ EXPECTED_REGEXES = [
r"deb-src http://badsecurity.ubuntu.com/ubuntu [a-z]+-security multiverse",
]
-TEST_KEYSERVER_KEY = """\
-pub rsa1024 2013-12-09 [SC]
- 7260 0DB1 5B8E 4C8B 1964 B868 038A CC97 C660 A937
-uid [ unknown] Launchpad PPA for Ryan Harper
-"""
+TEST_KEYSERVER_KEY = "7260 0DB1 5B8E 4C8B 1964 B868 038A CC97 C660 A937"
-TEST_PPA_KEY = """\
-/etc/apt/trusted.gpg.d/simplestreams-dev_ubuntu_trunk.gpg
----------------------------------------------------------
-pub rsa4096 2016-05-04 [SC]
- 3552 C902 B4DD F7BD 3842 1821 015D 28D7 4416 14D8
-uid [ unknown] Launchpad PPA for simplestreams-dev
-"""
+TEST_PPA_KEY = "3552 C902 B4DD F7BD 3842 1821 015D 28D7 4416 14D8"
-TEST_KEY = """\
-pub rsa4096 2016-03-04 [SC]
- 1FF0 D853 5EF7 E719 E5C8 1B9C 083D 06FB E4D3 04DF
-uid [ unknown] Launchpad PPA for cloud init development team
-"""
+TEST_KEY = "1FF0 D853 5EF7 E719 E5C8 1B9C 083D 06FB E4D3 04DF"
@pytest.mark.ci
@@ -214,29 +200,32 @@ class TestApt:
assert conf_exists is False
-DEFAULT_DATA = """\
+_DEFAULT_DATA = """\
#cloud-config
apt:
primary:
- arches:
- default
+ {uri}
security:
- arches:
- default
"""
+DEFAULT_DATA = _DEFAULT_DATA.format(uri='')
@pytest.mark.ubuntu
@pytest.mark.user_data(DEFAULT_DATA)
class TestDefaults:
- def test_primary(self, class_client: IntegrationInstance):
- """Test apt default primary sources.
+ @pytest.mark.openstack
+ def test_primary_on_openstack(self, class_client: IntegrationInstance):
+ """Test apt default primary source on openstack.
- Ported from
- tests/cloud_tests/testcases/modules/apt_configure_primary.py
+ When no uri is provided.
"""
+ zone = class_client.execute('cloud-init query v1.availability_zone')
sources_list = class_client.read_from_file('/etc/apt/sources.list')
- assert 'deb http://archive.ubuntu.com/ubuntu' in sources_list
+ assert '{}.clouds.archive.ubuntu.com'.format(zone) in sources_list
def test_security(self, class_client: IntegrationInstance):
"""Test apt default security sources.
@@ -253,6 +242,24 @@ class TestDefaults:
)
+DEFAULT_DATA_WITH_URI = _DEFAULT_DATA.format(
+ uri='uri: "http://something.random.invalid/ubuntu"'
+)
+
+
+@pytest.mark.user_data(DEFAULT_DATA_WITH_URI)
+def test_default_primary_with_uri(client: IntegrationInstance):
+ """Test apt default primary sources.
+
+ Ported from
+ tests/cloud_tests/testcases/modules/apt_configure_primary.py
+ """
+ sources_list = client.read_from_file('/etc/apt/sources.list')
+ assert 'archive.ubuntu.com' not in sources_list
+
+ assert 'something.random.invalid' in sources_list
+
+
DISABLED_DATA = """\
#cloud-config
apt:
diff --git a/tests/integration_tests/modules/test_set_password.py b/tests/integration_tests/modules/test_set_password.py
index b13f76fb..d7cf91a5 100644
--- a/tests/integration_tests/modules/test_set_password.py
+++ b/tests/integration_tests/modules/test_set_password.py
@@ -116,6 +116,30 @@ class Mixin:
# Which are not the same
assert shadow_users["harry"] != shadow_users["dick"]
+ def test_random_passwords_not_stored_in_cloud_init_output_log(
+ self, class_client
+ ):
+ """We should not emit passwords to the in-instance log file.
+
+ LP: #1918303
+ """
+ cloud_init_output = class_client.read_from_file(
+ "/var/log/cloud-init-output.log"
+ )
+ assert "dick:" not in cloud_init_output
+ assert "harry:" not in cloud_init_output
+
+ def test_random_passwords_emitted_to_serial_console(self, class_client):
+ """We should emit passwords to the serial console. (LP: #1918303)"""
+ try:
+ console_log = class_client.instance.console_log()
+ except NotImplementedError:
+ # Assume that an exception here means that we can't use the console
+ # log
+ pytest.skip("NotImplementedError when requesting console log")
+ assert "dick:" in console_log
+ assert "harry:" in console_log
+
def test_explicit_password_set_correctly(self, class_client):
"""Test that an explicitly-specified password is set correctly."""
shadow_users, _ = self._fetch_and_parse_etc_shadow(class_client)
diff --git a/tests/integration_tests/modules/test_users_groups.py b/tests/integration_tests/modules/test_users_groups.py
index ee08d87b..bcb17b7f 100644
--- a/tests/integration_tests/modules/test_users_groups.py
+++ b/tests/integration_tests/modules/test_users_groups.py
@@ -1,16 +1,16 @@
-"""Integration test for the user_groups module.
-
-This test specifies a number of users and groups via user-data, and confirms
-that they have been configured correctly in the system under test.
+"""Integration tests for the user_groups module.
TODO:
-* This test assumes that the "ubuntu" user will be created when "default" is
+* This module assumes that the "ubuntu" user will be created when "default" is
specified; this will need modification to run on other OSes.
"""
import re
import pytest
+from tests.integration_tests.clouds import ImageSpecification
+from tests.integration_tests.instances import IntegrationInstance
+
USER_DATA = """\
#cloud-config
@@ -45,6 +45,12 @@ AHWYPYb2FT.lbioDm2RrkJPb9BZMN1O/
@pytest.mark.ci
@pytest.mark.user_data(USER_DATA)
class TestUsersGroups:
+ """Test users and groups.
+
+ This test specifies a number of users and groups via user-data, and
+ confirms that they have been configured correctly in the system under test.
+ """
+
@pytest.mark.ubuntu
@pytest.mark.parametrize(
"getent_args,regex",
@@ -86,3 +92,32 @@ class TestUsersGroups:
_, groups_str = output.split(":", maxsplit=1)
groups = groups_str.split()
assert "secret" in groups
+
+
+@pytest.mark.user_data(USER_DATA)
+def test_sudoers_includedir(client: IntegrationInstance):
+ """Ensure we don't add additional #includedir to sudoers.
+
+ Newer versions of /etc/sudoers will use @includedir rather than
+ #includedir. Ensure we handle that properly and don't include an
+ additional #includedir when one isn't warranted.
+
+ https://github.com/canonical/cloud-init/pull/783
+ """
+ if ImageSpecification.from_os_image().release in [
+ 'xenial', 'bionic', 'focal'
+ ]:
+ raise pytest.skip(
+ 'Test requires version of sudo installed on groovy and later'
+ )
+ client.execute("sed -i 's/#include/@include/g' /etc/sudoers")
+
+ sudoers = client.read_from_file('/etc/sudoers')
+ if '@includedir /etc/sudoers.d' not in sudoers:
+ client.execute("echo '@includedir /etc/sudoers.d' >> /etc/sudoers")
+ client.instance.clean()
+ client.restart()
+ sudoers = client.read_from_file('/etc/sudoers')
+
+ assert '#includedir' not in sudoers
+ assert sudoers.count('includedir /etc/sudoers.d') == 1
diff --git a/tests/integration_tests/test_logging.py b/tests/integration_tests/test_logging.py
new file mode 100644
index 00000000..b31a0434
--- /dev/null
+++ b/tests/integration_tests/test_logging.py
@@ -0,0 +1,22 @@
+"""Integration tests relating to cloud-init's logging."""
+
+
+class TestVarLogCloudInitOutput:
+ """Integration tests relating to /var/log/cloud-init-output.log."""
+
+ def test_var_log_cloud_init_output_not_world_readable(self, client):
+ """
+ The log can contain sensitive data, it shouldn't be world-readable.
+
+ LP: #1918303
+ """
+ # Check the file exists
+ assert client.execute("test -f /var/log/cloud-init-output.log").ok
+
+ # Check its permissions are as we expect
+ perms, user, group = client.execute(
+ "stat -c %a:%U:%G /var/log/cloud-init-output.log"
+ ).split(":")
+ assert "640" == perms
+ assert "root" == user
+ assert "adm" == group
diff --git a/tests/unittests/test_datasource/test_aliyun.py b/tests/unittests/test_datasource/test_aliyun.py
index eb2828d5..cab1ac2b 100644
--- a/tests/unittests/test_datasource/test_aliyun.py
+++ b/tests/unittests/test_datasource/test_aliyun.py
@@ -7,6 +7,7 @@ from unittest import mock
from cloudinit import helpers
from cloudinit.sources import DataSourceAliYun as ay
+from cloudinit.sources.DataSourceEc2 import convert_ec2_metadata_network_config
from cloudinit.tests import helpers as test_helpers
DEFAULT_METADATA = {
@@ -183,6 +184,35 @@ class TestAliYunDatasource(test_helpers.HttprettyTestCase):
self.assertEqual(ay.parse_public_keys(public_keys),
public_keys['key-pair-0']['openssh-key'])
+ def test_route_metric_calculated_without_device_number(self):
+ """Test that route-metric code works without `device-number`
+
+ `device-number` is part of EC2 metadata, but not supported on aliyun.
+ Attempting to access it will raise a KeyError.
+
+ LP: #1917875
+ """
+ netcfg = convert_ec2_metadata_network_config(
+ {"interfaces": {"macs": {
+ "06:17:04:d7:26:09": {
+ "interface-id": "eni-e44ef49e",
+ },
+ "06:17:04:d7:26:08": {
+ "interface-id": "eni-e44ef49f",
+ }
+ }}},
+ macs_to_nics={
+ '06:17:04:d7:26:09': 'eth0',
+ '06:17:04:d7:26:08': 'eth1',
+ }
+ )
+
+ met0 = netcfg['ethernets']['eth0']['dhcp4-overrides']['route-metric']
+ met1 = netcfg['ethernets']['eth1']['dhcp4-overrides']['route-metric']
+
+ # route-metric numbers should be 100 apart
+ assert 100 == abs(met0 - met1)
+
class TestIsAliYun(test_helpers.CiTestCase):
ALIYUN_PRODUCT = 'Alibaba Cloud ECS'
diff --git a/tests/unittests/test_datasource/test_azure.py b/tests/unittests/test_datasource/test_azure.py
index f597c723..dedebeb1 100644
--- a/tests/unittests/test_datasource/test_azure.py
+++ b/tests/unittests/test_datasource/test_azure.py
@@ -408,7 +408,9 @@ class TestGetMetadataFromIMDS(HttprettyTestCase):
def setUp(self):
super(TestGetMetadataFromIMDS, self).setUp()
- self.network_md_url = dsaz.IMDS_URL + "instance?api-version=2019-06-01"
+ self.network_md_url = "{}/instance?api-version=2019-06-01".format(
+ dsaz.IMDS_URL
+ )
@mock.patch(MOCKPATH + 'readurl')
@mock.patch(MOCKPATH + 'EphemeralDHCPv4', autospec=True)
@@ -518,7 +520,7 @@ class TestGetMetadataFromIMDS(HttprettyTestCase):
"""Return empty dict when IMDS network metadata is absent."""
httpretty.register_uri(
httpretty.GET,
- dsaz.IMDS_URL + 'instance?api-version=2017-12-01',
+ dsaz.IMDS_URL + '/instance?api-version=2017-12-01',
body={}, status=404)
m_net_is_up.return_value = True # skips dhcp
@@ -1877,6 +1879,40 @@ scbus-1 on xpt0 bus 0
ssh_keys = dsrc.get_public_ssh_keys()
self.assertEqual(ssh_keys, ['key2'])
+ @mock.patch(MOCKPATH + 'get_metadata_from_imds')
+ def test_imds_api_version_wanted_nonexistent(
+ self,
+ m_get_metadata_from_imds):
+ def get_metadata_from_imds_side_eff(*args, **kwargs):
+ if kwargs['api_version'] == dsaz.IMDS_VER_WANT:
+ raise url_helper.UrlError("No IMDS version", code=400)
+ return NETWORK_METADATA
+ m_get_metadata_from_imds.side_effect = get_metadata_from_imds_side_eff
+ sys_cfg = {'datasource': {'Azure': {'apply_network_config': True}}}
+ odata = {'HostName': "myhost", 'UserName': "myuser"}
+ data = {
+ 'ovfcontent': construct_valid_ovf_env(data=odata),
+ 'sys_cfg': sys_cfg
+ }
+ dsrc = self._get_ds(data)
+ dsrc.get_data()
+ self.assertIsNotNone(dsrc.metadata)
+ self.assertTrue(dsrc.failed_desired_api_version)
+
+ @mock.patch(
+ MOCKPATH + 'get_metadata_from_imds', return_value=NETWORK_METADATA)
+ def test_imds_api_version_wanted_exists(self, m_get_metadata_from_imds):
+ sys_cfg = {'datasource': {'Azure': {'apply_network_config': True}}}
+ odata = {'HostName': "myhost", 'UserName': "myuser"}
+ data = {
+ 'ovfcontent': construct_valid_ovf_env(data=odata),
+ 'sys_cfg': sys_cfg
+ }
+ dsrc = self._get_ds(data)
+ dsrc.get_data()
+ self.assertIsNotNone(dsrc.metadata)
+ self.assertFalse(dsrc.failed_desired_api_version)
+
class TestAzureBounce(CiTestCase):
@@ -2657,7 +2693,7 @@ class TestPreprovisioningHotAttachNics(CiTestCase):
@mock.patch(MOCKPATH + 'DataSourceAzure.wait_for_link_up')
@mock.patch('cloudinit.sources.helpers.netlink.wait_for_nic_attach_event')
@mock.patch('cloudinit.sources.net.find_fallback_nic')
- @mock.patch(MOCKPATH + 'get_metadata_from_imds')
+ @mock.patch(MOCKPATH + 'DataSourceAzure.get_imds_data_with_api_fallback')
@mock.patch(MOCKPATH + 'EphemeralDHCPv4')
@mock.patch(MOCKPATH + 'DataSourceAzure._wait_for_nic_detach')
@mock.patch('os.path.isfile')
diff --git a/tests/unittests/test_datasource/test_configdrive.py b/tests/unittests/test_datasource/test_configdrive.py
index 6f830cc6..2e2b7847 100644
--- a/tests/unittests/test_datasource/test_configdrive.py
+++ b/tests/unittests/test_datasource/test_configdrive.py
@@ -494,6 +494,10 @@ class TestConfigDriveDataSource(CiTestCase):
self.assertEqual('config-disk (/dev/anything)', cfg_ds.subplatform)
+@mock.patch(
+ "cloudinit.net.is_openvswitch_internal_interface",
+ mock.Mock(return_value=False)
+)
class TestNetJson(CiTestCase):
def setUp(self):
super(TestNetJson, self).setUp()
@@ -654,6 +658,10 @@ class TestNetJson(CiTestCase):
self.assertEqual(out_data, conv_data)
+@mock.patch(
+ "cloudinit.net.is_openvswitch_internal_interface",
+ mock.Mock(return_value=False)
+)
class TestConvertNetworkData(CiTestCase):
with_logs = True
diff --git a/tests/unittests/test_handler/test_handler_locale.py b/tests/unittests/test_handler/test_handler_locale.py
index 47e7d804..15fe7b23 100644
--- a/tests/unittests/test_handler/test_handler_locale.py
+++ b/tests/unittests/test_handler/test_handler_locale.py
@@ -44,6 +44,29 @@ class TestLocale(t_help.FilesystemMockingTestCase):
cc = cloud.Cloud(ds, paths, {}, d, None)
return cc
+ def test_set_locale_arch(self):
+ locale = 'en_GB.UTF-8'
+ locale_configfile = '/etc/invalid-locale-path'
+ cfg = {
+ 'locale': locale,
+ 'locale_configfile': locale_configfile,
+ }
+ cc = self._get_cloud('arch')
+
+ with mock.patch('cloudinit.distros.arch.subp.subp') as m_subp:
+ with mock.patch('cloudinit.distros.arch.LOG.warning') as m_LOG:
+ cc_locale.handle('cc_locale', cfg, cc, LOG, [])
+ m_LOG.assert_called_with('Invalid locale_configfile %s, '
+ 'only supported value is '
+ '/etc/locale.conf',
+ locale_configfile)
+
+ contents = util.load_file(cc.distro.locale_gen_fn)
+ self.assertIn('%s UTF-8' % locale, contents)
+ m_subp.assert_called_with(['localectl',
+ 'set-locale',
+ locale], capture=False)
+
def test_set_locale_sles(self):
cfg = {
diff --git a/tests/unittests/test_net.py b/tests/unittests/test_net.py
index 38d934d4..cb636f41 100644
--- a/tests/unittests/test_net.py
+++ b/tests/unittests/test_net.py
@@ -2933,6 +2933,10 @@ iface eth1 inet dhcp
self.assertEqual(0, mock_settle.call_count)
+@mock.patch(
+ "cloudinit.net.is_openvswitch_internal_interface",
+ mock.Mock(return_value=False)
+)
class TestRhelSysConfigRendering(CiTestCase):
with_logs = True
@@ -3620,6 +3624,10 @@ USERCTL=no
expected, self._render_and_read(network_config=v2data))
+@mock.patch(
+ "cloudinit.net.is_openvswitch_internal_interface",
+ mock.Mock(return_value=False)
+)
class TestOpenSuseSysConfigRendering(CiTestCase):
with_logs = True
@@ -5037,6 +5045,10 @@ class TestNetRenderers(CiTestCase):
self.assertTrue(result)
+@mock.patch(
+ "cloudinit.net.is_openvswitch_internal_interface",
+ mock.Mock(return_value=False)
+)
class TestGetInterfaces(CiTestCase):
_data = {'bonds': ['bond1'],
'bridges': ['bridge1'],
@@ -5186,6 +5198,10 @@ class TestInterfaceHasOwnMac(CiTestCase):
self.assertFalse(interface_has_own_mac("eth0"))
+@mock.patch(
+ "cloudinit.net.is_openvswitch_internal_interface",
+ mock.Mock(return_value=False)
+)
class TestGetInterfacesByMac(CiTestCase):
_data = {'bonds': ['bond1'],
'bridges': ['bridge1'],
@@ -5342,6 +5358,10 @@ class TestInterfacesSorting(CiTestCase):
['enp0s3', 'enp0s8', 'enp0s13', 'enp1s2', 'enp2s0', 'enp2s3'])
+@mock.patch(
+ "cloudinit.net.is_openvswitch_internal_interface",
+ mock.Mock(return_value=False)
+)
class TestGetIBHwaddrsByInterface(CiTestCase):
_ib_addr = '80:00:00:28:fe:80:00:00:00:00:00:00:00:11:22:03:00:33:44:56'
diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py
index 857629f1..e5292001 100644
--- a/tests/unittests/test_util.py
+++ b/tests/unittests/test_util.py
@@ -572,6 +572,10 @@ class TestMultiLog(helpers.FilesystemMockingTestCase):
util.multi_log(logged_string)
self.assertEqual(logged_string, self.stdout.getvalue())
+ def test_logs_dont_go_to_stdout_if_fallback_to_stdout_is_false(self):
+ util.multi_log('something', fallback_to_stdout=False)
+ self.assertEqual('', self.stdout.getvalue())
+
def test_logs_go_to_log_if_given(self):
log = mock.MagicMock()
logged_string = 'something very important'
diff --git a/tools/.github-cla-signers b/tools/.github-cla-signers
index 689d7902..5c57acac 100644
--- a/tools/.github-cla-signers
+++ b/tools/.github-cla-signers
@@ -14,12 +14,14 @@ dankenigsberg
dermotbradley
dhensby
eandersson
+eb3095
emmanuelthome
izzyleung
johnsonshi
jordimassaguerpla
jqueuniet
jsf9k
+klausenbusk
landon912
lucasmoura
lungj
@@ -37,6 +39,7 @@ slyon
smoser
sshedi
TheRealFalcon
+taoyama
tnt-dev
tomponline
tsanghan
diff --git a/tox.ini b/tox.ini
index 0e2eae46..3158ebd5 100644
--- a/tox.ini
+++ b/tox.ini
@@ -147,13 +147,13 @@ deps =
[testenv:integration-tests]
basepython = python3
commands = {envpython} -m pytest --log-cli-level=INFO {posargs:tests/integration_tests}
-passenv = CLOUD_INIT_* SSH_AUTH_SOCK
+passenv = CLOUD_INIT_* SSH_AUTH_SOCK OS_*
deps =
-r{toxinidir}/integration-requirements.txt
[testenv:integration-tests-ci]
commands = {envpython} -m pytest --log-cli-level=INFO {posargs:tests/integration_tests}
-passenv = CLOUD_INIT_* SSH_AUTH_SOCK
+passenv = CLOUD_INIT_* SSH_AUTH_SOCK OS_*
deps =
-r{toxinidir}/integration-requirements.txt
setenv =
@@ -174,6 +174,7 @@ markers =
gce: test will only run on GCE platform
azure: test will only run on Azure platform
oci: test will only run on OCI platform
+ openstack: test will only run on openstack
lxd_config_dict: set the config_dict passed on LXD instance creation
lxd_container: test will only run in LXD container
lxd_use_exec: `execute` will use `lxc exec` instead of SSH