summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChad Smith <chad.smith@canonical.com>2018-03-28 12:29:04 -0600
committerChad Smith <chad.smith@canonical.com>2018-03-28 12:29:04 -0600
commitcf3eaed2e01062f9b5d47042d7a76b092970e0cf (patch)
tree53f7c52c5a76bb586da0483699fd6d188e72f457
parent9f159f3a55a7bba7868e03d9cccd898678381f03 (diff)
parent8caa3bcf8f2c5b3a448b9d892d4cf53ed8db9be9 (diff)
downloadvyos-cloud-init-cf3eaed2e01062f9b5d47042d7a76b092970e0cf.tar.gz
vyos-cloud-init-cf3eaed2e01062f9b5d47042d7a76b092970e0cf.zip
merge from master at 18.2
-rw-r--r--.pylintrc12
-rw-r--r--ChangeLog110
-rw-r--r--cloudinit/apport.py6
-rw-r--r--cloudinit/cloud.py5
-rw-r--r--cloudinit/cmd/main.py35
-rw-r--r--cloudinit/cmd/tests/test_clean.py3
-rw-r--r--cloudinit/cmd/tests/test_main.py161
-rw-r--r--cloudinit/cmd/tests/test_status.py3
-rw-r--r--cloudinit/config/cc_keys_to_console.py4
-rw-r--r--cloudinit/config/cc_puppet.py59
-rw-r--r--cloudinit/config/cc_resizefs.py22
-rw-r--r--cloudinit/config/cc_runcmd.py6
-rw-r--r--cloudinit/config/cc_salt_minion.py85
-rw-r--r--cloudinit/config/cc_set_hostname.py41
-rw-r--r--cloudinit/config/cc_snap.py230
-rw-r--r--cloudinit/config/cc_snap_config.py7
-rw-r--r--cloudinit/config/cc_snappy.py8
-rwxr-xr-xcloudinit/config/cc_ssh_authkey_fingerprints.py9
-rw-r--r--cloudinit/config/cc_ubuntu_advantage.py173
-rw-r--r--cloudinit/config/tests/test_snap.py490
-rw-r--r--cloudinit/config/tests/test_ubuntu_advantage.py269
-rw-r--r--cloudinit/distros/arch.py5
-rw-r--r--cloudinit/distros/freebsd.py6
-rw-r--r--cloudinit/distros/opensuse.py5
-rw-r--r--cloudinit/ec2_utils.py6
-rwxr-xr-xcloudinit/net/cmdline.py24
-rw-r--r--cloudinit/net/netplan.py35
-rw-r--r--cloudinit/net/network_state.py12
-rw-r--r--cloudinit/settings.py2
-rw-r--r--cloudinit/sources/DataSourceAliYun.py2
-rw-r--r--cloudinit/sources/DataSourceAzure.py33
-rw-r--r--cloudinit/sources/DataSourceCloudSigma.py2
-rw-r--r--cloudinit/sources/DataSourceConfigDrive.py10
-rw-r--r--cloudinit/sources/DataSourceGCE.py17
-rw-r--r--cloudinit/sources/DataSourceHetzner.py106
-rw-r--r--cloudinit/sources/DataSourceIBMCloud.py325
-rw-r--r--cloudinit/sources/DataSourceOVF.py21
-rw-r--r--cloudinit/sources/DataSourceOpenNebula.py109
-rw-r--r--cloudinit/sources/DataSourceScaleway.py8
-rw-r--r--cloudinit/sources/__init__.py21
-rw-r--r--cloudinit/sources/helpers/hetzner.py26
-rw-r--r--cloudinit/sources/tests/test_init.py98
-rw-r--r--cloudinit/stages.py3
-rw-r--r--cloudinit/subp.py57
-rw-r--r--cloudinit/tests/helpers.py54
-rw-r--r--cloudinit/tests/test_subp.py61
-rw-r--r--cloudinit/tests/test_util.py169
-rw-r--r--cloudinit/url_helper.py24
-rw-r--r--cloudinit/util.py139
-rw-r--r--cloudinit/version.py2
-rw-r--r--config/cloud.cfg.tmpl12
-rw-r--r--doc/examples/cloud-config-chef.txt4
-rw-r--r--doc/rtd/conf.py1
-rw-r--r--doc/rtd/topics/capabilities.rst14
-rw-r--r--doc/rtd/topics/debugging.rst57
-rw-r--r--doc/rtd/topics/modules.rst2
-rw-r--r--doc/rtd/topics/network-config.rst4
-rw-r--r--doc/rtd/topics/tests.rst20
-rw-r--r--packages/debian/control.in3
-rw-r--r--tests/cloud_tests/bddeb.py2
-rw-r--r--tests/cloud_tests/platforms/ec2/__init__.py0
-rw-r--r--tests/cloud_tests/platforms/lxd/__init__.py0
-rw-r--r--tests/cloud_tests/platforms/lxd/platform.py4
-rw-r--r--tests/cloud_tests/platforms/nocloudkvm/__init__.py0
-rw-r--r--tests/cloud_tests/platforms/nocloudkvm/instance.py2
-rw-r--r--tests/cloud_tests/platforms/nocloudkvm/platform.py4
-rw-r--r--tests/cloud_tests/platforms/platforms.py14
-rw-r--r--tests/cloud_tests/releases.yaml3
-rw-r--r--tests/cloud_tests/testcases.yaml3
-rw-r--r--tests/cloud_tests/testcases/__init__.py3
-rw-r--r--tests/cloud_tests/testcases/base.py173
-rw-r--r--tests/cloud_tests/testcases/main/command_output_simple.py17
-rw-r--r--tests/cloud_tests/testcases/modules/salt_minion.py10
-rw-r--r--tests/cloud_tests/testcases/modules/salt_minion.yaml10
-rw-r--r--tests/cloud_tests/testcases/modules/snap.py16
-rw-r--r--tests/cloud_tests/testcases/modules/snap.yaml18
-rw-r--r--tests/cloud_tests/testcases/modules/snappy.py2
-rw-r--r--tests/cloud_tests/util.py6
-rw-r--r--tests/cloud_tests/verify.py11
-rw-r--r--tests/data/mount_parse_ext.txt19
-rw-r--r--tests/data/mount_parse_zfs.txt21
-rw-r--r--tests/data/zpool_status_simple.txt10
-rw-r--r--tests/unittests/test_datasource/test_azure.py37
-rw-r--r--tests/unittests/test_datasource/test_common.py4
-rw-r--r--tests/unittests/test_datasource/test_gce.py20
-rw-r--r--tests/unittests/test_datasource/test_hetzner.py117
-rw-r--r--tests/unittests/test_datasource/test_ibmcloud.py262
-rw-r--r--tests/unittests/test_datasource/test_opennebula.py266
-rw-r--r--tests/unittests/test_ds_identify.py167
-rw-r--r--tests/unittests/test_handler/test_handler_apt_source_v1.py3
-rw-r--r--tests/unittests/test_handler/test_handler_bootcmd.py19
-rw-r--r--tests/unittests/test_handler/test_handler_ntp.py18
-rw-r--r--tests/unittests/test_handler/test_handler_resizefs.py72
-rw-r--r--tests/unittests/test_handler/test_handler_runcmd.py14
-rw-r--r--tests/unittests/test_handler/test_handler_set_hostname.py57
-rw-r--r--tests/unittests/test_handler/test_schema.py35
-rw-r--r--tests/unittests/test_net.py139
-rw-r--r--tests/unittests/test_util.py135
-rwxr-xr-xtools/ds-identify127
-rwxr-xr-xtools/pipremove14
-rwxr-xr-xtools/run-centos91
-rw-r--r--tox.ini9
102 files changed, 4560 insertions, 631 deletions
diff --git a/.pylintrc b/.pylintrc
index 05a086d9..0bdfa59d 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -46,7 +46,17 @@ reports=no
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis. It
# supports qualified module names, as well as Unix pattern matching.
-ignored-modules=six.moves,pkg_resources,httplib,http.client,paramiko,simplestreams
+ignored-modules=
+ http.client,
+ httplib,
+ pkg_resources,
+ six.moves,
+ # cloud_tests requirements.
+ boto3,
+ botocore,
+ paramiko,
+ pylxd,
+ simplestreams
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
diff --git a/ChangeLog b/ChangeLog
index 31c2dcbb..daa7ccf6 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,113 @@
+18.2:
+ - Hetzner: Exit early if dmi system-manufacturer is not Hetzner.
+ - Add missing dependency on isc-dhcp-client to trunk ubuntu packaging.
+ (LP: #1759307)
+ - FreeBSD: resizefs module now able to handle zfs/zpool.
+ [Dominic Schlegel] (LP: #1721243)
+ - cc_puppet: Revert regression of puppet creating ssl and ssl_cert dirs
+ - Enable IBMCloud datasource in settings.py.
+ - IBMCloud: Initial IBM Cloud datasource.
+ - tests: remove jsonschema from xenial tox environment.
+ - tests: Fix newly added schema unit tests to skip if no jsonschema.
+ - ec2: Adjust ec2 datasource after exception_cb change.
+ - Reduce AzurePreprovisioning HTTP timeouts.
+ [Douglas Jordan] (LP: #1752977)
+ - Revert the logic of exception_cb in read_url.
+ [Kurt Garloff] (LP: #1702160, #1298921)
+ - ubuntu-advantage: Add new config module to support
+ ubuntu-advantage-tools
+ - Handle global dns entries in netplan (LP: #1750884)
+ - Identify OpenTelekomCloud Xen as OpenStack DS.
+ [Kurt Garloff] (LP: #1756471)
+ - datasources: fix DataSource subclass get_hostname method signature
+ (LP: #1757176)
+ - OpenNebula: Update network to return v2 config rather than ENI.
+ [Akihiko Ota]
+ - Add Hetzner Cloud DataSource
+ - net: recognize iscsi root cases without ip= on kernel command line.
+ (LP: #1752391)
+ - tests: fix flakes warning for unused variable
+ - tests: patch leaked stderr messages from snap unit tests
+ - cc_snap: Add new module to install and configure snapd and snap
+ packages.
+ - tests: Make pylint happy and fix python2.6 uses of assertRaisesRegex.
+ - netplan: render bridge port-priority values (LP: #1735821)
+ - util: Fix subp regression. Allow specifying subp command as a string.
+ (LP: #1755965)
+ - doc: fix all warnings issued by 'tox -e doc'
+ - FreeBSD: Set hostname to FQDN. [Dominic Schlegel] (LP: #1753499)
+ - tests: fix run_tree and bddeb
+ - tests: Fix some warnings in tests that popped up with newer python.
+ - set_hostname: When present in metadata, set it before network bringup.
+ (LP: #1746455)
+ - tests: Centralize and re-use skipTest based on json schema presense.
+ - This commit fixes get_hostname on the AzureDataSource.
+ [Douglas Jordan] (LP: #1754495)
+ - shellify: raise TypeError on bad input.
+ - Make salt minion module work on FreeBSD.
+ [Dominic Schlegel] (LP: #1721503)
+ - Simplify some comparisions. [Rémy Léone]
+ - Change some list creation and population to literal. [Rémy Léone]
+ - GCE: fix reading of user-data that is not base64 encoded. (LP: #1752711)
+ - doc: fix chef install from apt packages example in RTD.
+ - Implement puppet 4 support [Romanos Skiadas] (LP: #1446804)
+ - subp: Fix subp usage with non-ascii characters when no system locale.
+ (LP: #1751051)
+ - salt: configure grains in grains file rather than in minion config.
+ [Daniel Wallace]
+
+18.1:
+ - OVF: Fix VMware support for 64-bit platforms. [Sankar Tanguturi]
+ - ds-identify: Fix searching for iso9660 OVF cdroms. (LP: #1749980)
+ - SUSE: Fix groups used for ownership of cloud-init.log [Robert Schweikert]
+ - ds-identify: check /writable/system-data/ for nocloud seed.
+ (LP: #1747070)
+ - tests: run nosetests in cloudinit/ directory, fix py26 fallout.
+ - tools: run-centos: git clone rather than tar.
+ - tests: add support for logs with lxd from snap and future lxd 3.
+ (LP: #1745663)
+ - EC2: Fix get_instance_id called against cached datasource pickle.
+ (LP: #1748354)
+ - cli: fix cloud-init status to report running when before result.json
+ (LP: #1747965)
+ - net: accept network-config in netplan format for renaming interfaces
+ (LP: #1709715)
+ - Fix ssh keys validation in ssh_util [Tatiana Kholkina]
+ - docs: Update RTD content for cloud-init subcommands.
+ - OVF: Extend well-known labels to include OVFENV. (LP: #1698669)
+ - Fix potential cases of uninitialized variables. (LP: #1744796)
+ - tests: Collect script output as binary, collect systemd journal, fix lxd.
+ - HACKING.rst: mention setting user name and email via git config.
+ - Azure VM Preprovisioning support. [Douglas Jordan] (LP: #1734991)
+ - tools/read-version: Fix read-version when in a git worktree.
+ - docs: Fix typos in docs and one debug message. [Florian Grignon]
+ - btrfs: support resizing if root is mounted ro.
+ [Robert Schweikert] (LP: #1734787)
+ - OpenNebula: Improve network configuration support.
+ [Akihiko Ota] (LP: #1719157, #1716397, #1736750)
+ - tests: Fix EC2 Platform to return console output as bytes.
+ - tests: Fix attempted use of /run in a test case.
+ - GCE: Improvements and changes to ssh key behavior for default user.
+ [Max Illfelder] (LP: #1670456, #1707033, #1707037, #1707039)
+ - subp: make ProcessExecutionError have expected types in stderr, stdout.
+ - tests: when querying ntp server, do not do dns resolution.
+ - Recognize uppercase vfat disk labels [James Penick] (LP: #1598783)
+ - tests: remove zesty as supported OS to test [Joshua Powers]
+ - Do not log warning on config files that represent None. (LP: #1742479)
+ - tests: Use git hash pip dependency format for pylxd.
+ - tests: add integration requirements text file [Joshua Powers]
+ - MAAS: add check_instance_id based off oauth tokens. (LP: #1712680)
+ - tests: update apt sources list test [Joshua Powers]
+ - tests: clean up image properties [Joshua Powers]
+ - tests: rename test ssh keys to avoid appearance of leaking private keys.
+ [Joshua Powers]
+ - tests: Enable AWS EC2 Integration Testing [Joshua Powers]
+ - cli: cloud-init clean handles symlinks (LP: #1741093)
+ - SUSE: Add a basic test of network config rendering. [Robert Schweikert]
+ - Azure: Only bounce network when necessary. (LP: #1722668)
+ - lint: Fix lints seen by pylint version 1.8.1.
+ - cli: Fix error in cloud-init modules --mode=init. (LP: #1736600)
+
17.2:
- ds-identify: failure in NoCloud due to unset variable usage.
(LP: #1737704)
diff --git a/cloudinit/apport.py b/cloudinit/apport.py
index 221f341c..618b0160 100644
--- a/cloudinit/apport.py
+++ b/cloudinit/apport.py
@@ -14,9 +14,9 @@ except ImportError:
KNOWN_CLOUD_NAMES = [
'Amazon - Ec2', 'AliYun', 'AltCloud', 'Azure', 'Bigstep', 'CloudSigma',
- 'CloudStack', 'DigitalOcean', 'GCE - Google Compute Engine', 'MAAS',
- 'NoCloud', 'OpenNebula', 'OpenStack', 'OVF', 'Scaleway', 'SmartOS',
- 'VMware', 'Other']
+ 'CloudStack', 'DigitalOcean', 'GCE - Google Compute Engine',
+ 'Hetzner Cloud', 'MAAS', 'NoCloud', 'OpenNebula', 'OpenStack', 'OVF',
+ 'Scaleway', 'SmartOS', 'VMware', 'Other']
# Potentially clear text collected logs
CLOUDINIT_LOG = '/var/log/cloud-init.log'
diff --git a/cloudinit/cloud.py b/cloudinit/cloud.py
index ba616781..6d12c437 100644
--- a/cloudinit/cloud.py
+++ b/cloudinit/cloud.py
@@ -78,8 +78,9 @@ class Cloud(object):
def get_locale(self):
return self.datasource.get_locale()
- def get_hostname(self, fqdn=False):
- return self.datasource.get_hostname(fqdn=fqdn)
+ def get_hostname(self, fqdn=False, metadata_only=False):
+ return self.datasource.get_hostname(
+ fqdn=fqdn, metadata_only=metadata_only)
def device_name_to_device(self, name):
return self.datasource.device_name_to_device(name)
diff --git a/cloudinit/cmd/main.py b/cloudinit/cmd/main.py
index d2f1b778..3f2dbb93 100644
--- a/cloudinit/cmd/main.py
+++ b/cloudinit/cmd/main.py
@@ -40,6 +40,7 @@ from cloudinit.settings import (PER_INSTANCE, PER_ALWAYS, PER_ONCE,
from cloudinit import atomic_helper
+from cloudinit.config import cc_set_hostname
from cloudinit.dhclient_hook import LogDhclient
@@ -215,12 +216,10 @@ def main_init(name, args):
if args.local:
deps = [sources.DEP_FILESYSTEM]
- early_logs = []
- early_logs.append(
- attempt_cmdline_url(
- path=os.path.join("%s.d" % CLOUD_CONFIG,
- "91_kernel_cmdline_url.cfg"),
- network=not args.local))
+ early_logs = [attempt_cmdline_url(
+ path=os.path.join("%s.d" % CLOUD_CONFIG,
+ "91_kernel_cmdline_url.cfg"),
+ network=not args.local)]
# Cloud-init 'init' stage is broken up into the following sub-stages
# 1. Ensure that the init object fetches its config without errors
@@ -354,6 +353,11 @@ def main_init(name, args):
LOG.debug("[%s] %s will now be targeting instance id: %s. new=%s",
mode, name, iid, init.is_new_instance())
+ if mode == sources.DSMODE_LOCAL:
+ # Before network comes up, set any configured hostname to allow
+ # dhcp clients to advertize this hostname to any DDNS services
+ # LP: #1746455.
+ _maybe_set_hostname(init, stage='local', retry_stage='network')
init.apply_network_config(bring_up=bool(mode != sources.DSMODE_LOCAL))
if mode == sources.DSMODE_LOCAL:
@@ -370,6 +374,7 @@ def main_init(name, args):
init.setup_datasource()
# update fully realizes user-data (pulling in #include if necessary)
init.update()
+ _maybe_set_hostname(init, stage='init-net', retry_stage='modules:config')
# Stage 7
try:
# Attempt to consume the data per instance.
@@ -683,6 +688,24 @@ def status_wrapper(name, args, data_d=None, link_d=None):
return len(v1[mode]['errors'])
+def _maybe_set_hostname(init, stage, retry_stage):
+ """Call set-hostname if metadata, vendordata or userdata provides it.
+
+ @param stage: String representing current stage in which we are running.
+ @param retry_stage: String represented logs upon error setting hostname.
+ """
+ cloud = init.cloudify()
+ (hostname, _fqdn) = util.get_hostname_fqdn(
+ init.cfg, cloud, metadata_only=True)
+ if hostname: # meta-data or user-data hostname content
+ try:
+ cc_set_hostname.handle('set-hostname', init.cfg, cloud, LOG, None)
+ except cc_set_hostname.SetHostnameError as e:
+ LOG.debug(
+ 'Failed setting hostname in %s stage. Will'
+ ' retry in %s stage. Error: %s.', stage, retry_stage, str(e))
+
+
def main_features(name, args):
sys.stdout.write('\n'.join(sorted(version.FEATURES)) + '\n')
diff --git a/cloudinit/cmd/tests/test_clean.py b/cloudinit/cmd/tests/test_clean.py
index 6713af4f..5a3ec3bf 100644
--- a/cloudinit/cmd/tests/test_clean.py
+++ b/cloudinit/cmd/tests/test_clean.py
@@ -165,10 +165,11 @@ class TestClean(CiTestCase):
wrap_and_call(
'cloudinit.cmd.clean',
{'Init': {'side_effect': self.init_class},
+ 'sys.exit': {'side_effect': self.sys_exit},
'sys.argv': {'new': ['clean', '--logs']}},
clean.main)
- self.assertRaisesCodeEqual(0, context_manager.exception.code)
+ self.assertEqual(0, context_manager.exception.code)
self.assertFalse(
os.path.exists(self.log1), 'Unexpected log {0}'.format(self.log1))
diff --git a/cloudinit/cmd/tests/test_main.py b/cloudinit/cmd/tests/test_main.py
new file mode 100644
index 00000000..dbe421c0
--- /dev/null
+++ b/cloudinit/cmd/tests/test_main.py
@@ -0,0 +1,161 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+from collections import namedtuple
+import copy
+import os
+from six import StringIO
+
+from cloudinit.cmd import main
+from cloudinit.util import (
+ ensure_dir, load_file, write_file, yaml_dumps)
+from cloudinit.tests.helpers import (
+ FilesystemMockingTestCase, wrap_and_call)
+
+mypaths = namedtuple('MyPaths', 'run_dir')
+myargs = namedtuple('MyArgs', 'debug files force local reporter subcommand')
+
+
+class TestMain(FilesystemMockingTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestMain, self).setUp()
+ self.new_root = self.tmp_dir()
+ self.cloud_dir = self.tmp_path('var/lib/cloud/', dir=self.new_root)
+ os.makedirs(self.cloud_dir)
+ self.replicateTestRoot('simple_ubuntu', self.new_root)
+ self.cfg = {
+ 'datasource_list': ['None'],
+ 'runcmd': ['ls /etc'], # test ALL_DISTROS
+ 'system_info': {'paths': {'cloud_dir': self.cloud_dir,
+ 'run_dir': self.new_root}},
+ 'write_files': [
+ {
+ 'path': '/etc/blah.ini',
+ 'content': 'blah',
+ 'permissions': 0o755,
+ },
+ ],
+ 'cloud_init_modules': ['write-files', 'runcmd'],
+ }
+ cloud_cfg = yaml_dumps(self.cfg)
+ ensure_dir(os.path.join(self.new_root, 'etc', 'cloud'))
+ self.cloud_cfg_file = os.path.join(
+ self.new_root, 'etc', 'cloud', 'cloud.cfg')
+ write_file(self.cloud_cfg_file, cloud_cfg)
+ self.patchOS(self.new_root)
+ self.patchUtils(self.new_root)
+ self.stderr = StringIO()
+ self.patchStdoutAndStderr(stderr=self.stderr)
+
+ def test_main_init_run_net_stops_on_file_no_net(self):
+ """When no-net file is present, main_init does not process modules."""
+ stop_file = os.path.join(self.cloud_dir, 'data', 'no-net') # stop file
+ write_file(stop_file, '')
+ cmdargs = myargs(
+ debug=False, files=None, force=False, local=False, reporter=None,
+ subcommand='init')
+ (item1, item2) = wrap_and_call(
+ 'cloudinit.cmd.main',
+ {'util.close_stdin': True,
+ 'netinfo.debug_info': 'my net debug info',
+ 'util.fixup_output': ('outfmt', 'errfmt')},
+ main.main_init, 'init', cmdargs)
+ # We should not run write_files module
+ self.assertFalse(
+ os.path.exists(os.path.join(self.new_root, 'etc/blah.ini')),
+ 'Unexpected run of write_files module produced blah.ini')
+ self.assertEqual([], item2)
+ # Instancify is called
+ instance_id_path = 'var/lib/cloud/data/instance-id'
+ self.assertFalse(
+ os.path.exists(os.path.join(self.new_root, instance_id_path)),
+ 'Unexpected call to datasource.instancify produced instance-id')
+ expected_logs = [
+ "Exiting. stop file ['{stop_file}'] existed\n".format(
+ stop_file=stop_file),
+ 'my net debug info' # netinfo.debug_info
+ ]
+ for log in expected_logs:
+ self.assertIn(log, self.stderr.getvalue())
+
+ def test_main_init_run_net_runs_modules(self):
+ """Modules like write_files are run in 'net' mode."""
+ cmdargs = myargs(
+ debug=False, files=None, force=False, local=False, reporter=None,
+ subcommand='init')
+ (item1, item2) = wrap_and_call(
+ 'cloudinit.cmd.main',
+ {'util.close_stdin': True,
+ 'netinfo.debug_info': 'my net debug info',
+ 'util.fixup_output': ('outfmt', 'errfmt')},
+ main.main_init, 'init', cmdargs)
+ self.assertEqual([], item2)
+ # Instancify is called
+ instance_id_path = 'var/lib/cloud/data/instance-id'
+ self.assertEqual(
+ 'iid-datasource-none\n',
+ os.path.join(load_file(
+ os.path.join(self.new_root, instance_id_path))))
+ # modules are run (including write_files)
+ self.assertEqual(
+ 'blah', load_file(os.path.join(self.new_root, 'etc/blah.ini')))
+ expected_logs = [
+ 'network config is disabled by fallback', # apply_network_config
+ 'my net debug info', # netinfo.debug_info
+ 'no previous run detected'
+ ]
+ for log in expected_logs:
+ self.assertIn(log, self.stderr.getvalue())
+
+ def test_main_init_run_net_calls_set_hostname_when_metadata_present(self):
+ """When local-hostname metadata is present, call cc_set_hostname."""
+ self.cfg['datasource'] = {
+ 'None': {'metadata': {'local-hostname': 'md-hostname'}}}
+ cloud_cfg = yaml_dumps(self.cfg)
+ write_file(self.cloud_cfg_file, cloud_cfg)
+ cmdargs = myargs(
+ debug=False, files=None, force=False, local=False, reporter=None,
+ subcommand='init')
+
+ def set_hostname(name, cfg, cloud, log, args):
+ self.assertEqual('set-hostname', name)
+ updated_cfg = copy.deepcopy(self.cfg)
+ updated_cfg.update(
+ {'def_log_file': '/var/log/cloud-init.log',
+ 'log_cfgs': [],
+ 'syslog_fix_perms': ['syslog:adm', 'root:adm', 'root:wheel'],
+ 'vendor_data': {'enabled': True, 'prefix': []}})
+ updated_cfg.pop('system_info')
+
+ self.assertEqual(updated_cfg, cfg)
+ self.assertEqual(main.LOG, log)
+ self.assertIsNone(args)
+
+ (item1, item2) = wrap_and_call(
+ 'cloudinit.cmd.main',
+ {'util.close_stdin': True,
+ 'netinfo.debug_info': 'my net debug info',
+ 'cc_set_hostname.handle': {'side_effect': set_hostname},
+ 'util.fixup_output': ('outfmt', 'errfmt')},
+ main.main_init, 'init', cmdargs)
+ self.assertEqual([], item2)
+ # Instancify is called
+ instance_id_path = 'var/lib/cloud/data/instance-id'
+ self.assertEqual(
+ 'iid-datasource-none\n',
+ os.path.join(load_file(
+ os.path.join(self.new_root, instance_id_path))))
+ # modules are run (including write_files)
+ self.assertEqual(
+ 'blah', load_file(os.path.join(self.new_root, 'etc/blah.ini')))
+ expected_logs = [
+ 'network config is disabled by fallback', # apply_network_config
+ 'my net debug info', # netinfo.debug_info
+ 'no previous run detected'
+ ]
+ for log in expected_logs:
+ self.assertIn(log, self.stderr.getvalue())
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/cmd/tests/test_status.py b/cloudinit/cmd/tests/test_status.py
index 4a5a8c06..37a89936 100644
--- a/cloudinit/cmd/tests/test_status.py
+++ b/cloudinit/cmd/tests/test_status.py
@@ -380,10 +380,11 @@ class TestStatus(CiTestCase):
wrap_and_call(
'cloudinit.cmd.status',
{'sys.argv': {'new': ['status']},
+ 'sys.exit': {'side_effect': self.sys_exit},
'_is_cloudinit_disabled': (False, ''),
'Init': {'side_effect': self.init_class}},
status.main)
- self.assertRaisesCodeEqual(0, context_manager.exception.code)
+ self.assertEqual(0, context_manager.exception.code)
self.assertEqual('status: running\n', m_stdout.getvalue())
# vi: ts=4 expandtab syntax=python
diff --git a/cloudinit/config/cc_keys_to_console.py b/cloudinit/config/cc_keys_to_console.py
index efedd4ae..aff4010e 100644
--- a/cloudinit/config/cc_keys_to_console.py
+++ b/cloudinit/config/cc_keys_to_console.py
@@ -63,9 +63,7 @@ def handle(name, cfg, cloud, log, _args):
["ssh-dss"])
try:
- cmd = [helper_path]
- cmd.append(','.join(fp_blacklist))
- cmd.append(','.join(key_blacklist))
+ cmd = [helper_path, ','.join(fp_blacklist), ','.join(key_blacklist)]
(stdout, _stderr) = util.subp(cmd)
util.multi_log("%s\n" % (stdout.strip()),
stderr=False, console=True)
diff --git a/cloudinit/config/cc_puppet.py b/cloudinit/config/cc_puppet.py
index 28b1d568..4190a20b 100644
--- a/cloudinit/config/cc_puppet.py
+++ b/cloudinit/config/cc_puppet.py
@@ -21,6 +21,13 @@ under ``version``, and defaults to ``none``, which selects the latest version
in the repos. If the ``puppet`` config key exists in the config archive, this
module will attempt to start puppet even if no installation was performed.
+The module also provides keys for configuring the new puppet 4 paths and
+installing the puppet package from the puppetlabs repositories:
+https://docs.puppet.com/puppet/4.2/reference/whered_it_go.html
+The keys are ``package_name``, ``conf_file`` and ``ssl_dir``. If unset, their
+values will default to ones that work with puppet 3.x and with distributions
+that ship modified puppet 4.x that uses the old paths.
+
Puppet configuration can be specified under the ``conf`` key. The
configuration is specified as a dictionary containing high-level ``<section>``
keys and lists of ``<key>=<value>`` pairs within each section. Each section
@@ -44,6 +51,9 @@ in pem format as a multi-line string (using the ``|`` yaml notation).
puppet:
install: <true/false>
version: <version>
+ conf_file: '/etc/puppet/puppet.conf'
+ ssl_dir: '/var/lib/puppet/ssl'
+ package_name: 'puppet'
conf:
agent:
server: "puppetmaster.example.org"
@@ -63,9 +73,17 @@ from cloudinit import helpers
from cloudinit import util
PUPPET_CONF_PATH = '/etc/puppet/puppet.conf'
-PUPPET_SSL_CERT_DIR = '/var/lib/puppet/ssl/certs/'
PUPPET_SSL_DIR = '/var/lib/puppet/ssl'
-PUPPET_SSL_CERT_PATH = '/var/lib/puppet/ssl/certs/ca.pem'
+PUPPET_PACKAGE_NAME = 'puppet'
+
+
+class PuppetConstants(object):
+
+ def __init__(self, puppet_conf_file, puppet_ssl_dir, log):
+ self.conf_path = puppet_conf_file
+ self.ssl_dir = puppet_ssl_dir
+ self.ssl_cert_dir = os.path.join(puppet_ssl_dir, "certs")
+ self.ssl_cert_path = os.path.join(self.ssl_cert_dir, "ca.pem")
def _autostart_puppet(log):
@@ -92,22 +110,29 @@ def handle(name, cfg, cloud, log, _args):
return
puppet_cfg = cfg['puppet']
-
# Start by installing the puppet package if necessary...
install = util.get_cfg_option_bool(puppet_cfg, 'install', True)
version = util.get_cfg_option_str(puppet_cfg, 'version', None)
+ package_name = util.get_cfg_option_str(
+ puppet_cfg, 'package_name', PUPPET_PACKAGE_NAME)
+ conf_file = util.get_cfg_option_str(
+ puppet_cfg, 'conf_file', PUPPET_CONF_PATH)
+ ssl_dir = util.get_cfg_option_str(puppet_cfg, 'ssl_dir', PUPPET_SSL_DIR)
+
+ p_constants = PuppetConstants(conf_file, ssl_dir, log)
if not install and version:
log.warn(("Puppet install set false but version supplied,"
" doing nothing."))
elif install:
log.debug(("Attempting to install puppet %s,"),
version if version else 'latest')
- cloud.distro.install_packages(('puppet', version))
+
+ cloud.distro.install_packages((package_name, version))
# ... and then update the puppet configuration
if 'conf' in puppet_cfg:
# Add all sections from the conf object to puppet.conf
- contents = util.load_file(PUPPET_CONF_PATH)
+ contents = util.load_file(p_constants.conf_path)
# Create object for reading puppet.conf values
puppet_config = helpers.DefaultingConfigParser()
# Read puppet.conf values from original file in order to be able to
@@ -115,20 +140,23 @@ def handle(name, cfg, cloud, log, _args):
# (TODO(harlowja) is this really needed??)
cleaned_lines = [i.lstrip() for i in contents.splitlines()]
cleaned_contents = '\n'.join(cleaned_lines)
- puppet_config.readfp(StringIO(cleaned_contents),
- filename=PUPPET_CONF_PATH)
+ # Move to puppet_config.read_file when dropping py2.7
+ puppet_config.readfp( # pylint: disable=W1505
+ StringIO(cleaned_contents),
+ filename=p_constants.conf_path)
for (cfg_name, cfg) in puppet_cfg['conf'].items():
# Cert configuration is a special case
# Dump the puppet master ca certificate in the correct place
if cfg_name == 'ca_cert':
# Puppet ssl sub-directory isn't created yet
# Create it with the proper permissions and ownership
- util.ensure_dir(PUPPET_SSL_DIR, 0o771)
- util.chownbyname(PUPPET_SSL_DIR, 'puppet', 'root')
- util.ensure_dir(PUPPET_SSL_CERT_DIR)
- util.chownbyname(PUPPET_SSL_CERT_DIR, 'puppet', 'root')
- util.write_file(PUPPET_SSL_CERT_PATH, cfg)
- util.chownbyname(PUPPET_SSL_CERT_PATH, 'puppet', 'root')
+ util.ensure_dir(p_constants.ssl_dir, 0o771)
+ util.chownbyname(p_constants.ssl_dir, 'puppet', 'root')
+ util.ensure_dir(p_constants.ssl_cert_dir)
+
+ util.chownbyname(p_constants.ssl_cert_dir, 'puppet', 'root')
+ util.write_file(p_constants.ssl_cert_path, cfg)
+ util.chownbyname(p_constants.ssl_cert_path, 'puppet', 'root')
else:
# Iterate through the config items, we'll use ConfigParser.set
# to overwrite or create new items as needed
@@ -144,8 +172,9 @@ def handle(name, cfg, cloud, log, _args):
puppet_config.set(cfg_name, o, v)
# We got all our config as wanted we'll rename
# the previous puppet.conf and create our new one
- util.rename(PUPPET_CONF_PATH, "%s.old" % (PUPPET_CONF_PATH))
- util.write_file(PUPPET_CONF_PATH, puppet_config.stringify())
+ util.rename(p_constants.conf_path, "%s.old"
+ % (p_constants.conf_path))
+ util.write_file(p_constants.conf_path, puppet_config.stringify())
# Set it up so it autostarts
_autostart_puppet(log)
diff --git a/cloudinit/config/cc_resizefs.py b/cloudinit/config/cc_resizefs.py
index cec22bb7..c8e1752f 100644
--- a/cloudinit/config/cc_resizefs.py
+++ b/cloudinit/config/cc_resizefs.py
@@ -84,6 +84,10 @@ def _resize_ufs(mount_point, devpth):
return ('growfs', devpth)
+def _resize_zfs(mount_point, devpth):
+ return ('zpool', 'online', '-e', mount_point, devpth)
+
+
def _get_dumpfs_output(mount_point):
dumpfs_res, err = util.subp(['dumpfs', '-m', mount_point])
return dumpfs_res
@@ -148,6 +152,7 @@ RESIZE_FS_PREFIXES_CMDS = [
('ext', _resize_ext),
('xfs', _resize_xfs),
('ufs', _resize_ufs),
+ ('zfs', _resize_zfs),
]
RESIZE_FS_PRECHECK_CMDS = {
@@ -188,6 +193,13 @@ def maybe_get_writable_device_path(devpath, info, log):
log.debug("Not attempting to resize devpath '%s': %s", devpath, info)
return None
+ # FreeBSD zpool can also just use gpt/<label>
+ # with that in mind we can not do an os.stat on "gpt/whatever"
+ # therefore return the devpath already here.
+ if devpath.startswith('gpt/'):
+ log.debug('We have a gpt label - just go ahead')
+ return devpath
+
try:
statret = os.stat(devpath)
except OSError as exc:
@@ -231,6 +243,16 @@ def handle(name, cfg, _cloud, log, args):
(devpth, fs_type, mount_point) = result
+ # if we have a zfs then our device path at this point
+ # is the zfs label. For example: vmzroot/ROOT/freebsd
+ # we will have to get the zpool name out of this
+ # and set the resize_what variable to the zpool
+ # so the _resize_zfs function gets the right attribute.
+ if fs_type == 'zfs':
+ zpool = devpth.split('/')[0]
+ devpth = util.get_device_info_from_zpool(zpool)
+ resize_what = zpool
+
info = "dev=%s mnt_point=%s path=%s" % (devpth, mount_point, resize_what)
log.debug("resize_info: %s" % info)
diff --git a/cloudinit/config/cc_runcmd.py b/cloudinit/config/cc_runcmd.py
index 449872f0..539cbd5d 100644
--- a/cloudinit/config/cc_runcmd.py
+++ b/cloudinit/config/cc_runcmd.py
@@ -39,8 +39,10 @@ schema = {
using ``sh``.
.. note::
- all commands must be proper yaml, so you have to quote any characters
- yaml would eat (':' can be problematic)"""),
+
+ all commands must be proper yaml, so you have to quote any characters
+ yaml would eat (':' can be problematic)
+ """),
'distros': distros,
'examples': [dedent("""\
runcmd:
diff --git a/cloudinit/config/cc_salt_minion.py b/cloudinit/config/cc_salt_minion.py
index 2b388372..d6a21d72 100644
--- a/cloudinit/config/cc_salt_minion.py
+++ b/cloudinit/config/cc_salt_minion.py
@@ -12,7 +12,9 @@ key is present in the config parts, then salt minion will be installed and
started. Configuration for salt minion can be specified in the ``conf`` key
under ``salt_minion``. Any conf values present there will be assigned in
``/etc/salt/minion``. The public and private keys to use for salt minion can be
-specified with ``public_key`` and ``private_key`` respectively.
+specified with ``public_key`` and ``private_key`` respectively. Optionally if
+you have a custom package name, service name or config directory you can
+specify them with ``pkg_name``, ``service_name`` and ``config_dir``.
**Internal name:** ``cc_salt_minion``
@@ -23,8 +25,14 @@ specified with ``public_key`` and ``private_key`` respectively.
**Config keys**::
salt_minion:
+ pkg_name: 'salt-minion'
+ service_name: 'salt-minion'
+ config_dir: '/etc/salt'
conf:
master: salt.example.com
+ grains:
+ role:
+ - web
public_key: |
------BEGIN PUBLIC KEY-------
<key data>
@@ -39,7 +47,34 @@ import os
from cloudinit import util
-# Note: see http://saltstack.org/topics/installation/
+# Note: see https://docs.saltstack.com/en/latest/topics/installation/
+# Note: see https://docs.saltstack.com/en/latest/ref/configuration/
+
+
+class SaltConstants(object):
+ """
+ defines default distribution specific salt variables
+ """
+ def __init__(self, cfg):
+
+ # constants tailored for FreeBSD
+ if util.is_FreeBSD():
+ self.pkg_name = 'py27-salt'
+ self.srv_name = 'salt_minion'
+ self.conf_dir = '/usr/local/etc/salt'
+ # constants for any other OS
+ else:
+ self.pkg_name = 'salt-minion'
+ self.srv_name = 'salt-minion'
+ self.conf_dir = '/etc/salt'
+
+ # if there are constants given in cloud config use those
+ self.pkg_name = util.get_cfg_option_str(cfg, 'pkg_name',
+ self.pkg_name)
+ self.conf_dir = util.get_cfg_option_str(cfg, 'config_dir',
+ self.conf_dir)
+ self.srv_name = util.get_cfg_option_str(cfg, 'service_name',
+ self.srv_name)
def handle(name, cfg, cloud, log, _args):
@@ -49,39 +84,49 @@ def handle(name, cfg, cloud, log, _args):
" no 'salt_minion' key in configuration"), name)
return
- salt_cfg = cfg['salt_minion']
+ s_cfg = cfg['salt_minion']
+ const = SaltConstants(cfg=s_cfg)
# Start by installing the salt package ...
- cloud.distro.install_packages(('salt-minion',))
+ cloud.distro.install_packages(const.pkg_name)
# Ensure we can configure files at the right dir
- config_dir = salt_cfg.get("config_dir", '/etc/salt')
- util.ensure_dir(config_dir)
+ util.ensure_dir(const.conf_dir)
# ... and then update the salt configuration
- if 'conf' in salt_cfg:
- # Add all sections from the conf object to /etc/salt/minion
- minion_config = os.path.join(config_dir, 'minion')
- minion_data = util.yaml_dumps(salt_cfg.get('conf'))
+ if 'conf' in s_cfg:
+ # Add all sections from the conf object to minion config file
+ minion_config = os.path.join(const.conf_dir, 'minion')
+ minion_data = util.yaml_dumps(s_cfg.get('conf'))
util.write_file(minion_config, minion_data)
+ if 'grains' in s_cfg:
+ # add grains to /etc/salt/grains
+ grains_config = os.path.join(const.conf_dir, 'grains')
+ grains_data = util.yaml_dumps(s_cfg.get('grains'))
+ util.write_file(grains_config, grains_data)
+
# ... copy the key pair if specified
- if 'public_key' in salt_cfg and 'private_key' in salt_cfg:
- if os.path.isdir("/etc/salt/pki/minion"):
- pki_dir_default = "/etc/salt/pki/minion"
- else:
- pki_dir_default = "/etc/salt/pki"
+ if 'public_key' in s_cfg and 'private_key' in s_cfg:
+ pki_dir_default = os.path.join(const.conf_dir, "pki/minion")
+ if not os.path.isdir(pki_dir_default):
+ pki_dir_default = os.path.join(const.conf_dir, "pki")
- pki_dir = salt_cfg.get('pki_dir', pki_dir_default)
+ pki_dir = s_cfg.get('pki_dir', pki_dir_default)
with util.umask(0o77):
util.ensure_dir(pki_dir)
pub_name = os.path.join(pki_dir, 'minion.pub')
pem_name = os.path.join(pki_dir, 'minion.pem')
- util.write_file(pub_name, salt_cfg['public_key'])
- util.write_file(pem_name, salt_cfg['private_key'])
+ util.write_file(pub_name, s_cfg['public_key'])
+ util.write_file(pem_name, s_cfg['private_key'])
+
+ # we need to have the salt minion service enabled in rc in order to be
+ # able to start the service. this does only apply on FreeBSD servers.
+ if cloud.distro.osfamily == 'freebsd':
+ cloud.distro.updatercconf('salt_minion_enable', 'YES')
- # restart salt-minion. 'service' will start even if not started. if it
+ # restart salt-minion. 'service' will start even if not started. if it
# was started, it needs to be restarted for config change.
- util.subp(['service', 'salt-minion', 'restart'], capture=False)
+ util.subp(['service', const.srv_name, 'restart'], capture=False)
# vi: ts=4 expandtab
diff --git a/cloudinit/config/cc_set_hostname.py b/cloudinit/config/cc_set_hostname.py
index aa3dfe5f..3d2b2da3 100644
--- a/cloudinit/config/cc_set_hostname.py
+++ b/cloudinit/config/cc_set_hostname.py
@@ -32,22 +32,51 @@ will be used.
hostname: <fqdn/hostname>
"""
+import os
+
+
+from cloudinit.atomic_helper import write_json
from cloudinit import util
+class SetHostnameError(Exception):
+ """Raised when the distro runs into an exception when setting hostname.
+
+ This may happen if we attempt to set the hostname early in cloud-init's
+ init-local timeframe as certain services may not be running yet.
+ """
+ pass
+
+
def handle(name, cfg, cloud, log, _args):
if util.get_cfg_option_bool(cfg, "preserve_hostname", False):
log.debug(("Configuration option 'preserve_hostname' is set,"
" not setting the hostname in module %s"), name)
return
-
(hostname, fqdn) = util.get_hostname_fqdn(cfg, cloud)
+ # Check for previous successful invocation of set-hostname
+
+ # set-hostname artifact file accounts for both hostname and fqdn
+ # deltas. As such, it's format is different than cc_update_hostname's
+ # previous-hostname file which only contains the base hostname.
+ # TODO consolidate previous-hostname and set-hostname artifact files and
+ # distro._read_hostname implementation so we only validate one artifact.
+ prev_fn = os.path.join(cloud.get_cpath('data'), "set-hostname")
+ prev_hostname = {}
+ if os.path.exists(prev_fn):
+ prev_hostname = util.load_json(util.load_file(prev_fn))
+ hostname_changed = (hostname != prev_hostname.get('hostname') or
+ fqdn != prev_hostname.get('fqdn'))
+ if not hostname_changed:
+ log.debug('No hostname changes. Skipping set-hostname')
+ return
+ log.debug("Setting the hostname to %s (%s)", fqdn, hostname)
try:
- log.debug("Setting the hostname to %s (%s)", fqdn, hostname)
cloud.distro.set_hostname(hostname, fqdn)
- except Exception:
- util.logexc(log, "Failed to set the hostname to %s (%s)", fqdn,
- hostname)
- raise
+ except Exception as e:
+ msg = "Failed to set the hostname to %s (%s)" % (fqdn, hostname)
+ util.logexc(log, msg)
+ raise SetHostnameError("%s: %s" % (msg, e))
+ write_json(prev_fn, {'hostname': hostname, 'fqdn': fqdn})
# vi: ts=4 expandtab
diff --git a/cloudinit/config/cc_snap.py b/cloudinit/config/cc_snap.py
new file mode 100644
index 00000000..34a53fd4
--- /dev/null
+++ b/cloudinit/config/cc_snap.py
@@ -0,0 +1,230 @@
+# Copyright (C) 2018 Canonical Ltd.
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+
+"""Snap: Install, configure and manage snapd and snap packages."""
+
+import sys
+from textwrap import dedent
+
+from cloudinit import log as logging
+from cloudinit.config.schema import (
+ get_schema_doc, validate_cloudconfig_schema)
+from cloudinit.settings import PER_INSTANCE
+from cloudinit.subp import prepend_base_command
+from cloudinit import util
+
+
+distros = ['ubuntu']
+frequency = PER_INSTANCE
+
+LOG = logging.getLogger(__name__)
+
+schema = {
+ 'id': 'cc_snap',
+ 'name': 'Snap',
+ 'title': 'Install, configure and manage snapd and snap packages',
+ 'description': dedent("""\
+ This module provides a simple configuration namespace in cloud-init to
+ both setup snapd and install snaps.
+
+ .. note::
+ Both ``assertions`` and ``commands`` values can be either a
+ dictionary or a list. If these configs are provided as a
+ dictionary, the keys are only used to order the execution of the
+ assertions or commands and the dictionary is merged with any
+ vendor-data snap configuration provided. If a list is provided by
+ the user instead of a dict, any vendor-data snap configuration is
+ ignored.
+
+ The ``assertions`` configuration option is a dictionary or list of
+ properly-signed snap assertions which will run before any snap
+ ``commands``. They will be added to snapd's assertion database by
+ invoking ``snap ack <aggregate_assertion_file>``.
+
+ Snap ``commands`` is a dictionary or list of individual snap
+ commands to run on the target system. These commands can be used to
+ create snap users, install snaps and provide snap configuration.
+
+ .. note::
+ If 'side-loading' private/unpublished snaps on an instance, it is
+ best to create a snap seed directory and seed.yaml manifest in
+ **/var/lib/snapd/seed/** which snapd automatically installs on
+ startup.
+
+ **Development only**: The ``squashfuse_in_container`` boolean can be
+ set true to install squashfuse package when in a container to enable
+ snap installs. Default is false.
+ """),
+ 'distros': distros,
+ 'examples': [dedent("""\
+ snap:
+ assertions:
+ 00: |
+ signed_assertion_blob_here
+ 02: |
+ signed_assertion_blob_here
+ commands:
+ 00: snap create-user --sudoer --known <snap-user>@mydomain.com
+ 01: snap install canonical-livepatch
+ 02: canonical-livepatch enable <AUTH_TOKEN>
+ """), dedent("""\
+ # LXC-based containers require squashfuse before snaps can be installed
+ snap:
+ commands:
+ 00: apt-get install squashfuse -y
+ 11: snap install emoj
+
+ """), dedent("""\
+ # Convenience: the snap command can be omitted when specifying commands
+ # as a list and 'snap' will automatically be prepended.
+ # The following commands are equivalent:
+ snap:
+ commands:
+ 00: ['install', 'vlc']
+ 01: ['snap', 'install', 'vlc']
+ 02: snap install vlc
+ 03: 'snap install vlc'
+ """)],
+ 'frequency': PER_INSTANCE,
+ 'type': 'object',
+ 'properties': {
+ 'snap': {
+ 'type': 'object',
+ 'properties': {
+ 'assertions': {
+ 'type': ['object', 'array'], # Array of strings or dict
+ 'items': {'type': 'string'},
+ 'additionalItems': False, # Reject items non-string
+ 'minItems': 1,
+ 'minProperties': 1,
+ 'uniqueItems': True
+ },
+ 'commands': {
+ 'type': ['object', 'array'], # Array of strings or dict
+ 'items': {
+ 'oneOf': [
+ {'type': 'array', 'items': {'type': 'string'}},
+ {'type': 'string'}]
+ },
+ 'additionalItems': False, # Reject non-string & non-list
+ 'minItems': 1,
+ 'minProperties': 1,
+ 'uniqueItems': True
+ },
+ 'squashfuse_in_container': {
+ 'type': 'boolean'
+ }
+ },
+ 'additionalProperties': False, # Reject keys not in schema
+ 'required': [],
+ 'minProperties': 1
+ }
+ }
+}
+
+# TODO schema for 'assertions' and 'commands' are too permissive at the moment.
+# Once python-jsonschema supports schema draft 6 add support for arbitrary
+# object keys with 'patternProperties' constraint to validate string values.
+
+__doc__ = get_schema_doc(schema) # Supplement python help()
+
+SNAP_CMD = "snap"
+ASSERTIONS_FILE = "/var/lib/cloud/instance/snapd.assertions"
+
+
+def add_assertions(assertions):
+ """Import list of assertions.
+
+ Import assertions by concatenating each assertion into a
+ string separated by a '\n'. Write this string to a instance file and
+ then invoke `snap ack /path/to/file` and check for errors.
+ If snap exits 0, then all assertions are imported.
+ """
+ if not assertions:
+ return
+ LOG.debug('Importing user-provided snap assertions')
+ if isinstance(assertions, dict):
+ assertions = assertions.values()
+ elif not isinstance(assertions, list):
+ raise TypeError(
+ 'assertion parameter was not a list or dict: {assertions}'.format(
+ assertions=assertions))
+
+ snap_cmd = [SNAP_CMD, 'ack']
+ combined = "\n".join(assertions)
+
+ for asrt in assertions:
+ LOG.debug('Snap acking: %s', asrt.split('\n')[0:2])
+
+ util.write_file(ASSERTIONS_FILE, combined.encode('utf-8'))
+ util.subp(snap_cmd + [ASSERTIONS_FILE], capture=True)
+
+
+def run_commands(commands):
+ """Run the provided commands provided in snap:commands configuration.
+
+ Commands are run individually. Any errors are collected and reported
+ after attempting all commands.
+
+ @param commands: A list or dict containing commands to run. Keys of a
+ dict will be used to order the commands provided as dict values.
+ """
+ if not commands:
+ return
+ LOG.debug('Running user-provided snap commands')
+ if isinstance(commands, dict):
+ # Sort commands based on dictionary key
+ commands = [v for _, v in sorted(commands.items())]
+ elif not isinstance(commands, list):
+ raise TypeError(
+ 'commands parameter was not a list or dict: {commands}'.format(
+ commands=commands))
+
+ fixed_snap_commands = prepend_base_command('snap', commands)
+
+ cmd_failures = []
+ for command in fixed_snap_commands:
+ shell = isinstance(command, str)
+ try:
+ util.subp(command, shell=shell, status_cb=sys.stderr.write)
+ except util.ProcessExecutionError as e:
+ cmd_failures.append(str(e))
+ if cmd_failures:
+ msg = 'Failures running snap commands:\n{cmd_failures}'.format(
+ cmd_failures=cmd_failures)
+ util.logexc(LOG, msg)
+ raise RuntimeError(msg)
+
+
+# RELEASE_BLOCKER: Once LP: #1628289 is released on xenial, drop this function.
+def maybe_install_squashfuse(cloud):
+ """Install squashfuse if we are in a container."""
+ if not util.is_container():
+ return
+ try:
+ cloud.distro.update_package_sources()
+ except Exception as e:
+ util.logexc(LOG, "Package update failed")
+ raise
+ try:
+ cloud.distro.install_packages(['squashfuse'])
+ except Exception as e:
+ util.logexc(LOG, "Failed to install squashfuse")
+ raise
+
+
+def handle(name, cfg, cloud, log, args):
+ cfgin = cfg.get('snap', {})
+ if not cfgin:
+ LOG.debug(("Skipping module named %s,"
+ " no 'snap' key in configuration"), name)
+ return
+
+ validate_cloudconfig_schema(cfg, schema)
+ if util.is_true(cfgin.get('squashfuse_in_container', False)):
+ maybe_install_squashfuse(cloud)
+ add_assertions(cfgin.get('assertions', []))
+ run_commands(cfgin.get('commands', []))
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/config/cc_snap_config.py b/cloudinit/config/cc_snap_config.py
index e82c0811..afe297ee 100644
--- a/cloudinit/config/cc_snap_config.py
+++ b/cloudinit/config/cc_snap_config.py
@@ -4,11 +4,15 @@
#
# This file is part of cloud-init. See LICENSE file for license information.
+# RELEASE_BLOCKER: Remove this deprecated module in 18.3
"""
Snap Config
-----------
**Summary:** snap_config modules allows configuration of snapd.
+**Deprecated**: Use :ref:`snap` module instead. This module will not exist
+in cloud-init 18.3.
+
This module uses the same ``snappy`` namespace for configuration but
acts only only a subset of the configuration.
@@ -154,6 +158,9 @@ def handle(name, cfg, cloud, log, args):
LOG.debug('No snappy config provided, skipping')
return
+ log.warning(
+ 'DEPRECATION: snap_config module will be dropped in 18.3 release.'
+ ' Use snap module instead')
if not(util.system_is_snappy()):
LOG.debug("%s: system not snappy", name)
return
diff --git a/cloudinit/config/cc_snappy.py b/cloudinit/config/cc_snappy.py
index eecb8178..bab80bbe 100644
--- a/cloudinit/config/cc_snappy.py
+++ b/cloudinit/config/cc_snappy.py
@@ -1,10 +1,14 @@
# This file is part of cloud-init. See LICENSE file for license information.
+# RELEASE_BLOCKER: Remove this deprecated module in 18.3
"""
Snappy
------
**Summary:** snappy modules allows configuration of snappy.
+**Deprecated**: Use :ref:`snap` module instead. This module will not exist
+in cloud-init 18.3.
+
The below example config config would install ``etcd``, and then install
``pkg2.smoser`` with a ``<config-file>`` argument where ``config-file`` has
``config-blob`` inside it. If ``pkgname`` is installed already, then
@@ -271,6 +275,10 @@ def handle(name, cfg, cloud, log, args):
LOG.debug("%s: 'auto' mode, and system not snappy", name)
return
+ log.warning(
+ 'DEPRECATION: snappy module will be dropped in 18.3 release.'
+ ' Use snap module instead')
+
set_snappy_command()
pkg_ops = get_package_ops(packages=mycfg['packages'],
diff --git a/cloudinit/config/cc_ssh_authkey_fingerprints.py b/cloudinit/config/cc_ssh_authkey_fingerprints.py
index 35d8c57f..98b0e665 100755
--- a/cloudinit/config/cc_ssh_authkey_fingerprints.py
+++ b/cloudinit/config/cc_ssh_authkey_fingerprints.py
@@ -77,11 +77,10 @@ def _pprint_key_entries(user, key_fn, key_entries, hash_meth='md5',
tbl = SimpleTable(tbl_fields)
for entry in key_entries:
if _is_printable_key(entry):
- row = []
- row.append(entry.keytype or '-')
- row.append(_gen_fingerprint(entry.base64, hash_meth) or '-')
- row.append(entry.options or '-')
- row.append(entry.comment or '-')
+ row = [entry.keytype or '-',
+ _gen_fingerprint(entry.base64, hash_meth) or '-',
+ entry.options or '-',
+ entry.comment or '-']
tbl.add_row(row)
authtbl_s = tbl.get_string()
authtbl_lines = authtbl_s.splitlines()
diff --git a/cloudinit/config/cc_ubuntu_advantage.py b/cloudinit/config/cc_ubuntu_advantage.py
new file mode 100644
index 00000000..16b1868b
--- /dev/null
+++ b/cloudinit/config/cc_ubuntu_advantage.py
@@ -0,0 +1,173 @@
+# Copyright (C) 2018 Canonical Ltd.
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+
+"""Ubuntu advantage: manage ubuntu-advantage offerings from Canonical."""
+
+import sys
+from textwrap import dedent
+
+from cloudinit import log as logging
+from cloudinit.config.schema import (
+ get_schema_doc, validate_cloudconfig_schema)
+from cloudinit.settings import PER_INSTANCE
+from cloudinit.subp import prepend_base_command
+from cloudinit import util
+
+
+distros = ['ubuntu']
+frequency = PER_INSTANCE
+
+LOG = logging.getLogger(__name__)
+
+schema = {
+ 'id': 'cc_ubuntu_advantage',
+ 'name': 'Ubuntu Advantage',
+ 'title': 'Install, configure and manage ubuntu-advantage offerings',
+ 'description': dedent("""\
+ This module provides configuration options to setup ubuntu-advantage
+ subscriptions.
+
+ .. note::
+ Both ``commands`` value can be either a dictionary or a list. If
+ the configuration provided is a dictionary, the keys are only used
+ to order the execution of the commands and the dictionary is
+ merged with any vendor-data ubuntu-advantage configuration
+ provided. If a ``commands`` is provided as a list, any vendor-data
+ ubuntu-advantage ``commands`` are ignored.
+
+ Ubuntu-advantage ``commands`` is a dictionary or list of
+ ubuntu-advantage commands to run on the deployed machine.
+ These commands can be used to enable or disable subscriptions to
+ various ubuntu-advantage products. See 'man ubuntu-advantage' for more
+ information on supported subcommands.
+
+ .. note::
+ Each command item can be a string or list. If the item is a list,
+ 'ubuntu-advantage' can be omitted and it will automatically be
+ inserted as part of the command.
+ """),
+ 'distros': distros,
+ 'examples': [dedent("""\
+ # Enable Extended Security Maintenance using your service auth token
+ ubuntu-advantage:
+ commands:
+ 00: ubuntu-advantage enable-esm <token>
+ """), dedent("""\
+ # Enable livepatch by providing your livepatch token
+ ubuntu-advantage:
+ commands:
+ 00: ubuntu-advantage enable-livepatch <livepatch-token>
+
+ """), dedent("""\
+ # Convenience: the ubuntu-advantage command can be omitted when
+ # specifying commands as a list and 'ubuntu-advantage' will
+ # automatically be prepended.
+ # The following commands are equivalent
+ ubuntu-advantage:
+ commands:
+ 00: ['enable-livepatch', 'my-token']
+ 01: ['ubuntu-advantage', 'enable-livepatch', 'my-token']
+ 02: ubuntu-advantage enable-livepatch my-token
+ 03: 'ubuntu-advantage enable-livepatch my-token'
+ """)],
+ 'frequency': PER_INSTANCE,
+ 'type': 'object',
+ 'properties': {
+ 'ubuntu-advantage': {
+ 'type': 'object',
+ 'properties': {
+ 'commands': {
+ 'type': ['object', 'array'], # Array of strings or dict
+ 'items': {
+ 'oneOf': [
+ {'type': 'array', 'items': {'type': 'string'}},
+ {'type': 'string'}]
+ },
+ 'additionalItems': False, # Reject non-string & non-list
+ 'minItems': 1,
+ 'minProperties': 1,
+ 'uniqueItems': True
+ }
+ },
+ 'additionalProperties': False, # Reject keys not in schema
+ 'required': ['commands']
+ }
+ }
+}
+
+# TODO schema for 'assertions' and 'commands' are too permissive at the moment.
+# Once python-jsonschema supports schema draft 6 add support for arbitrary
+# object keys with 'patternProperties' constraint to validate string values.
+
+__doc__ = get_schema_doc(schema) # Supplement python help()
+
+UA_CMD = "ubuntu-advantage"
+
+
+def run_commands(commands):
+ """Run the commands provided in ubuntu-advantage:commands config.
+
+ Commands are run individually. Any errors are collected and reported
+ after attempting all commands.
+
+ @param commands: A list or dict containing commands to run. Keys of a
+ dict will be used to order the commands provided as dict values.
+ """
+ if not commands:
+ return
+ LOG.debug('Running user-provided ubuntu-advantage commands')
+ if isinstance(commands, dict):
+ # Sort commands based on dictionary key
+ commands = [v for _, v in sorted(commands.items())]
+ elif not isinstance(commands, list):
+ raise TypeError(
+ 'commands parameter was not a list or dict: {commands}'.format(
+ commands=commands))
+
+ fixed_ua_commands = prepend_base_command('ubuntu-advantage', commands)
+
+ cmd_failures = []
+ for command in fixed_ua_commands:
+ shell = isinstance(command, str)
+ try:
+ util.subp(command, shell=shell, status_cb=sys.stderr.write)
+ except util.ProcessExecutionError as e:
+ cmd_failures.append(str(e))
+ if cmd_failures:
+ msg = (
+ 'Failures running ubuntu-advantage commands:\n'
+ '{cmd_failures}'.format(
+ cmd_failures=cmd_failures))
+ util.logexc(LOG, msg)
+ raise RuntimeError(msg)
+
+
+def maybe_install_ua_tools(cloud):
+ """Install ubuntu-advantage-tools if not present."""
+ if util.which('ubuntu-advantage'):
+ return
+ try:
+ cloud.distro.update_package_sources()
+ except Exception as e:
+ util.logexc(LOG, "Package update failed")
+ raise
+ try:
+ cloud.distro.install_packages(['ubuntu-advantage-tools'])
+ except Exception as e:
+ util.logexc(LOG, "Failed to install ubuntu-advantage-tools")
+ raise
+
+
+def handle(name, cfg, cloud, log, args):
+ cfgin = cfg.get('ubuntu-advantage')
+ if cfgin is None:
+ LOG.debug(("Skipping module named %s,"
+ " no 'ubuntu-advantage' key in configuration"), name)
+ return
+
+ validate_cloudconfig_schema(cfg, schema)
+ maybe_install_ua_tools(cloud)
+ run_commands(cfgin.get('commands', []))
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/config/tests/test_snap.py b/cloudinit/config/tests/test_snap.py
new file mode 100644
index 00000000..c5b4a9de
--- /dev/null
+++ b/cloudinit/config/tests/test_snap.py
@@ -0,0 +1,490 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+import re
+from six import StringIO
+
+from cloudinit.config.cc_snap import (
+ ASSERTIONS_FILE, add_assertions, handle, maybe_install_squashfuse,
+ run_commands, schema)
+from cloudinit.config.schema import validate_cloudconfig_schema
+from cloudinit import util
+from cloudinit.tests.helpers import (
+ CiTestCase, mock, wrap_and_call, skipUnlessJsonSchema)
+
+
+SYSTEM_USER_ASSERTION = """\
+type: system-user
+authority-id: LqvZQdfyfGlYvtep4W6Oj6pFXP9t1Ksp
+brand-id: LqvZQdfyfGlYvtep4W6Oj6pFXP9t1Ksp
+email: foo@bar.com
+password: $6$E5YiAuMIPAwX58jG$miomhVNui/vf7f/3ctB/f0RWSKFxG0YXzrJ9rtJ1ikvzt
+series:
+- 16
+since: 2016-09-10T16:34:00+03:00
+until: 2017-11-10T16:34:00+03:00
+username: baz
+sign-key-sha3-384: RuVvnp4n52GilycjfbbTCI3_L8Y6QlIE75wxMc0KzGV3AUQqVd9GuXoj
+
+AcLBXAQAAQoABgUCV/UU1wAKCRBKnlMoJQLkZVeLD/9/+hIeVywtzsDA3oxl+P+u9D13y9s6svP
+Jd6Wnf4FTw6sq1GjBE4ZA7lrwSaRCUJ9Vcsvf2q9OGPY7mOb2TBxaDe0PbUMjrSrqllSSQwhpNI
+zG+NxkkKuxsUmLzFa+k9m6cyojNbw5LFhQZBQCGlr3JYqC0tIREq/UsZxj+90TUC87lDJwkU8GF
+s4CR+rejZj4itIcDcVxCSnJH6hv6j2JrJskJmvObqTnoOlcab+JXdamXqbldSP3UIhWoyVjqzkj
++to7mXgx+cCUA9+ngNCcfUG+1huGGTWXPCYkZ78HvErcRlIdeo4d3xwtz1cl/w3vYnq9og1XwsP
+Yfetr3boig2qs1Y+j/LpsfYBYncgWjeDfAB9ZZaqQz/oc8n87tIPZDJHrusTlBfop8CqcM4xsKS
+d+wnEY8e/F24mdSOYmS1vQCIDiRU3MKb6x138Ud6oHXFlRBbBJqMMctPqWDunWzb5QJ7YR0I39q
+BrnEqv5NE0G7w6HOJ1LSPG5Hae3P4T2ea+ATgkb03RPr3KnXnzXg4TtBbW1nytdlgoNc/BafE1H
+f3NThcq9gwX4xWZ2PAWnqVPYdDMyCtzW3Ck+o6sIzx+dh4gDLPHIi/6TPe/pUuMop9CBpWwez7V
+v1z+1+URx6Xlq3Jq18y5pZ6fY3IDJ6km2nQPMzcm4Q=="""
+
+ACCOUNT_ASSERTION = """\
+type: account-key
+authority-id: canonical
+revision: 2
+public-key-sha3-384: BWDEoaqyr25nF5SNCvEv2v7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0
+account-id: canonical
+name: store
+since: 2016-04-01T00:00:00.0Z
+body-length: 717
+sign-key-sha3-384: -CvQKAwRQ5h3Ffn10FILJoEZUXOv6km9FwA80-Rcj-f-6jadQ89VRswH
+
+AcbBTQRWhcGAARAA0KKYYQWuHOrsFVi4p4l7ZzSvX7kLgJFFeFgOkzdWKBTHEnsMKjl5mefFe9j
+qe8NlmJdfY7BenP7XeBtwKp700H/t9lLrZbpTNAPHXYxEWFJp5bPqIcJYBZ+29oLVLN1Tc5X482
+vCiDqL8+pPYqBrK2fNlyPlNNSum9wI70rDDL4r6FVvr+osTnGejibdV8JphWX+lrSQDnRSdM8KJ
+UM43vTgLGTi9W54oRhsA2OFexRfRksTrnqGoonCjqX5wO3OFSaMDzMsO2MJ/hPfLgDqw53qjzuK
+Iec9OL3k5basvu2cj5u9tKwVFDsCKK2GbKUsWWpx2KTpOifmhmiAbzkTHbH9KaoMS7p0kJwhTQG
+o9aJ9VMTWHJc/NCBx7eu451u6d46sBPCXS/OMUh2766fQmoRtO1OwCTxsRKG2kkjbMn54UdFULl
+VfzvyghMNRKIezsEkmM8wueTqGUGZWa6CEZqZKwhe/PROxOPYzqtDH18XZknbU1n5lNb7vNfem9
+2ai+3+JyFnW9UhfvpVF7gzAgdyCqNli4C6BIN43uwoS8HkykocZS/+Gv52aUQ/NZ8BKOHLw+7an
+Q0o8W9ltSLZbEMxFIPSN0stiZlkXAp6DLyvh1Y4wXSynDjUondTpej2fSvSlCz/W5v5V7qA4nIc
+vUvV7RjVzv17ut0AEQEAAQ==
+
+AcLDXAQAAQoABgUCV83k9QAKCRDUpVvql9g3IBT8IACKZ7XpiBZ3W4lqbPssY6On81WmxQLtvsM
+WTp6zZpl/wWOSt2vMNUk9pvcmrNq1jG9CuhDfWFLGXEjcrrmVkN3YuCOajMSPFCGrxsIBLSRt/b
+nrKykdLAAzMfG8rP1d82bjFFiIieE+urQ0Kcv09Jtdvavq3JT1Tek5mFyyfhHNlQEKOzWqmRWiL
+3c3VOZUs1ZD8TSlnuq/x+5T0X0YtOyGjSlVxk7UybbyMNd6MZfNaMpIG4x+mxD3KHFtBAC7O6kL
+eX3i6j5nCY5UABfA3DZEAkWP4zlmdBEOvZ9t293NaDdOpzsUHRkoi0Zez/9BHQ/kwx/uNc2WqrY
+inCmu16JGNeXqsyinnLl7Ghn2RwhvDMlLxF6RTx8xdx1yk6p3PBTwhZMUvuZGjUtN/AG8BmVJQ1
+rsGSRkkSywvnhVJRB2sudnrMBmNS2goJbzSbmJnOlBrd2WsV0T9SgNMWZBiov3LvU4o2SmAb6b+
+rYwh8H5QHcuuYJuxDjFhPswIp6Wes5T6hUicf3SWtObcDS4HSkVS4ImBjjX9YgCuFy7QdnooOWE
+aPvkRw3XCVeYq0K6w9GRsk1YFErD4XmXXZjDYY650MX9v42Sz5MmphHV8jdIY5ssbadwFSe2rCQ
+6UX08zy7RsIb19hTndE6ncvSNDChUR9eEnCm73eYaWTWTnq1cxdVP/s52r8uss++OYOkPWqh5nO
+haRn7INjH/yZX4qXjNXlTjo0PnHH0q08vNKDwLhxS+D9du+70FeacXFyLIbcWllSbJ7DmbumGpF
+yYbtj3FDDPzachFQdIG3lSt+cSUGeyfSs6wVtc3cIPka/2Urx7RprfmoWSI6+a5NcLdj0u2z8O9
+HxeIgxDpg/3gT8ZIuFKePMcLDM19Fh/p0ysCsX+84B9chNWtsMSmIaE57V+959MVtsLu7SLb9gi
+skrju0pQCwsu2wHMLTNd1f3PTHmrr49hxetTus07HSQUApMtAGKzQilF5zqFjbyaTd4xgQbd+PK
+CjFyzQTDOcUhXpuUGt/IzlqiFfsCsmbj2K4KdSNYMlqIgZ3Azu8KvZLIhsyN7v5vNIZSPfEbjde
+ClU9r0VRiJmtYBUjcSghD9LWn+yRLwOxhfQVjm0cBwIt5R/yPF/qC76yIVuWUtM5Y2/zJR1J8OF
+qWchvlImHtvDzS9FQeLyzJAOjvZ2CnWp2gILgUz0WQdOk1Dq8ax7KS9BQ42zxw9EZAEPw3PEFqR
+IQsRTONp+iVS8YxSmoYZjDlCgRMWUmawez/Fv5b9Fb/XkO5Eq4e+KfrpUujXItaipb+tV8h5v3t
+oG3Ie3WOHrVjCLXIdYslpL1O4nadqR6Xv58pHj6k"""
+
+
+class FakeCloud(object):
+ def __init__(self, distro):
+ self.distro = distro
+
+
+class TestAddAssertions(CiTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestAddAssertions, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ @mock.patch('cloudinit.config.cc_snap.util.subp')
+ def test_add_assertions_on_empty_list(self, m_subp):
+ """When provided with an empty list, add_assertions does nothing."""
+ add_assertions([])
+ self.assertEqual('', self.logs.getvalue())
+ m_subp.assert_not_called()
+
+ def test_add_assertions_on_non_list_or_dict(self):
+ """When provided an invalid type, add_assertions raises an error."""
+ with self.assertRaises(TypeError) as context_manager:
+ add_assertions(assertions="I'm Not Valid")
+ self.assertEqual(
+ "assertion parameter was not a list or dict: I'm Not Valid",
+ str(context_manager.exception))
+
+ @mock.patch('cloudinit.config.cc_snap.util.subp')
+ def test_add_assertions_adds_assertions_as_list(self, m_subp):
+ """When provided with a list, add_assertions adds all assertions."""
+ self.assertEqual(
+ ASSERTIONS_FILE, '/var/lib/cloud/instance/snapd.assertions')
+ assert_file = self.tmp_path('snapd.assertions', dir=self.tmp)
+ assertions = [SYSTEM_USER_ASSERTION, ACCOUNT_ASSERTION]
+ wrap_and_call(
+ 'cloudinit.config.cc_snap',
+ {'ASSERTIONS_FILE': {'new': assert_file}},
+ add_assertions, assertions)
+ self.assertIn(
+ 'Importing user-provided snap assertions', self.logs.getvalue())
+ self.assertIn(
+ 'sertions', self.logs.getvalue())
+ self.assertEqual(
+ [mock.call(['snap', 'ack', assert_file], capture=True)],
+ m_subp.call_args_list)
+ compare_file = self.tmp_path('comparison', dir=self.tmp)
+ util.write_file(compare_file, '\n'.join(assertions).encode('utf-8'))
+ self.assertEqual(
+ util.load_file(compare_file), util.load_file(assert_file))
+
+ @mock.patch('cloudinit.config.cc_snap.util.subp')
+ def test_add_assertions_adds_assertions_as_dict(self, m_subp):
+ """When provided with a dict, add_assertions adds all assertions."""
+ self.assertEqual(
+ ASSERTIONS_FILE, '/var/lib/cloud/instance/snapd.assertions')
+ assert_file = self.tmp_path('snapd.assertions', dir=self.tmp)
+ assertions = {'00': SYSTEM_USER_ASSERTION, '01': ACCOUNT_ASSERTION}
+ wrap_and_call(
+ 'cloudinit.config.cc_snap',
+ {'ASSERTIONS_FILE': {'new': assert_file}},
+ add_assertions, assertions)
+ self.assertIn(
+ 'Importing user-provided snap assertions', self.logs.getvalue())
+ self.assertIn(
+ "DEBUG: Snap acking: ['type: system-user', 'authority-id: Lqv",
+ self.logs.getvalue())
+ self.assertIn(
+ "DEBUG: Snap acking: ['type: account-key', 'authority-id: canonic",
+ self.logs.getvalue())
+ self.assertEqual(
+ [mock.call(['snap', 'ack', assert_file], capture=True)],
+ m_subp.call_args_list)
+ compare_file = self.tmp_path('comparison', dir=self.tmp)
+ combined = '\n'.join(assertions.values())
+ util.write_file(compare_file, combined.encode('utf-8'))
+ self.assertEqual(
+ util.load_file(compare_file), util.load_file(assert_file))
+
+
+class TestRunCommands(CiTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestRunCommands, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ @mock.patch('cloudinit.config.cc_snap.util.subp')
+ def test_run_commands_on_empty_list(self, m_subp):
+ """When provided with an empty list, run_commands does nothing."""
+ run_commands([])
+ self.assertEqual('', self.logs.getvalue())
+ m_subp.assert_not_called()
+
+ def test_run_commands_on_non_list_or_dict(self):
+ """When provided an invalid type, run_commands raises an error."""
+ with self.assertRaises(TypeError) as context_manager:
+ run_commands(commands="I'm Not Valid")
+ self.assertEqual(
+ "commands parameter was not a list or dict: I'm Not Valid",
+ str(context_manager.exception))
+
+ def test_run_command_logs_commands_and_exit_codes_to_stderr(self):
+ """All exit codes are logged to stderr."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+
+ cmd1 = 'echo "HI" >> %s' % outfile
+ cmd2 = 'bogus command'
+ cmd3 = 'echo "MOM" >> %s' % outfile
+ commands = [cmd1, cmd2, cmd3]
+
+ mock_path = 'cloudinit.config.cc_snap.sys.stderr'
+ with mock.patch(mock_path, new_callable=StringIO) as m_stderr:
+ with self.assertRaises(RuntimeError) as context_manager:
+ run_commands(commands=commands)
+
+ self.assertIsNotNone(
+ re.search(r'bogus: (command )?not found',
+ str(context_manager.exception)),
+ msg='Expected bogus command not found')
+ expected_stderr_log = '\n'.join([
+ 'Begin run command: {cmd}'.format(cmd=cmd1),
+ 'End run command: exit(0)',
+ 'Begin run command: {cmd}'.format(cmd=cmd2),
+ 'ERROR: End run command: exit(127)',
+ 'Begin run command: {cmd}'.format(cmd=cmd3),
+ 'End run command: exit(0)\n'])
+ self.assertEqual(expected_stderr_log, m_stderr.getvalue())
+
+ def test_run_command_as_lists(self):
+ """When commands are specified as a list, run them in order."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+
+ cmd1 = 'echo "HI" >> %s' % outfile
+ cmd2 = 'echo "MOM" >> %s' % outfile
+ commands = [cmd1, cmd2]
+ mock_path = 'cloudinit.config.cc_snap.sys.stderr'
+ with mock.patch(mock_path, new_callable=StringIO):
+ run_commands(commands=commands)
+
+ self.assertIn(
+ 'DEBUG: Running user-provided snap commands',
+ self.logs.getvalue())
+ self.assertEqual('HI\nMOM\n', util.load_file(outfile))
+ self.assertIn(
+ 'WARNING: Non-snap commands in snap config:', self.logs.getvalue())
+
+ def test_run_command_dict_sorted_as_command_script(self):
+ """When commands are a dict, sort them and run."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+ cmd1 = 'echo "HI" >> %s' % outfile
+ cmd2 = 'echo "MOM" >> %s' % outfile
+ commands = {'02': cmd1, '01': cmd2}
+ mock_path = 'cloudinit.config.cc_snap.sys.stderr'
+ with mock.patch(mock_path, new_callable=StringIO):
+ run_commands(commands=commands)
+
+ expected_messages = [
+ 'DEBUG: Running user-provided snap commands']
+ for message in expected_messages:
+ self.assertIn(message, self.logs.getvalue())
+ self.assertEqual('MOM\nHI\n', util.load_file(outfile))
+
+
+@skipUnlessJsonSchema()
+class TestSchema(CiTestCase):
+
+ with_logs = True
+
+ def test_schema_warns_on_snap_not_as_dict(self):
+ """If the snap configuration is not a dict, emit a warning."""
+ validate_cloudconfig_schema({'snap': 'wrong type'}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nsnap: 'wrong type' is not of type"
+ " 'object'\n",
+ self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.run_commands')
+ def test_schema_disallows_unknown_keys(self, _):
+ """Unknown keys in the snap configuration emit warnings."""
+ validate_cloudconfig_schema(
+ {'snap': {'commands': ['ls'], 'invalid-key': ''}}, schema)
+ self.assertIn(
+ 'WARNING: Invalid config:\nsnap: Additional properties are not'
+ " allowed ('invalid-key' was unexpected)",
+ self.logs.getvalue())
+
+ def test_warn_schema_requires_either_commands_or_assertions(self):
+ """Warn when snap configuration lacks both commands and assertions."""
+ validate_cloudconfig_schema(
+ {'snap': {}}, schema)
+ self.assertIn(
+ 'WARNING: Invalid config:\nsnap: {} does not have enough'
+ ' properties',
+ self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.run_commands')
+ def test_warn_schema_commands_is_not_list_or_dict(self, _):
+ """Warn when snap:commands config is not a list or dict."""
+ validate_cloudconfig_schema(
+ {'snap': {'commands': 'broken'}}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nsnap.commands: 'broken' is not of type"
+ " 'object', 'array'\n",
+ self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.run_commands')
+ def test_warn_schema_when_commands_is_empty(self, _):
+ """Emit warnings when snap:commands is an empty list or dict."""
+ validate_cloudconfig_schema(
+ {'snap': {'commands': []}}, schema)
+ validate_cloudconfig_schema(
+ {'snap': {'commands': {}}}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nsnap.commands: [] is too short\n"
+ "WARNING: Invalid config:\nsnap.commands: {} does not have enough"
+ " properties\n",
+ self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.run_commands')
+ def test_schema_when_commands_are_list_or_dict(self, _):
+ """No warnings when snap:commands are either a list or dict."""
+ validate_cloudconfig_schema(
+ {'snap': {'commands': ['valid']}}, schema)
+ validate_cloudconfig_schema(
+ {'snap': {'commands': {'01': 'also valid'}}}, schema)
+ self.assertEqual('', self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.add_assertions')
+ def test_warn_schema_assertions_is_not_list_or_dict(self, _):
+ """Warn when snap:assertions config is not a list or dict."""
+ validate_cloudconfig_schema(
+ {'snap': {'assertions': 'broken'}}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nsnap.assertions: 'broken' is not of"
+ " type 'object', 'array'\n",
+ self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.add_assertions')
+ def test_warn_schema_when_assertions_is_empty(self, _):
+ """Emit warnings when snap:assertions is an empty list or dict."""
+ validate_cloudconfig_schema(
+ {'snap': {'assertions': []}}, schema)
+ validate_cloudconfig_schema(
+ {'snap': {'assertions': {}}}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nsnap.assertions: [] is too short\n"
+ "WARNING: Invalid config:\nsnap.assertions: {} does not have"
+ " enough properties\n",
+ self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.add_assertions')
+ def test_schema_when_assertions_are_list_or_dict(self, _):
+ """No warnings when snap:assertions are a list or dict."""
+ validate_cloudconfig_schema(
+ {'snap': {'assertions': ['valid']}}, schema)
+ validate_cloudconfig_schema(
+ {'snap': {'assertions': {'01': 'also valid'}}}, schema)
+ self.assertEqual('', self.logs.getvalue())
+
+
+class TestHandle(CiTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestHandle, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ @mock.patch('cloudinit.config.cc_snap.run_commands')
+ @mock.patch('cloudinit.config.cc_snap.add_assertions')
+ @mock.patch('cloudinit.config.cc_snap.validate_cloudconfig_schema')
+ def test_handle_no_config(self, m_schema, m_add, m_run):
+ """When no snap-related configuration is provided, nothing happens."""
+ cfg = {}
+ handle('snap', cfg=cfg, cloud=None, log=self.logger, args=None)
+ self.assertIn(
+ "DEBUG: Skipping module named snap, no 'snap' key in config",
+ self.logs.getvalue())
+ m_schema.assert_not_called()
+ m_add.assert_not_called()
+ m_run.assert_not_called()
+
+ @mock.patch('cloudinit.config.cc_snap.run_commands')
+ @mock.patch('cloudinit.config.cc_snap.add_assertions')
+ @mock.patch('cloudinit.config.cc_snap.maybe_install_squashfuse')
+ def test_handle_skips_squashfuse_when_unconfigured(self, m_squash, m_add,
+ m_run):
+ """When squashfuse_in_container is unset, don't attempt to install."""
+ handle(
+ 'snap', cfg={'snap': {}}, cloud=None, log=self.logger, args=None)
+ handle(
+ 'snap', cfg={'snap': {'squashfuse_in_container': None}},
+ cloud=None, log=self.logger, args=None)
+ handle(
+ 'snap', cfg={'snap': {'squashfuse_in_container': False}},
+ cloud=None, log=self.logger, args=None)
+ self.assertEqual([], m_squash.call_args_list) # No calls
+ # snap configuration missing assertions and commands will default to []
+ self.assertIn(mock.call([]), m_add.call_args_list)
+ self.assertIn(mock.call([]), m_run.call_args_list)
+
+ @mock.patch('cloudinit.config.cc_snap.maybe_install_squashfuse')
+ def test_handle_tries_to_install_squashfuse(self, m_squash):
+ """If squashfuse_in_container is True, try installing squashfuse."""
+ cfg = {'snap': {'squashfuse_in_container': True}}
+ mycloud = FakeCloud(None)
+ handle('snap', cfg=cfg, cloud=mycloud, log=self.logger, args=None)
+ self.assertEqual(
+ [mock.call(mycloud)], m_squash.call_args_list)
+
+ def test_handle_runs_commands_provided(self):
+ """If commands are specified as a list, run them."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+
+ cfg = {
+ 'snap': {'commands': ['echo "HI" >> %s' % outfile,
+ 'echo "MOM" >> %s' % outfile]}}
+ mock_path = 'cloudinit.config.cc_snap.sys.stderr'
+ with mock.patch(mock_path, new_callable=StringIO):
+ handle('snap', cfg=cfg, cloud=None, log=self.logger, args=None)
+ self.assertEqual('HI\nMOM\n', util.load_file(outfile))
+
+ @mock.patch('cloudinit.config.cc_snap.util.subp')
+ def test_handle_adds_assertions(self, m_subp):
+ """Any configured snap assertions are provided to add_assertions."""
+ assert_file = self.tmp_path('snapd.assertions', dir=self.tmp)
+ compare_file = self.tmp_path('comparison', dir=self.tmp)
+ cfg = {
+ 'snap': {'assertions': [SYSTEM_USER_ASSERTION, ACCOUNT_ASSERTION]}}
+ wrap_and_call(
+ 'cloudinit.config.cc_snap',
+ {'ASSERTIONS_FILE': {'new': assert_file}},
+ handle, 'snap', cfg=cfg, cloud=None, log=self.logger, args=None)
+ content = '\n'.join(cfg['snap']['assertions'])
+ util.write_file(compare_file, content.encode('utf-8'))
+ self.assertEqual(
+ util.load_file(compare_file), util.load_file(assert_file))
+
+ @mock.patch('cloudinit.config.cc_snap.util.subp')
+ @skipUnlessJsonSchema()
+ def test_handle_validates_schema(self, m_subp):
+ """Any provided configuration is runs validate_cloudconfig_schema."""
+ assert_file = self.tmp_path('snapd.assertions', dir=self.tmp)
+ cfg = {'snap': {'invalid': ''}} # Generates schema warning
+ wrap_and_call(
+ 'cloudinit.config.cc_snap',
+ {'ASSERTIONS_FILE': {'new': assert_file}},
+ handle, 'snap', cfg=cfg, cloud=None, log=self.logger, args=None)
+ self.assertEqual(
+ "WARNING: Invalid config:\nsnap: Additional properties are not"
+ " allowed ('invalid' was unexpected)\n",
+ self.logs.getvalue())
+
+
+class TestMaybeInstallSquashFuse(CiTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestMaybeInstallSquashFuse, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ @mock.patch('cloudinit.config.cc_snap.util.is_container')
+ def test_maybe_install_squashfuse_skips_non_containers(self, m_container):
+ """maybe_install_squashfuse does nothing when not on a container."""
+ m_container.return_value = False
+ maybe_install_squashfuse(cloud=FakeCloud(None))
+ self.assertEqual([mock.call()], m_container.call_args_list)
+ self.assertEqual('', self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.util.is_container')
+ def test_maybe_install_squashfuse_raises_install_errors(self, m_container):
+ """maybe_install_squashfuse logs and raises package install errors."""
+ m_container.return_value = True
+ distro = mock.MagicMock()
+ distro.update_package_sources.side_effect = RuntimeError(
+ 'Some apt error')
+ with self.assertRaises(RuntimeError) as context_manager:
+ maybe_install_squashfuse(cloud=FakeCloud(distro))
+ self.assertEqual('Some apt error', str(context_manager.exception))
+ self.assertIn('Package update failed\nTraceback', self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.util.is_container')
+ def test_maybe_install_squashfuse_raises_update_errors(self, m_container):
+ """maybe_install_squashfuse logs and raises package update errors."""
+ m_container.return_value = True
+ distro = mock.MagicMock()
+ distro.update_package_sources.side_effect = RuntimeError(
+ 'Some apt error')
+ with self.assertRaises(RuntimeError) as context_manager:
+ maybe_install_squashfuse(cloud=FakeCloud(distro))
+ self.assertEqual('Some apt error', str(context_manager.exception))
+ self.assertIn('Package update failed\nTraceback', self.logs.getvalue())
+
+ @mock.patch('cloudinit.config.cc_snap.util.is_container')
+ def test_maybe_install_squashfuse_happy_path(self, m_container):
+ """maybe_install_squashfuse logs and raises package install errors."""
+ m_container.return_value = True
+ distro = mock.MagicMock() # No errors raised
+ maybe_install_squashfuse(cloud=FakeCloud(distro))
+ self.assertEqual(
+ [mock.call()], distro.update_package_sources.call_args_list)
+ self.assertEqual(
+ [mock.call(['squashfuse'])],
+ distro.install_packages.call_args_list)
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/config/tests/test_ubuntu_advantage.py b/cloudinit/config/tests/test_ubuntu_advantage.py
new file mode 100644
index 00000000..f2a59faf
--- /dev/null
+++ b/cloudinit/config/tests/test_ubuntu_advantage.py
@@ -0,0 +1,269 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+import re
+from six import StringIO
+
+from cloudinit.config.cc_ubuntu_advantage import (
+ handle, maybe_install_ua_tools, run_commands, schema)
+from cloudinit.config.schema import validate_cloudconfig_schema
+from cloudinit import util
+from cloudinit.tests.helpers import CiTestCase, mock, skipUnlessJsonSchema
+
+
+# Module path used in mocks
+MPATH = 'cloudinit.config.cc_ubuntu_advantage'
+
+
+class FakeCloud(object):
+ def __init__(self, distro):
+ self.distro = distro
+
+
+class TestRunCommands(CiTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestRunCommands, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ @mock.patch('%s.util.subp' % MPATH)
+ def test_run_commands_on_empty_list(self, m_subp):
+ """When provided with an empty list, run_commands does nothing."""
+ run_commands([])
+ self.assertEqual('', self.logs.getvalue())
+ m_subp.assert_not_called()
+
+ def test_run_commands_on_non_list_or_dict(self):
+ """When provided an invalid type, run_commands raises an error."""
+ with self.assertRaises(TypeError) as context_manager:
+ run_commands(commands="I'm Not Valid")
+ self.assertEqual(
+ "commands parameter was not a list or dict: I'm Not Valid",
+ str(context_manager.exception))
+
+ def test_run_command_logs_commands_and_exit_codes_to_stderr(self):
+ """All exit codes are logged to stderr."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+
+ cmd1 = 'echo "HI" >> %s' % outfile
+ cmd2 = 'bogus command'
+ cmd3 = 'echo "MOM" >> %s' % outfile
+ commands = [cmd1, cmd2, cmd3]
+
+ mock_path = '%s.sys.stderr' % MPATH
+ with mock.patch(mock_path, new_callable=StringIO) as m_stderr:
+ with self.assertRaises(RuntimeError) as context_manager:
+ run_commands(commands=commands)
+
+ self.assertIsNotNone(
+ re.search(r'bogus: (command )?not found',
+ str(context_manager.exception)),
+ msg='Expected bogus command not found')
+ expected_stderr_log = '\n'.join([
+ 'Begin run command: {cmd}'.format(cmd=cmd1),
+ 'End run command: exit(0)',
+ 'Begin run command: {cmd}'.format(cmd=cmd2),
+ 'ERROR: End run command: exit(127)',
+ 'Begin run command: {cmd}'.format(cmd=cmd3),
+ 'End run command: exit(0)\n'])
+ self.assertEqual(expected_stderr_log, m_stderr.getvalue())
+
+ def test_run_command_as_lists(self):
+ """When commands are specified as a list, run them in order."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+
+ cmd1 = 'echo "HI" >> %s' % outfile
+ cmd2 = 'echo "MOM" >> %s' % outfile
+ commands = [cmd1, cmd2]
+ with mock.patch('%s.sys.stderr' % MPATH, new_callable=StringIO):
+ run_commands(commands=commands)
+
+ self.assertIn(
+ 'DEBUG: Running user-provided ubuntu-advantage commands',
+ self.logs.getvalue())
+ self.assertEqual('HI\nMOM\n', util.load_file(outfile))
+ self.assertIn(
+ 'WARNING: Non-ubuntu-advantage commands in ubuntu-advantage'
+ ' config:',
+ self.logs.getvalue())
+
+ def test_run_command_dict_sorted_as_command_script(self):
+ """When commands are a dict, sort them and run."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+ cmd1 = 'echo "HI" >> %s' % outfile
+ cmd2 = 'echo "MOM" >> %s' % outfile
+ commands = {'02': cmd1, '01': cmd2}
+ with mock.patch('%s.sys.stderr' % MPATH, new_callable=StringIO):
+ run_commands(commands=commands)
+
+ expected_messages = [
+ 'DEBUG: Running user-provided ubuntu-advantage commands']
+ for message in expected_messages:
+ self.assertIn(message, self.logs.getvalue())
+ self.assertEqual('MOM\nHI\n', util.load_file(outfile))
+
+
+@skipUnlessJsonSchema()
+class TestSchema(CiTestCase):
+
+ with_logs = True
+
+ def test_schema_warns_on_ubuntu_advantage_not_as_dict(self):
+ """If ubuntu-advantage configuration is not a dict, emit a warning."""
+ validate_cloudconfig_schema({'ubuntu-advantage': 'wrong type'}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nubuntu-advantage: 'wrong type' is not"
+ " of type 'object'\n",
+ self.logs.getvalue())
+
+ @mock.patch('%s.run_commands' % MPATH)
+ def test_schema_disallows_unknown_keys(self, _):
+ """Unknown keys in ubuntu-advantage configuration emit warnings."""
+ validate_cloudconfig_schema(
+ {'ubuntu-advantage': {'commands': ['ls'], 'invalid-key': ''}},
+ schema)
+ self.assertIn(
+ 'WARNING: Invalid config:\nubuntu-advantage: Additional properties'
+ " are not allowed ('invalid-key' was unexpected)",
+ self.logs.getvalue())
+
+ def test_warn_schema_requires_commands(self):
+ """Warn when ubuntu-advantage configuration lacks commands."""
+ validate_cloudconfig_schema(
+ {'ubuntu-advantage': {}}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nubuntu-advantage: 'commands' is a"
+ " required property\n",
+ self.logs.getvalue())
+
+ @mock.patch('%s.run_commands' % MPATH)
+ def test_warn_schema_commands_is_not_list_or_dict(self, _):
+ """Warn when ubuntu-advantage:commands config is not a list or dict."""
+ validate_cloudconfig_schema(
+ {'ubuntu-advantage': {'commands': 'broken'}}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nubuntu-advantage.commands: 'broken' is"
+ " not of type 'object', 'array'\n",
+ self.logs.getvalue())
+
+ @mock.patch('%s.run_commands' % MPATH)
+ def test_warn_schema_when_commands_is_empty(self, _):
+ """Emit warnings when ubuntu-advantage:commands is empty."""
+ validate_cloudconfig_schema(
+ {'ubuntu-advantage': {'commands': []}}, schema)
+ validate_cloudconfig_schema(
+ {'ubuntu-advantage': {'commands': {}}}, schema)
+ self.assertEqual(
+ "WARNING: Invalid config:\nubuntu-advantage.commands: [] is too"
+ " short\nWARNING: Invalid config:\nubuntu-advantage.commands: {}"
+ " does not have enough properties\n",
+ self.logs.getvalue())
+
+ @mock.patch('%s.run_commands' % MPATH)
+ def test_schema_when_commands_are_list_or_dict(self, _):
+ """No warnings when ubuntu-advantage:commands are a list or dict."""
+ validate_cloudconfig_schema(
+ {'ubuntu-advantage': {'commands': ['valid']}}, schema)
+ validate_cloudconfig_schema(
+ {'ubuntu-advantage': {'commands': {'01': 'also valid'}}}, schema)
+ self.assertEqual('', self.logs.getvalue())
+
+
+class TestHandle(CiTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestHandle, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ @mock.patch('%s.run_commands' % MPATH)
+ @mock.patch('%s.validate_cloudconfig_schema' % MPATH)
+ def test_handle_no_config(self, m_schema, m_run):
+ """When no ua-related configuration is provided, nothing happens."""
+ cfg = {}
+ handle('ua-test', cfg=cfg, cloud=None, log=self.logger, args=None)
+ self.assertIn(
+ "DEBUG: Skipping module named ua-test, no 'ubuntu-advantage' key"
+ " in config",
+ self.logs.getvalue())
+ m_schema.assert_not_called()
+ m_run.assert_not_called()
+
+ @mock.patch('%s.maybe_install_ua_tools' % MPATH)
+ def test_handle_tries_to_install_ubuntu_advantage_tools(self, m_install):
+ """If ubuntu_advantage is provided, try installing ua-tools package."""
+ cfg = {'ubuntu-advantage': {}}
+ mycloud = FakeCloud(None)
+ handle('nomatter', cfg=cfg, cloud=mycloud, log=self.logger, args=None)
+ m_install.assert_called_once_with(mycloud)
+
+ @mock.patch('%s.maybe_install_ua_tools' % MPATH)
+ def test_handle_runs_commands_provided(self, m_install):
+ """When commands are specified as a list, run them."""
+ outfile = self.tmp_path('output.log', dir=self.tmp)
+
+ cfg = {
+ 'ubuntu-advantage': {'commands': ['echo "HI" >> %s' % outfile,
+ 'echo "MOM" >> %s' % outfile]}}
+ mock_path = '%s.sys.stderr' % MPATH
+ with mock.patch(mock_path, new_callable=StringIO):
+ handle('nomatter', cfg=cfg, cloud=None, log=self.logger, args=None)
+ self.assertEqual('HI\nMOM\n', util.load_file(outfile))
+
+
+class TestMaybeInstallUATools(CiTestCase):
+
+ with_logs = True
+
+ def setUp(self):
+ super(TestMaybeInstallUATools, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ @mock.patch('%s.util.which' % MPATH)
+ def test_maybe_install_ua_tools_noop_when_ua_tools_present(self, m_which):
+ """Do nothing if ubuntu-advantage-tools already exists."""
+ m_which.return_value = '/usr/bin/ubuntu-advantage' # already installed
+ distro = mock.MagicMock()
+ distro.update_package_sources.side_effect = RuntimeError(
+ 'Some apt error')
+ maybe_install_ua_tools(cloud=FakeCloud(distro)) # No RuntimeError
+
+ @mock.patch('%s.util.which' % MPATH)
+ def test_maybe_install_ua_tools_raises_update_errors(self, m_which):
+ """maybe_install_ua_tools logs and raises apt update errors."""
+ m_which.return_value = None
+ distro = mock.MagicMock()
+ distro.update_package_sources.side_effect = RuntimeError(
+ 'Some apt error')
+ with self.assertRaises(RuntimeError) as context_manager:
+ maybe_install_ua_tools(cloud=FakeCloud(distro))
+ self.assertEqual('Some apt error', str(context_manager.exception))
+ self.assertIn('Package update failed\nTraceback', self.logs.getvalue())
+
+ @mock.patch('%s.util.which' % MPATH)
+ def test_maybe_install_ua_raises_install_errors(self, m_which):
+ """maybe_install_ua_tools logs and raises package install errors."""
+ m_which.return_value = None
+ distro = mock.MagicMock()
+ distro.update_package_sources.return_value = None
+ distro.install_packages.side_effect = RuntimeError(
+ 'Some install error')
+ with self.assertRaises(RuntimeError) as context_manager:
+ maybe_install_ua_tools(cloud=FakeCloud(distro))
+ self.assertEqual('Some install error', str(context_manager.exception))
+ self.assertIn(
+ 'Failed to install ubuntu-advantage-tools\n', self.logs.getvalue())
+
+ @mock.patch('%s.util.which' % MPATH)
+ def test_maybe_install_ua_tools_happy_path(self, m_which):
+ """maybe_install_ua_tools installs ubuntu-advantage-tools."""
+ m_which.return_value = None
+ distro = mock.MagicMock() # No errors raised
+ maybe_install_ua_tools(cloud=FakeCloud(distro))
+ distro.update_package_sources.assert_called_once_with()
+ distro.install_packages.assert_called_once_with(
+ ['ubuntu-advantage-tools'])
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/distros/arch.py b/cloudinit/distros/arch.py
index f87a3432..b814c8ba 100644
--- a/cloudinit/distros/arch.py
+++ b/cloudinit/distros/arch.py
@@ -129,11 +129,8 @@ class Distro(distros.Distro):
if pkgs is None:
pkgs = []
- cmd = ['pacman']
+ cmd = ['pacman', "-Sy", "--quiet", "--noconfirm"]
# Redirect output
- cmd.append("-Sy")
- cmd.append("--quiet")
- cmd.append("--noconfirm")
if args and isinstance(args, str):
cmd.append(args)
diff --git a/cloudinit/distros/freebsd.py b/cloudinit/distros/freebsd.py
index aa468bca..754d3df6 100644
--- a/cloudinit/distros/freebsd.py
+++ b/cloudinit/distros/freebsd.py
@@ -132,6 +132,12 @@ class Distro(distros.Distro):
LOG.debug("Using network interface %s", bsddev)
return bsddev
+ def _select_hostname(self, hostname, fqdn):
+ # Should be FQDN if available. See rc.conf(5) in FreeBSD
+ if fqdn:
+ return fqdn
+ return hostname
+
def _read_system_hostname(self):
sys_hostname = self._read_hostname(filename=None)
return ('rc.conf', sys_hostname)
diff --git a/cloudinit/distros/opensuse.py b/cloudinit/distros/opensuse.py
index a219e9fb..162dfa05 100644
--- a/cloudinit/distros/opensuse.py
+++ b/cloudinit/distros/opensuse.py
@@ -67,11 +67,10 @@ class Distro(distros.Distro):
if pkgs is None:
pkgs = []
- cmd = ['zypper']
# No user interaction possible, enable non-interactive mode
- cmd.append('--non-interactive')
+ cmd = ['zypper', '--non-interactive']
- # Comand is the operation, such as install
+ # Command is the operation, such as install
if command == 'upgrade':
command = 'update'
cmd.append(command)
diff --git a/cloudinit/ec2_utils.py b/cloudinit/ec2_utils.py
index d6c61e4c..dc3f0fc3 100644
--- a/cloudinit/ec2_utils.py
+++ b/cloudinit/ec2_utils.py
@@ -135,10 +135,8 @@ class MetadataMaterializer(object):
def _skip_retry_on_codes(status_codes, _request_args, cause):
- """Returns if a request should retry based on a given set of codes that
- case retrying to be stopped/skipped.
- """
- return cause.code in status_codes
+ """Returns False if cause.code is in status_codes."""
+ return cause.code not in status_codes
def get_instance_userdata(api_version='latest',
diff --git a/cloudinit/net/cmdline.py b/cloudinit/net/cmdline.py
index 7b2cc9db..9e9fe0fe 100755
--- a/cloudinit/net/cmdline.py
+++ b/cloudinit/net/cmdline.py
@@ -9,12 +9,15 @@ import base64
import glob
import gzip
import io
+import os
from . import get_devicelist
from . import read_sys_net_safe
from cloudinit import util
+_OPEN_ISCSI_INTERFACE_FILE = "/run/initramfs/open-iscsi.interface"
+
def _klibc_to_config_entry(content, mac_addrs=None):
"""Convert a klibc written shell content file to a 'config' entry
@@ -103,9 +106,13 @@ def _klibc_to_config_entry(content, mac_addrs=None):
return name, iface
+def _get_klibc_net_cfg_files():
+ return glob.glob('/run/net-*.conf') + glob.glob('/run/net6-*.conf')
+
+
def config_from_klibc_net_cfg(files=None, mac_addrs=None):
if files is None:
- files = glob.glob('/run/net-*.conf') + glob.glob('/run/net6-*.conf')
+ files = _get_klibc_net_cfg_files()
entries = []
names = {}
@@ -160,10 +167,23 @@ def _b64dgz(b64str, gzipped="try"):
return _decomp_gzip(blob, strict=gzipped != "try")
+def _is_initramfs_netconfig(files, cmdline):
+ if files:
+ if 'ip=' in cmdline or 'ip6=' in cmdline:
+ return True
+ if os.path.exists(_OPEN_ISCSI_INTERFACE_FILE):
+ # iBft can configure networking without ip=
+ return True
+ return False
+
+
def read_kernel_cmdline_config(files=None, mac_addrs=None, cmdline=None):
if cmdline is None:
cmdline = util.get_cmdline()
+ if files is None:
+ files = _get_klibc_net_cfg_files()
+
if 'network-config=' in cmdline:
data64 = None
for tok in cmdline.split():
@@ -172,7 +192,7 @@ def read_kernel_cmdline_config(files=None, mac_addrs=None, cmdline=None):
if data64:
return util.load_yaml(_b64dgz(data64))
- if 'ip=' not in cmdline and 'ip6=' not in cmdline:
+ if not _is_initramfs_netconfig(files, cmdline):
return None
if mac_addrs is None:
diff --git a/cloudinit/net/netplan.py b/cloudinit/net/netplan.py
index d3788af8..63443484 100644
--- a/cloudinit/net/netplan.py
+++ b/cloudinit/net/netplan.py
@@ -311,12 +311,12 @@ class Renderer(renderer.Renderer):
if newname is None:
continue
br_config.update({newname: value})
- if newname == 'path-cost':
- # <interface> <cost> -> <interface>: int(<cost>)
+ if newname in ['path-cost', 'port-priority']:
+ # <interface> <value> -> <interface>: int(<value>)
newvalue = {}
- for costval in value:
- (port, cost) = costval.split()
- newvalue[port] = int(cost)
+ for val in value:
+ (port, portval) = val.split()
+ newvalue[port] = int(portval)
br_config.update({newname: newvalue})
if len(br_config) > 0:
@@ -336,22 +336,15 @@ class Renderer(renderer.Renderer):
_extract_addresses(ifcfg, vlan)
vlans.update({ifname: vlan})
- # inject global nameserver values under each physical interface
- if nameservers:
- for _eth, cfg in ethernets.items():
- nscfg = cfg.get('nameservers', {})
- addresses = nscfg.get('addresses', [])
- addresses += nameservers
- nscfg.update({'addresses': addresses})
- cfg.update({'nameservers': nscfg})
-
- if searchdomains:
- for _eth, cfg in ethernets.items():
- nscfg = cfg.get('nameservers', {})
- search = nscfg.get('search', [])
- search += searchdomains
- nscfg.update({'search': search})
- cfg.update({'nameservers': nscfg})
+ # inject global nameserver values under each all interface which
+ # has addresses and do not already have a DNS configuration
+ if nameservers or searchdomains:
+ nscfg = {'addresses': nameservers, 'search': searchdomains}
+ for section in [ethernets, wifis, bonds, bridges, vlans]:
+ for _name, cfg in section.items():
+ if 'nameservers' in cfg or 'addresses' not in cfg:
+ continue
+ cfg.update({'nameservers': nscfg})
# workaround yaml dictionary key sorting when dumping
def _render_section(name, section):
diff --git a/cloudinit/net/network_state.py b/cloudinit/net/network_state.py
index fe667d88..6d63e5c5 100644
--- a/cloudinit/net/network_state.py
+++ b/cloudinit/net/network_state.py
@@ -47,7 +47,7 @@ NET_CONFIG_TO_V2 = {
'bridge_maxage': 'max-age',
'bridge_maxwait': None,
'bridge_pathcost': 'path-cost',
- 'bridge_portprio': None,
+ 'bridge_portprio': 'port-priority',
'bridge_stp': 'stp',
'bridge_waitport': None}}
@@ -708,6 +708,7 @@ class NetworkStateInterpreter(object):
gateway4 = None
gateway6 = None
+ nameservers = {}
for address in cfg.get('addresses', []):
subnet = {
'type': 'static',
@@ -723,6 +724,15 @@ class NetworkStateInterpreter(object):
gateway4 = cfg.get('gateway4')
subnet.update({'gateway': gateway4})
+ if 'nameservers' in cfg and not nameservers:
+ addresses = cfg.get('nameservers').get('addresses')
+ if addresses:
+ nameservers['dns_nameservers'] = addresses
+ search = cfg.get('nameservers').get('search')
+ if search:
+ nameservers['dns_search'] = search
+ subnet.update(nameservers)
+
subnets.append(subnet)
routes = []
diff --git a/cloudinit/settings.py b/cloudinit/settings.py
index c120498f..dde5749d 100644
--- a/cloudinit/settings.py
+++ b/cloudinit/settings.py
@@ -36,6 +36,8 @@ CFG_BUILTIN = {
'SmartOS',
'Bigstep',
'Scaleway',
+ 'Hetzner',
+ 'IBMCloud',
# At the end to act as a 'catch' when none of the above work...
'None',
],
diff --git a/cloudinit/sources/DataSourceAliYun.py b/cloudinit/sources/DataSourceAliYun.py
index 7ac8288d..22279d09 100644
--- a/cloudinit/sources/DataSourceAliYun.py
+++ b/cloudinit/sources/DataSourceAliYun.py
@@ -22,7 +22,7 @@ class DataSourceAliYun(EC2.DataSourceEc2):
super(DataSourceAliYun, self).__init__(sys_cfg, distro, paths)
self.seed_dir = os.path.join(paths.seed_dir, "AliYun")
- def get_hostname(self, fqdn=False, _resolve_ip=False):
+ def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
return self.metadata.get('hostname', 'localhost.localdomain')
def get_public_ssh_keys(self):
diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py
index 4bcbf3a4..0ee622e2 100644
--- a/cloudinit/sources/DataSourceAzure.py
+++ b/cloudinit/sources/DataSourceAzure.py
@@ -20,7 +20,7 @@ from cloudinit import net
from cloudinit.net.dhcp import EphemeralDHCPv4
from cloudinit import sources
from cloudinit.sources.helpers.azure import get_metadata_from_fabric
-from cloudinit.url_helper import readurl, wait_for_url, UrlError
+from cloudinit.url_helper import readurl, UrlError
from cloudinit import util
LOG = logging.getLogger(__name__)
@@ -49,7 +49,6 @@ DEFAULT_FS = 'ext4'
AZURE_CHASSIS_ASSET_TAG = '7783-7084-3265-9085-8269-3286-77'
REPROVISION_MARKER_FILE = "/var/lib/cloud/data/poll_imds"
IMDS_URL = "http://169.254.169.254/metadata/reprovisiondata"
-IMDS_RETRIES = 5
def find_storvscid_from_sysctl_pnpinfo(sysctl_out, deviceid):
@@ -223,6 +222,8 @@ DEF_PASSWD_REDACTION = 'REDACTED'
def get_hostname(hostname_command='hostname'):
+ if not isinstance(hostname_command, (list, tuple)):
+ hostname_command = (hostname_command,)
return util.subp(hostname_command, capture=True)[0].strip()
@@ -449,36 +450,24 @@ class DataSourceAzure(sources.DataSource):
headers = {"Metadata": "true"}
LOG.debug("Start polling IMDS")
- def sleep_cb(response, loop_n):
- return 1
-
- def exception_cb(msg, exception):
+ def exc_cb(msg, exception):
if isinstance(exception, UrlError) and exception.code == 404:
- return
- LOG.warning("Exception during polling. Will try DHCP.",
- exc_info=True)
-
+ return True
# If we get an exception while trying to call IMDS, we
# call DHCP and setup the ephemeral network to acquire the new IP.
- raise exception
+ return False
need_report = report_ready
- for i in range(IMDS_RETRIES):
+ while True:
try:
with EphemeralDHCPv4() as lease:
if need_report:
self._report_ready(lease=lease)
need_report = False
- wait_for_url([url], max_wait=None, timeout=60,
- status_cb=LOG.info,
- headers_cb=lambda url: headers, sleep_time=1,
- exception_cb=exception_cb,
- sleep_time_cb=sleep_cb)
- return str(readurl(url, headers=headers))
- except Exception:
- LOG.debug("Exception during polling-retrying dhcp" +
- " %d more time(s).", (IMDS_RETRIES - i),
- exc_info=True)
+ return readurl(url, timeout=1, headers=headers,
+ exception_cb=exc_cb, infinite=True).contents
+ except UrlError:
+ pass
def _report_ready(self, lease):
"""Tells the fabric provisioning has completed
diff --git a/cloudinit/sources/DataSourceCloudSigma.py b/cloudinit/sources/DataSourceCloudSigma.py
index 4eaad475..c816f349 100644
--- a/cloudinit/sources/DataSourceCloudSigma.py
+++ b/cloudinit/sources/DataSourceCloudSigma.py
@@ -84,7 +84,7 @@ class DataSourceCloudSigma(sources.DataSource):
return True
- def get_hostname(self, fqdn=False, resolve_ip=False):
+ def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
"""
Cleans up and uses the server's name if the latter is set. Otherwise
the first part from uuid is being used.
diff --git a/cloudinit/sources/DataSourceConfigDrive.py b/cloudinit/sources/DataSourceConfigDrive.py
index b8db6267..c7b5fe5f 100644
--- a/cloudinit/sources/DataSourceConfigDrive.py
+++ b/cloudinit/sources/DataSourceConfigDrive.py
@@ -14,6 +14,7 @@ from cloudinit import util
from cloudinit.net import eni
+from cloudinit.sources.DataSourceIBMCloud import get_ibm_platform
from cloudinit.sources.helpers import openstack
LOG = logging.getLogger(__name__)
@@ -255,6 +256,15 @@ def find_candidate_devs(probe_optical=True):
# an unpartitioned block device (ex sda, not sda1)
devices = [d for d in candidates
if d in by_label or not util.is_partition(d)]
+
+ if devices:
+ # IBMCloud uses config-2 label, but limited to a single UUID.
+ ibm_platform, ibm_path = get_ibm_platform()
+ if ibm_path in devices:
+ devices.remove(ibm_path)
+ LOG.debug("IBMCloud device '%s' (%s) removed from candidate list",
+ ibm_path, ibm_platform)
+
return devices
diff --git a/cloudinit/sources/DataSourceGCE.py b/cloudinit/sources/DataSourceGCE.py
index 2da34a99..d8162623 100644
--- a/cloudinit/sources/DataSourceGCE.py
+++ b/cloudinit/sources/DataSourceGCE.py
@@ -90,7 +90,7 @@ class DataSourceGCE(sources.DataSource):
public_keys_data = self.metadata['public-keys-data']
return _parse_public_keys(public_keys_data, self.default_user)
- def get_hostname(self, fqdn=False, resolve_ip=False):
+ def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
# GCE has long FDQN's and has asked for short hostnames.
return self.metadata['local-hostname'].split('.')[0]
@@ -213,16 +213,15 @@ def read_md(address=None, platform_check=True):
if md['availability-zone']:
md['availability-zone'] = md['availability-zone'].split('/')[-1]
- encoding = instance_data.get('user-data-encoding')
- if encoding:
+ if 'user-data' in instance_data:
+ # instance_data was json, so values are all utf-8 strings.
+ ud = instance_data['user-data'].encode("utf-8")
+ encoding = instance_data.get('user-data-encoding')
if encoding == 'base64':
- md['user-data'] = b64decode(instance_data.get('user-data'))
- else:
+ ud = b64decode(ud)
+ elif encoding:
LOG.warning('unknown user-data-encoding: %s, ignoring', encoding)
-
- if 'user-data' in md:
- ret['user-data'] = md['user-data']
- del md['user-data']
+ ret['user-data'] = ud
ret['meta-data'] = md
ret['success'] = True
diff --git a/cloudinit/sources/DataSourceHetzner.py b/cloudinit/sources/DataSourceHetzner.py
new file mode 100644
index 00000000..5c75b65b
--- /dev/null
+++ b/cloudinit/sources/DataSourceHetzner.py
@@ -0,0 +1,106 @@
+# Author: Jonas Keidel <jonas.keidel@hetzner.com>
+# Author: Markus Schade <markus.schade@hetzner.com>
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+#
+"""Hetzner Cloud API Documentation.
+ https://docs.hetzner.cloud/"""
+
+from cloudinit import log as logging
+from cloudinit import net as cloudnet
+from cloudinit import sources
+from cloudinit import util
+
+import cloudinit.sources.helpers.hetzner as hc_helper
+
+LOG = logging.getLogger(__name__)
+
+BASE_URL_V1 = 'http://169.254.169.254/hetzner/v1'
+
+BUILTIN_DS_CONFIG = {
+ 'metadata_url': BASE_URL_V1 + '/metadata',
+ 'userdata_url': BASE_URL_V1 + '/userdata',
+}
+
+MD_RETRIES = 60
+MD_TIMEOUT = 2
+MD_WAIT_RETRY = 2
+
+
+class DataSourceHetzner(sources.DataSource):
+ def __init__(self, sys_cfg, distro, paths):
+ sources.DataSource.__init__(self, sys_cfg, distro, paths)
+ self.distro = distro
+ self.metadata = dict()
+ self.ds_cfg = util.mergemanydict([
+ util.get_cfg_by_path(sys_cfg, ["datasource", "Hetzner"], {}),
+ BUILTIN_DS_CONFIG])
+ self.metadata_address = self.ds_cfg['metadata_url']
+ self.userdata_address = self.ds_cfg['userdata_url']
+ self.retries = self.ds_cfg.get('retries', MD_RETRIES)
+ self.timeout = self.ds_cfg.get('timeout', MD_TIMEOUT)
+ self.wait_retry = self.ds_cfg.get('wait_retry', MD_WAIT_RETRY)
+ self._network_config = None
+ self.dsmode = sources.DSMODE_NETWORK
+
+ def get_data(self):
+ if not on_hetzner():
+ return False
+ nic = cloudnet.find_fallback_nic()
+ with cloudnet.EphemeralIPv4Network(nic, "169.254.0.1", 16,
+ "169.254.255.255"):
+ md = hc_helper.read_metadata(
+ self.metadata_address, timeout=self.timeout,
+ sec_between=self.wait_retry, retries=self.retries)
+ ud = hc_helper.read_userdata(
+ self.userdata_address, timeout=self.timeout,
+ sec_between=self.wait_retry, retries=self.retries)
+
+ self.userdata_raw = ud
+ self.metadata_full = md
+
+ """hostname is name provided by user at launch. The API enforces
+ it is a valid hostname, but it is not guaranteed to be resolvable
+ in dns or fully qualified."""
+ self.metadata['instance-id'] = md['instance-id']
+ self.metadata['local-hostname'] = md['hostname']
+ self.metadata['network-config'] = md.get('network-config', None)
+ self.metadata['public-keys'] = md.get('public-keys', None)
+ self.vendordata_raw = md.get("vendor_data", None)
+
+ return True
+
+ @property
+ def network_config(self):
+ """Configure the networking. This needs to be done each boot, since
+ the IP information may have changed due to snapshot and/or
+ migration.
+ """
+
+ if self._network_config:
+ return self._network_config
+
+ _net_config = self.metadata['network-config']
+ if not _net_config:
+ raise Exception("Unable to get meta-data from server....")
+
+ self._network_config = _net_config
+
+ return self._network_config
+
+
+def on_hetzner():
+ return util.read_dmi_data('system-manufacturer') == "Hetzner"
+
+
+# Used to match classes to dependencies
+datasources = [
+ (DataSourceHetzner, (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)
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/sources/DataSourceIBMCloud.py b/cloudinit/sources/DataSourceIBMCloud.py
new file mode 100644
index 00000000..02b3d56f
--- /dev/null
+++ b/cloudinit/sources/DataSourceIBMCloud.py
@@ -0,0 +1,325 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+"""Datasource for IBMCloud.
+
+IBMCloud is also know as SoftLayer or BlueMix.
+IBMCloud hypervisor is xen (2018-03-10).
+
+There are 2 different api exposed launch methods.
+ * template: This is the legacy method of launching instances.
+ When booting from an image template, the system boots first into
+ a "provisioning" mode. There, host <-> guest mechanisms are utilized
+ to execute code in the guest and provision it.
+
+ Cloud-init will disable itself when it detects that it is in the
+ provisioning mode. It detects this by the presence of
+ a file '/root/provisioningConfiguration.cfg'.
+
+ When provided with user-data, the "first boot" will contain a
+ ConfigDrive-like disk labeled with 'METADATA'. If there is no user-data
+ provided, then there is no data-source.
+
+ Cloud-init never does any network configuration in this mode.
+
+ * os_code: Essentially "launch by OS Code" (Operating System Code).
+ This is a more modern approach. There is no specific "provisioning" boot.
+ Instead, cloud-init does all the customization. With or without
+ user-data provided, an OpenStack ConfigDrive like disk is attached.
+
+ Only disks with label 'config-2' and UUID '9796-932E' are considered.
+ This is to avoid this datasource claiming ConfigDrive. This does
+ mean that 1 in 8^16 (~4 billion) Xen ConfigDrive systems will be
+ incorrectly identified as IBMCloud.
+
+TODO:
+ * is uuid (/sys/hypervisor/uuid) stable for life of an instance?
+ it seems it is not the same as data's uuid in the os_code case
+ but is in the template case.
+
+"""
+import base64
+import json
+import os
+
+from cloudinit import log as logging
+from cloudinit import sources
+from cloudinit.sources.helpers import openstack
+from cloudinit import util
+
+LOG = logging.getLogger(__name__)
+
+IBM_CONFIG_UUID = "9796-932E"
+
+
+class Platforms(object):
+ TEMPLATE_LIVE_METADATA = "Template/Live/Metadata"
+ TEMPLATE_LIVE_NODATA = "UNABLE TO BE IDENTIFIED."
+ TEMPLATE_PROVISIONING_METADATA = "Template/Provisioning/Metadata"
+ TEMPLATE_PROVISIONING_NODATA = "Template/Provisioning/No-Metadata"
+ OS_CODE = "OS-Code/Live"
+
+
+PROVISIONING = (
+ Platforms.TEMPLATE_PROVISIONING_METADATA,
+ Platforms.TEMPLATE_PROVISIONING_NODATA)
+
+
+class DataSourceIBMCloud(sources.DataSource):
+
+ dsname = 'IBMCloud'
+ system_uuid = None
+
+ def __init__(self, sys_cfg, distro, paths):
+ super(DataSourceIBMCloud, self).__init__(sys_cfg, distro, paths)
+ self.source = None
+ self._network_config = None
+ self.network_json = None
+ self.platform = None
+
+ def __str__(self):
+ root = super(DataSourceIBMCloud, self).__str__()
+ mstr = "%s [%s %s]" % (root, self.platform, self.source)
+ return mstr
+
+ def _get_data(self):
+ results = read_md()
+ if results is None:
+ return False
+
+ self.source = results['source']
+ self.platform = results['platform']
+ self.metadata = results['metadata']
+ self.userdata_raw = results.get('userdata')
+ self.network_json = results.get('networkdata')
+ vd = results.get('vendordata')
+ self.vendordata_pure = vd
+ self.system_uuid = results['system-uuid']
+ try:
+ self.vendordata_raw = sources.convert_vendordata(vd)
+ except ValueError as e:
+ LOG.warning("Invalid content in vendor-data: %s", e)
+ self.vendordata_raw = None
+
+ return True
+
+ def check_instance_id(self, sys_cfg):
+ """quickly (local check only) if self.instance_id is still valid
+
+ in Template mode, the system uuid (/sys/hypervisor/uuid) is the
+ same as found in the METADATA disk. But that is not true in OS_CODE
+ mode. So we read the system_uuid and keep that for later compare."""
+ if self.system_uuid is None:
+ return False
+ return self.system_uuid == _read_system_uuid()
+
+ @property
+ def network_config(self):
+ if self.platform != Platforms.OS_CODE:
+ # If deployed from template, an agent in the provisioning
+ # environment handles networking configuration. Not cloud-init.
+ return {'config': 'disabled', 'version': 1}
+ if self._network_config is None:
+ if self.network_json is not None:
+ LOG.debug("network config provided via network_json")
+ self._network_config = openstack.convert_net_json(
+ self.network_json, known_macs=None)
+ else:
+ LOG.debug("no network configuration available.")
+ return self._network_config
+
+
+def _read_system_uuid():
+ uuid_path = "/sys/hypervisor/uuid"
+ if not os.path.isfile(uuid_path):
+ return None
+ return util.load_file(uuid_path).strip().lower()
+
+
+def _is_xen():
+ return os.path.exists("/proc/xen")
+
+
+def _is_ibm_provisioning():
+ return os.path.exists("/root/provisioningConfiguration.cfg")
+
+
+def get_ibm_platform():
+ """Return a tuple (Platform, path)
+
+ If this is Not IBM cloud, then the return value is (None, None).
+ An instance in provisioning mode is considered running on IBM cloud."""
+ label_mdata = "METADATA"
+ label_cfg2 = "CONFIG-2"
+ not_found = (None, None)
+
+ if not _is_xen():
+ return not_found
+
+ # fslabels contains only the first entry with a given label.
+ fslabels = {}
+ try:
+ devs = util.blkid()
+ except util.ProcessExecutionError as e:
+ LOG.warning("Failed to run blkid: %s", e)
+ return (None, None)
+
+ for dev in sorted(devs.keys()):
+ data = devs[dev]
+ label = data.get("LABEL", "").upper()
+ uuid = data.get("UUID", "").upper()
+ if label not in (label_mdata, label_cfg2):
+ continue
+ if label in fslabels:
+ LOG.warning("Duplicate fslabel '%s'. existing=%s current=%s",
+ label, fslabels[label], data)
+ continue
+ if label == label_cfg2 and uuid != IBM_CONFIG_UUID:
+ LOG.debug("Skipping %s with LABEL=%s due to uuid != %s: %s",
+ dev, label, uuid, data)
+ continue
+ fslabels[label] = data
+
+ metadata_path = fslabels.get(label_mdata, {}).get('DEVNAME')
+ cfg2_path = fslabels.get(label_cfg2, {}).get('DEVNAME')
+
+ if cfg2_path:
+ return (Platforms.OS_CODE, cfg2_path)
+ elif metadata_path:
+ if _is_ibm_provisioning():
+ return (Platforms.TEMPLATE_PROVISIONING_METADATA, metadata_path)
+ else:
+ return (Platforms.TEMPLATE_LIVE_METADATA, metadata_path)
+ elif _is_ibm_provisioning():
+ return (Platforms.TEMPLATE_PROVISIONING_NODATA, None)
+ return not_found
+
+
+def read_md():
+ """Read data from IBM Cloud.
+
+ @return: None if not running on IBM Cloud.
+ dictionary with guaranteed fields: metadata, version
+ and optional fields: userdata, vendordata, networkdata.
+ Also includes the system uuid from /sys/hypervisor/uuid."""
+ platform, path = get_ibm_platform()
+ if platform is None:
+ LOG.debug("This is not an IBMCloud platform.")
+ return None
+ elif platform in PROVISIONING:
+ LOG.debug("Cloud-init is disabled during provisioning: %s.",
+ platform)
+ return None
+
+ ret = {'platform': platform, 'source': path,
+ 'system-uuid': _read_system_uuid()}
+
+ try:
+ if os.path.isdir(path):
+ results = metadata_from_dir(path)
+ else:
+ results = util.mount_cb(path, metadata_from_dir)
+ except BrokenMetadata as e:
+ raise RuntimeError(
+ "Failed reading IBM config disk (platform=%s path=%s): %s" %
+ (platform, path, e))
+
+ ret.update(results)
+ return ret
+
+
+class BrokenMetadata(IOError):
+ pass
+
+
+def metadata_from_dir(source_dir):
+ """Walk source_dir extracting standardized metadata.
+
+ Certain metadata keys are renamed to present a standardized set of metadata
+ keys.
+
+ This function has a lot in common with ConfigDriveReader.read_v2 but
+ there are a number of inconsistencies, such key renames and as only
+ presenting a 'latest' version which make it an unlikely candidate to share
+ code.
+
+ @return: Dict containing translated metadata, userdata, vendordata,
+ networkdata as present.
+ """
+
+ def opath(fname):
+ return os.path.join("openstack", "latest", fname)
+
+ def load_json_bytes(blob):
+ return json.loads(blob.decode('utf-8'))
+
+ files = [
+ # tuples of (results_name, path, translator)
+ ('metadata_raw', opath('meta_data.json'), load_json_bytes),
+ ('userdata', opath('user_data'), None),
+ ('vendordata', opath('vendor_data.json'), load_json_bytes),
+ ('networkdata', opath('network_data.json'), load_json_bytes),
+ ]
+
+ results = {}
+ for (name, path, transl) in files:
+ fpath = os.path.join(source_dir, path)
+ raw = None
+ try:
+ raw = util.load_file(fpath, decode=False)
+ except IOError as e:
+ LOG.debug("Failed reading path '%s': %s", fpath, e)
+
+ if raw is None or transl is None:
+ data = raw
+ else:
+ try:
+ data = transl(raw)
+ except Exception as e:
+ raise BrokenMetadata("Failed decoding %s: %s" % (path, e))
+
+ results[name] = data
+
+ if results.get('metadata_raw') is None:
+ raise BrokenMetadata(
+ "%s missing required file 'meta_data.json'" % source_dir)
+
+ results['metadata'] = {}
+
+ md_raw = results['metadata_raw']
+ md = results['metadata']
+ if 'random_seed' in md_raw:
+ try:
+ md['random_seed'] = base64.b64decode(md_raw['random_seed'])
+ except (ValueError, TypeError) as e:
+ raise BrokenMetadata(
+ "Badly formatted metadata random_seed entry: %s" % e)
+
+ renames = (
+ ('public_keys', 'public-keys'), ('hostname', 'local-hostname'),
+ ('uuid', 'instance-id'))
+ for mdname, newname in renames:
+ if mdname in md_raw:
+ md[newname] = md_raw[mdname]
+
+ return results
+
+
+# Used to match classes to dependencies
+datasources = [
+ (DataSourceIBMCloud, (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
+
+ parser = argparse.ArgumentParser(description='Query IBM Cloud Metadata')
+ args = parser.parse_args()
+ data = read_md()
+ print(util.json_dumps(data))
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/sources/DataSourceOVF.py b/cloudinit/sources/DataSourceOVF.py
index 6e62f984..dc914a72 100644
--- a/cloudinit/sources/DataSourceOVF.py
+++ b/cloudinit/sources/DataSourceOVF.py
@@ -95,11 +95,20 @@ class DataSourceOVF(sources.DataSource):
"VMware Customization support")
elif not util.get_cfg_option_bool(
self.sys_cfg, "disable_vmware_customization", True):
- deployPkgPluginPath = search_file("/usr/lib/vmware-tools",
- "libdeployPkgPlugin.so")
- if not deployPkgPluginPath:
- deployPkgPluginPath = search_file("/usr/lib/open-vm-tools",
- "libdeployPkgPlugin.so")
+
+ search_paths = (
+ "/usr/lib/vmware-tools", "/usr/lib64/vmware-tools",
+ "/usr/lib/open-vm-tools", "/usr/lib64/open-vm-tools")
+
+ plugin = "libdeployPkgPlugin.so"
+ deployPkgPluginPath = None
+ for path in search_paths:
+ deployPkgPluginPath = search_file(path, plugin)
+ if deployPkgPluginPath:
+ LOG.debug("Found the customization plugin at %s",
+ deployPkgPluginPath)
+ break
+
if deployPkgPluginPath:
# When the VM is powered on, the "VMware Tools" daemon
# copies the customization specification file to
@@ -111,6 +120,8 @@ class DataSourceOVF(sources.DataSource):
msg="waiting for configuration file",
func=wait_for_imc_cfg_file,
args=("cust.cfg", max_wait))
+ else:
+ LOG.debug("Did not find the customization plugin.")
if vmwareImcConfigFilePath:
LOG.debug("Found VMware Customization Config File at %s",
diff --git a/cloudinit/sources/DataSourceOpenNebula.py b/cloudinit/sources/DataSourceOpenNebula.py
index ce47b6bd..d4a41116 100644
--- a/cloudinit/sources/DataSourceOpenNebula.py
+++ b/cloudinit/sources/DataSourceOpenNebula.py
@@ -20,7 +20,6 @@ import string
from cloudinit import log as logging
from cloudinit import net
-from cloudinit.net import eni
from cloudinit import sources
from cloudinit import util
@@ -91,19 +90,19 @@ class DataSourceOpenNebula(sources.DataSource):
return False
self.seed = seed
- self.network_eni = results.get('network-interfaces')
+ self.network = results.get('network-interfaces')
self.metadata = md
self.userdata_raw = results.get('userdata')
return True
@property
def network_config(self):
- if self.network_eni is not None:
- return eni.convert_eni_data(self.network_eni)
+ if self.network is not None:
+ return self.network
else:
return None
- def get_hostname(self, fqdn=False, resolve_ip=None):
+ def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
if resolve_ip is None:
if self.dsmode == sources.DSMODE_NETWORK:
resolve_ip = True
@@ -143,18 +142,42 @@ class OpenNebulaNetwork(object):
def mac2network(self, mac):
return self.mac2ip(mac).rpartition(".")[0] + ".0"
- def get_dns(self, dev):
- return self.get_field(dev, "dns", "").split()
+ def get_nameservers(self, dev):
+ nameservers = {}
+ dns = self.get_field(dev, "dns", "").split()
+ dns.extend(self.context.get('DNS', "").split())
+ if dns:
+ nameservers['addresses'] = dns
+ search_domain = self.get_field(dev, "search_domain", "").split()
+ if search_domain:
+ nameservers['search'] = search_domain
+ return nameservers
- def get_domain(self, dev):
- return self.get_field(dev, "domain")
+ def get_mtu(self, dev):
+ return self.get_field(dev, "mtu")
def get_ip(self, dev, mac):
return self.get_field(dev, "ip", self.mac2ip(mac))
+ def get_ip6(self, dev):
+ addresses6 = []
+ ip6 = self.get_field(dev, "ip6")
+ if ip6:
+ addresses6.append(ip6)
+ ip6_ula = self.get_field(dev, "ip6_ula")
+ if ip6_ula:
+ addresses6.append(ip6_ula)
+ return addresses6
+
+ def get_ip6_prefix(self, dev):
+ return self.get_field(dev, "ip6_prefix_length", "64")
+
def get_gateway(self, dev):
return self.get_field(dev, "gateway")
+ def get_gateway6(self, dev):
+ return self.get_field(dev, "gateway6")
+
def get_mask(self, dev):
return self.get_field(dev, "mask", "255.255.255.0")
@@ -171,13 +194,11 @@ class OpenNebulaNetwork(object):
return default if val in (None, "") else val
def gen_conf(self):
- global_dns = self.context.get('DNS', "").split()
-
- conf = []
- conf.append('auto lo')
- conf.append('iface lo inet loopback')
- conf.append('')
+ netconf = {}
+ netconf['version'] = 2
+ netconf['ethernets'] = {}
+ ethernets = {}
for mac, dev in self.ifaces.items():
mac = mac.lower()
@@ -185,29 +206,49 @@ class OpenNebulaNetwork(object):
# dev stores the current system name.
c_dev = self.context_devname.get(mac, dev)
- conf.append('auto ' + dev)
- conf.append('iface ' + dev + ' inet static')
- conf.append(' #hwaddress %s' % mac)
- conf.append(' address ' + self.get_ip(c_dev, mac))
- conf.append(' network ' + self.get_network(c_dev, mac))
- conf.append(' netmask ' + self.get_mask(c_dev))
+ devconf = {}
+
+ # Set MAC address
+ devconf['match'] = {'macaddress': mac}
+ # Set IPv4 address
+ devconf['addresses'] = []
+ mask = self.get_mask(c_dev)
+ prefix = str(net.mask_to_net_prefix(mask))
+ devconf['addresses'].append(
+ self.get_ip(c_dev, mac) + '/' + prefix)
+
+ # Set IPv6 Global and ULA address
+ addresses6 = self.get_ip6(c_dev)
+ if addresses6:
+ prefix6 = self.get_ip6_prefix(c_dev)
+ devconf['addresses'].extend(
+ [i + '/' + prefix6 for i in addresses6])
+
+ # Set IPv4 default gateway
gateway = self.get_gateway(c_dev)
if gateway:
- conf.append(' gateway ' + gateway)
+ devconf['gateway4'] = gateway
+
+ # Set IPv6 default gateway
+ gateway6 = self.get_gateway6(c_dev)
+ if gateway:
+ devconf['gateway6'] = gateway6
- domain = self.get_domain(c_dev)
- if domain:
- conf.append(' dns-search ' + domain)
+ # Set DNS servers and search domains
+ nameservers = self.get_nameservers(c_dev)
+ if nameservers:
+ devconf['nameservers'] = nameservers
- # add global DNS servers to all interfaces
- dns = self.get_dns(c_dev)
- if global_dns or dns:
- conf.append(' dns-nameservers ' + ' '.join(global_dns + dns))
+ # Set MTU size
+ mtu = self.get_mtu(c_dev)
+ if mtu:
+ devconf['mtu'] = mtu
- conf.append('')
+ ethernets[dev] = devconf
- return "\n".join(conf)
+ netconf['ethernets'] = ethernets
+ return(netconf)
def find_candidate_devs():
@@ -393,10 +434,10 @@ def read_context_disk_dir(source_dir, asuser=None):
except TypeError:
LOG.warning("Failed base64 decoding of userdata")
- # generate static /etc/network/interfaces
+ # generate Network Configuration v2
# only if there are any required context variables
- # http://opennebula.org/documentation:rel3.8:cong#network_configuration
- ipaddr_keys = [k for k in context if re.match(r'^ETH\d+_IP$', k)]
+ # http://docs.opennebula.org/5.4/operation/references/template.html#context-section
+ ipaddr_keys = [k for k in context if re.match(r'^ETH\d+_IP.*$', k)]
if ipaddr_keys:
onet = OpenNebulaNetwork(context)
results['network-interfaces'] = onet.gen_conf()
diff --git a/cloudinit/sources/DataSourceScaleway.py b/cloudinit/sources/DataSourceScaleway.py
index b0b19c93..e2502b02 100644
--- a/cloudinit/sources/DataSourceScaleway.py
+++ b/cloudinit/sources/DataSourceScaleway.py
@@ -113,9 +113,9 @@ def query_data_api_once(api_address, timeout, requests_session):
retries=0,
session=requests_session,
# If the error is a HTTP/404 or a ConnectionError, go into raise
- # block below.
- exception_cb=lambda _, exc: exc.code == 404 or (
- isinstance(exc.cause, requests.exceptions.ConnectionError)
+ # block below and don't bother retrying.
+ exception_cb=lambda _, exc: exc.code != 404 and (
+ not isinstance(exc.cause, requests.exceptions.ConnectionError)
)
)
return util.decode_binary(resp.contents)
@@ -215,7 +215,7 @@ class DataSourceScaleway(sources.DataSource):
def get_public_ssh_keys(self):
return [key['key'] for key in self.metadata['ssh_public_keys']]
- def get_hostname(self, fqdn=False, resolve_ip=False):
+ def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
return self.metadata['hostname']
@property
diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py
index a05ca2f6..df0b374a 100644
--- a/cloudinit/sources/__init__.py
+++ b/cloudinit/sources/__init__.py
@@ -276,21 +276,34 @@ class DataSource(object):
return "iid-datasource"
return str(self.metadata['instance-id'])
- def get_hostname(self, fqdn=False, resolve_ip=False):
+ def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False):
+ """Get hostname or fqdn from the datasource. Look it up if desired.
+
+ @param fqdn: Boolean, set True to return hostname with domain.
+ @param resolve_ip: Boolean, set True to attempt to resolve an ipv4
+ address provided in local-hostname meta-data.
+ @param metadata_only: Boolean, set True to avoid looking up hostname
+ if meta-data doesn't have local-hostname present.
+
+ @return: hostname or qualified hostname. Optionally return None when
+ metadata_only is True and local-hostname data is not available.
+ """
defdomain = "localdomain"
defhost = "localhost"
domain = defdomain
if not self.metadata or 'local-hostname' not in self.metadata:
+ if metadata_only:
+ return None
# this is somewhat questionable really.
# the cloud datasource was asked for a hostname
# and didn't have one. raising error might be more appropriate
# but instead, basically look up the existing hostname
toks = []
hostname = util.get_hostname()
- fqdn = util.get_fqdn_from_hosts(hostname)
- if fqdn and fqdn.find(".") > 0:
- toks = str(fqdn).split(".")
+ hosts_fqdn = util.get_fqdn_from_hosts(hostname)
+ if hosts_fqdn and hosts_fqdn.find(".") > 0:
+ toks = str(hosts_fqdn).split(".")
elif hostname and hostname.find(".") > 0:
toks = str(hostname).split(".")
elif hostname:
diff --git a/cloudinit/sources/helpers/hetzner.py b/cloudinit/sources/helpers/hetzner.py
new file mode 100644
index 00000000..2554530d
--- /dev/null
+++ b/cloudinit/sources/helpers/hetzner.py
@@ -0,0 +1,26 @@
+# Author: Jonas Keidel <jonas.keidel@hetzner.com>
+# Author: Markus Schade <markus.schade@hetzner.com>
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+
+from cloudinit import log as logging
+from cloudinit import url_helper
+from cloudinit import util
+
+LOG = logging.getLogger(__name__)
+
+
+def read_metadata(url, timeout=2, sec_between=2, retries=30):
+ response = url_helper.readurl(url, timeout=timeout,
+ sec_between=sec_between, retries=retries)
+ if not response.ok():
+ raise RuntimeError("unable to read metadata at %s" % url)
+ return util.load_yaml(response.contents.decode())
+
+
+def read_userdata(url, timeout=2, sec_between=2, retries=30):
+ response = url_helper.readurl(url, timeout=timeout,
+ sec_between=sec_between, retries=retries)
+ if not response.ok():
+ raise RuntimeError("unable to read userdata at %s" % url)
+ return response.contents
diff --git a/cloudinit/sources/tests/test_init.py b/cloudinit/sources/tests/test_init.py
index af151154..e7fda22a 100644
--- a/cloudinit/sources/tests/test_init.py
+++ b/cloudinit/sources/tests/test_init.py
@@ -1,13 +1,15 @@
# This file is part of cloud-init. See LICENSE file for license information.
+import inspect
import os
import six
import stat
from cloudinit.helpers import Paths
+from cloudinit import importer
from cloudinit.sources import (
INSTANCE_JSON_FILE, DataSource)
-from cloudinit.tests.helpers import CiTestCase, skipIf
+from cloudinit.tests.helpers import CiTestCase, skipIf, mock
from cloudinit.user_data import UserDataProcessor
from cloudinit import util
@@ -108,6 +110,74 @@ class TestDataSource(CiTestCase):
self.assertEqual('userdata_raw', datasource.userdata_raw)
self.assertEqual('vendordata_raw', datasource.vendordata_raw)
+ def test_get_hostname_strips_local_hostname_without_domain(self):
+ """Datasource.get_hostname strips metadata local-hostname of domain."""
+ tmp = self.tmp_dir()
+ datasource = DataSourceTestSubclassNet(
+ self.sys_cfg, self.distro, Paths({'run_dir': tmp}))
+ self.assertTrue(datasource.get_data())
+ self.assertEqual(
+ 'test-subclass-hostname', datasource.metadata['local-hostname'])
+ self.assertEqual('test-subclass-hostname', datasource.get_hostname())
+ datasource.metadata['local-hostname'] = 'hostname.my.domain.com'
+ self.assertEqual('hostname', datasource.get_hostname())
+
+ def test_get_hostname_with_fqdn_returns_local_hostname_with_domain(self):
+ """Datasource.get_hostname with fqdn set gets qualified hostname."""
+ tmp = self.tmp_dir()
+ datasource = DataSourceTestSubclassNet(
+ self.sys_cfg, self.distro, Paths({'run_dir': tmp}))
+ self.assertTrue(datasource.get_data())
+ datasource.metadata['local-hostname'] = 'hostname.my.domain.com'
+ self.assertEqual(
+ 'hostname.my.domain.com', datasource.get_hostname(fqdn=True))
+
+ def test_get_hostname_without_metadata_uses_system_hostname(self):
+ """Datasource.gethostname runs util.get_hostname when no metadata."""
+ tmp = self.tmp_dir()
+ datasource = DataSourceTestSubclassNet(
+ self.sys_cfg, self.distro, Paths({'run_dir': tmp}))
+ self.assertEqual({}, datasource.metadata)
+ mock_fqdn = 'cloudinit.sources.util.get_fqdn_from_hosts'
+ with mock.patch('cloudinit.sources.util.get_hostname') as m_gethost:
+ with mock.patch(mock_fqdn) as m_fqdn:
+ m_gethost.return_value = 'systemhostname.domain.com'
+ m_fqdn.return_value = None # No maching fqdn in /etc/hosts
+ self.assertEqual('systemhostname', datasource.get_hostname())
+ self.assertEqual(
+ 'systemhostname.domain.com',
+ datasource.get_hostname(fqdn=True))
+
+ def test_get_hostname_without_metadata_returns_none(self):
+ """Datasource.gethostname returns None when metadata_only and no MD."""
+ tmp = self.tmp_dir()
+ datasource = DataSourceTestSubclassNet(
+ self.sys_cfg, self.distro, Paths({'run_dir': tmp}))
+ self.assertEqual({}, datasource.metadata)
+ mock_fqdn = 'cloudinit.sources.util.get_fqdn_from_hosts'
+ with mock.patch('cloudinit.sources.util.get_hostname') as m_gethost:
+ with mock.patch(mock_fqdn) as m_fqdn:
+ self.assertIsNone(datasource.get_hostname(metadata_only=True))
+ self.assertIsNone(
+ datasource.get_hostname(fqdn=True, metadata_only=True))
+ self.assertEqual([], m_gethost.call_args_list)
+ self.assertEqual([], m_fqdn.call_args_list)
+
+ def test_get_hostname_without_metadata_prefers_etc_hosts(self):
+ """Datasource.gethostname prefers /etc/hosts to util.get_hostname."""
+ tmp = self.tmp_dir()
+ datasource = DataSourceTestSubclassNet(
+ self.sys_cfg, self.distro, Paths({'run_dir': tmp}))
+ self.assertEqual({}, datasource.metadata)
+ mock_fqdn = 'cloudinit.sources.util.get_fqdn_from_hosts'
+ with mock.patch('cloudinit.sources.util.get_hostname') as m_gethost:
+ with mock.patch(mock_fqdn) as m_fqdn:
+ m_gethost.return_value = 'systemhostname.domain.com'
+ m_fqdn.return_value = 'fqdnhostname.domain.com'
+ self.assertEqual('fqdnhostname', datasource.get_hostname())
+ self.assertEqual('fqdnhostname.domain.com',
+ datasource.get_hostname(fqdn=True))
+
def test_get_data_write_json_instance_data(self):
"""get_data writes INSTANCE_JSON_FILE to run_dir as readonly root."""
tmp = self.tmp_dir()
@@ -200,3 +270,29 @@ class TestDataSource(CiTestCase):
"WARNING: Error persisting instance-data.json: 'utf8' codec can't"
" decode byte 0xaa in position 2: invalid start byte",
self.logs.getvalue())
+
+ def test_get_hostname_subclass_support(self):
+ """Validate get_hostname signature on all subclasses of DataSource."""
+ # Use inspect.getfullargspec when we drop py2.6 and py2.7
+ get_args = inspect.getargspec # pylint: disable=W1505
+ base_args = get_args(DataSource.get_hostname) # pylint: disable=W1505
+ # Import all DataSource subclasses so we can inspect them.
+ modules = util.find_modules(os.path.dirname(os.path.dirname(__file__)))
+ for loc, name in modules.items():
+ mod_locs, _ = importer.find_module(name, ['cloudinit.sources'], [])
+ if mod_locs:
+ importer.import_module(mod_locs[0])
+ for child in DataSource.__subclasses__():
+ if 'Test' in child.dsname:
+ continue
+ self.assertEqual(
+ base_args,
+ get_args(child.get_hostname), # pylint: disable=W1505
+ '%s does not implement DataSource.get_hostname params'
+ % child)
+ for grandchild in child.__subclasses__():
+ self.assertEqual(
+ base_args,
+ get_args(grandchild.get_hostname), # pylint: disable=W1505
+ '%s does not implement DataSource.get_hostname params'
+ % grandchild)
diff --git a/cloudinit/stages.py b/cloudinit/stages.py
index d0452688..bc4ebc85 100644
--- a/cloudinit/stages.py
+++ b/cloudinit/stages.py
@@ -132,8 +132,7 @@ class Init(object):
return initial_dirs
def purge_cache(self, rm_instance_lnk=False):
- rm_list = []
- rm_list.append(self.paths.boot_finished)
+ rm_list = [self.paths.boot_finished]
if rm_instance_lnk:
rm_list.append(self.paths.instance_link)
for f in rm_list:
diff --git a/cloudinit/subp.py b/cloudinit/subp.py
new file mode 100644
index 00000000..0ad09306
--- /dev/null
+++ b/cloudinit/subp.py
@@ -0,0 +1,57 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+"""Common utility functions for interacting with subprocess."""
+
+# TODO move subp shellify and runparts related functions out of util.py
+
+import logging
+
+LOG = logging.getLogger(__name__)
+
+
+def prepend_base_command(base_command, commands):
+ """Ensure user-provided commands start with base_command; warn otherwise.
+
+ Each command is either a list or string. Perform the following:
+ - If the command is a list, pop the first element if it is None
+ - If the command is a list, insert base_command as the first element if
+ not present.
+ - When the command is a string not starting with 'base-command', warn.
+
+ Allow flexibility to provide non-base-command environment/config setup if
+ needed.
+
+ @commands: List of commands. Each command element is a list or string.
+
+ @return: List of 'fixed up' commands.
+ @raise: TypeError on invalid config item type.
+ """
+ warnings = []
+ errors = []
+ fixed_commands = []
+ for command in commands:
+ if isinstance(command, list):
+ if command[0] is None: # Avoid warnings by specifying None
+ command = command[1:]
+ elif command[0] != base_command: # Automatically prepend
+ command.insert(0, base_command)
+ elif isinstance(command, str):
+ if not command.startswith('%s ' % base_command):
+ warnings.append(command)
+ else:
+ errors.append(str(command))
+ continue
+ fixed_commands.append(command)
+
+ if warnings:
+ LOG.warning(
+ 'Non-%s commands in %s config:\n%s',
+ base_command, base_command, '\n'.join(warnings))
+ if errors:
+ raise TypeError(
+ 'Invalid {name} config.'
+ ' These commands are not a string or list:\n{errors}'.format(
+ name=base_command, errors='\n'.join(errors)))
+ return fixed_commands
+
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/tests/helpers.py b/cloudinit/tests/helpers.py
index 0080c729..999b1d7c 100644
--- a/cloudinit/tests/helpers.py
+++ b/cloudinit/tests/helpers.py
@@ -173,17 +173,15 @@ class CiTestCase(TestCase):
dir = self.tmp_dir()
return os.path.normpath(os.path.abspath(os.path.join(dir, path)))
- def assertRaisesCodeEqual(self, expected, found):
- """Handle centos6 having different context manager for assertRaises.
- with assertRaises(Exception) as e:
- raise Exception("BOO")
-
- centos6 will have e.exception as an integer.
- anything nwere will have it as something with a '.code'"""
- if isinstance(found, int):
- self.assertEqual(expected, found)
- else:
- self.assertEqual(expected, found.code)
+ def sys_exit(self, code):
+ """Provide a wrapper around sys.exit for python 2.6
+
+ In 2.6, this code would produce 'cm.exception' with value int(2)
+ rather than the SystemExit that was raised by sys.exit(2).
+ with assertRaises(SystemExit) as cm:
+ sys.exit(2)
+ """
+ raise SystemExit(code)
class ResourceUsingTestCase(CiTestCase):
@@ -285,10 +283,15 @@ class FilesystemMockingTestCase(ResourceUsingTestCase):
def patchOS(self, new_root):
patch_funcs = {
os.path: [('isfile', 1), ('exists', 1),
- ('islink', 1), ('isdir', 1)],
+ ('islink', 1), ('isdir', 1), ('lexists', 1)],
os: [('listdir', 1), ('mkdir', 1),
- ('lstat', 1), ('symlink', 2)],
+ ('lstat', 1), ('symlink', 2)]
}
+
+ if hasattr(os, 'scandir'):
+ # py27 does not have scandir
+ patch_funcs[os].append(('scandir', 1))
+
for (mod, funcs) in patch_funcs.items():
for f, nargs in funcs:
func = getattr(mod, f)
@@ -411,6 +414,19 @@ except AttributeError:
return decorator
+try:
+ import jsonschema
+ assert jsonschema # avoid pyflakes error F401: import unused
+ _missing_jsonschema_dep = False
+except ImportError:
+ _missing_jsonschema_dep = True
+
+
+def skipUnlessJsonSchema():
+ return skipIf(
+ _missing_jsonschema_dep, "No python-jsonschema dependency present.")
+
+
# older versions of mock do not have the useful 'assert_not_called'
if not hasattr(mock.Mock, 'assert_not_called'):
def __mock_assert_not_called(mmock):
@@ -422,12 +438,12 @@ if not hasattr(mock.Mock, 'assert_not_called'):
mock.Mock.assert_not_called = __mock_assert_not_called
-# older unittest2.TestCase (centos6) do not have assertRaisesRegex
-# And setting assertRaisesRegex to assertRaisesRegexp causes
-# https://github.com/PyCQA/pylint/issues/1653 . So the workaround.
+# older unittest2.TestCase (centos6) have only the now-deprecated
+# assertRaisesRegexp. Simple assignment makes pylint complain, about
+# users of assertRaisesRegex so we use getattr to trick it.
+# https://github.com/PyCQA/pylint/issues/1946
if not hasattr(unittest2.TestCase, 'assertRaisesRegex'):
- def _tricky(*args, **kwargs):
- return unittest2.TestCase.assertRaisesRegexp
- unittest2.TestCase.assertRaisesRegex = _tricky
+ unittest2.TestCase.assertRaisesRegex = (
+ getattr(unittest2.TestCase, 'assertRaisesRegexp'))
# vi: ts=4 expandtab
diff --git a/cloudinit/tests/test_subp.py b/cloudinit/tests/test_subp.py
new file mode 100644
index 00000000..448097d3
--- /dev/null
+++ b/cloudinit/tests/test_subp.py
@@ -0,0 +1,61 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+"""Tests for cloudinit.subp utility functions"""
+
+from cloudinit import subp
+from cloudinit.tests.helpers import CiTestCase
+
+
+class TestPrependBaseCommands(CiTestCase):
+
+ with_logs = True
+
+ def test_prepend_base_command_errors_on_neither_string_nor_list(self):
+ """Raise an error for each command which is not a string or list."""
+ orig_commands = ['ls', 1, {'not': 'gonna work'}, ['basecmd', 'list']]
+ with self.assertRaises(TypeError) as context_manager:
+ subp.prepend_base_command(
+ base_command='basecmd', commands=orig_commands)
+ self.assertEqual(
+ "Invalid basecmd config. These commands are not a string or"
+ " list:\n1\n{'not': 'gonna work'}",
+ str(context_manager.exception))
+
+ def test_prepend_base_command_warns_on_non_base_string_commands(self):
+ """Warn on each non-base for commands of type string."""
+ orig_commands = [
+ 'ls', 'basecmd list', 'touch /blah', 'basecmd install x']
+ fixed_commands = subp.prepend_base_command(
+ base_command='basecmd', commands=orig_commands)
+ self.assertEqual(
+ 'WARNING: Non-basecmd commands in basecmd config:\n'
+ 'ls\ntouch /blah\n',
+ self.logs.getvalue())
+ self.assertEqual(orig_commands, fixed_commands)
+
+ def test_prepend_base_command_prepends_on_non_base_list_commands(self):
+ """Prepend 'basecmd' for each non-basecmd command of type list."""
+ orig_commands = [['ls'], ['basecmd', 'list'], ['basecmda', '/blah'],
+ ['basecmd', 'install', 'x']]
+ expected = [['basecmd', 'ls'], ['basecmd', 'list'],
+ ['basecmd', 'basecmda', '/blah'],
+ ['basecmd', 'install', 'x']]
+ fixed_commands = subp.prepend_base_command(
+ base_command='basecmd', commands=orig_commands)
+ self.assertEqual('', self.logs.getvalue())
+ self.assertEqual(expected, fixed_commands)
+
+ def test_prepend_base_command_removes_first_item_when_none(self):
+ """Remove the first element of a non-basecmd when it is None."""
+ orig_commands = [[None, 'ls'], ['basecmd', 'list'],
+ [None, 'touch', '/blah'],
+ ['basecmd', 'install', 'x']]
+ expected = [['ls'], ['basecmd', 'list'],
+ ['touch', '/blah'],
+ ['basecmd', 'install', 'x']]
+ fixed_commands = subp.prepend_base_command(
+ base_command='basecmd', commands=orig_commands)
+ self.assertEqual('', self.logs.getvalue())
+ self.assertEqual(expected, fixed_commands)
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/tests/test_util.py b/cloudinit/tests/test_util.py
index ba6bf699..3f37dbb6 100644
--- a/cloudinit/tests/test_util.py
+++ b/cloudinit/tests/test_util.py
@@ -3,6 +3,7 @@
"""Tests for cloudinit.util"""
import logging
+from textwrap import dedent
import cloudinit.util as util
@@ -16,6 +17,25 @@ MOUNT_INFO = [
]
+class FakeCloud(object):
+
+ def __init__(self, hostname, fqdn):
+ self.hostname = hostname
+ self.fqdn = fqdn
+ self.calls = []
+
+ def get_hostname(self, fqdn=None, metadata_only=None):
+ myargs = {}
+ if fqdn is not None:
+ myargs['fqdn'] = fqdn
+ if metadata_only is not None:
+ myargs['metadata_only'] = metadata_only
+ self.calls.append(myargs)
+ if fqdn:
+ return self.fqdn
+ return self.hostname
+
+
class TestUtil(CiTestCase):
def test_parse_mount_info_no_opts_no_arg(self):
@@ -44,3 +64,152 @@ class TestUtil(CiTestCase):
m_mount_info.return_value = ('/dev/sda1', 'btrfs', '/', 'ro,relatime')
is_rw = util.mount_is_read_write('/')
self.assertEqual(is_rw, False)
+
+
+class TestShellify(CiTestCase):
+
+ def test_input_dict_raises_type_error(self):
+ self.assertRaisesRegex(
+ TypeError, 'Input.*was.*dict.*xpected',
+ util.shellify, {'mykey': 'myval'})
+
+ def test_input_str_raises_type_error(self):
+ self.assertRaisesRegex(
+ TypeError, 'Input.*was.*str.*xpected', util.shellify, "foobar")
+
+ def test_value_with_int_raises_type_error(self):
+ self.assertRaisesRegex(
+ TypeError, 'shellify.*int', util.shellify, ["foo", 1])
+
+ def test_supports_strings_and_lists(self):
+ self.assertEqual(
+ '\n'.join(["#!/bin/sh", "echo hi mom", "'echo' 'hi dad'",
+ "'echo' 'hi' 'sis'", ""]),
+ util.shellify(["echo hi mom", ["echo", "hi dad"],
+ ('echo', 'hi', 'sis')]))
+
+
+class TestGetHostnameFqdn(CiTestCase):
+
+ def test_get_hostname_fqdn_from_only_cfg_fqdn(self):
+ """When cfg only has the fqdn key, derive hostname and fqdn from it."""
+ hostname, fqdn = util.get_hostname_fqdn(
+ cfg={'fqdn': 'myhost.domain.com'}, cloud=None)
+ self.assertEqual('myhost', hostname)
+ self.assertEqual('myhost.domain.com', fqdn)
+
+ def test_get_hostname_fqdn_from_cfg_fqdn_and_hostname(self):
+ """When cfg has both fqdn and hostname keys, return them."""
+ hostname, fqdn = util.get_hostname_fqdn(
+ cfg={'fqdn': 'myhost.domain.com', 'hostname': 'other'}, cloud=None)
+ self.assertEqual('other', hostname)
+ self.assertEqual('myhost.domain.com', fqdn)
+
+ def test_get_hostname_fqdn_from_cfg_hostname_with_domain(self):
+ """When cfg has only hostname key which represents a fqdn, use that."""
+ hostname, fqdn = util.get_hostname_fqdn(
+ cfg={'hostname': 'myhost.domain.com'}, cloud=None)
+ self.assertEqual('myhost', hostname)
+ self.assertEqual('myhost.domain.com', fqdn)
+
+ def test_get_hostname_fqdn_from_cfg_hostname_without_domain(self):
+ """When cfg has a hostname without a '.' query cloud.get_hostname."""
+ mycloud = FakeCloud('cloudhost', 'cloudhost.mycloud.com')
+ hostname, fqdn = util.get_hostname_fqdn(
+ cfg={'hostname': 'myhost'}, cloud=mycloud)
+ self.assertEqual('myhost', hostname)
+ self.assertEqual('cloudhost.mycloud.com', fqdn)
+ self.assertEqual(
+ [{'fqdn': True, 'metadata_only': False}], mycloud.calls)
+
+ def test_get_hostname_fqdn_from_without_fqdn_or_hostname(self):
+ """When cfg has neither hostname nor fqdn cloud.get_hostname."""
+ mycloud = FakeCloud('cloudhost', 'cloudhost.mycloud.com')
+ hostname, fqdn = util.get_hostname_fqdn(cfg={}, cloud=mycloud)
+ self.assertEqual('cloudhost', hostname)
+ self.assertEqual('cloudhost.mycloud.com', fqdn)
+ self.assertEqual(
+ [{'fqdn': True, 'metadata_only': False},
+ {'metadata_only': False}], mycloud.calls)
+
+ def test_get_hostname_fqdn_from_passes_metadata_only_to_cloud(self):
+ """Calls to cloud.get_hostname pass the metadata_only parameter."""
+ mycloud = FakeCloud('cloudhost', 'cloudhost.mycloud.com')
+ hostname, fqdn = util.get_hostname_fqdn(
+ cfg={}, cloud=mycloud, metadata_only=True)
+ self.assertEqual(
+ [{'fqdn': True, 'metadata_only': True},
+ {'metadata_only': True}], mycloud.calls)
+
+
+class TestBlkid(CiTestCase):
+ ids = {
+ "id01": "1111-1111",
+ "id02": "22222222-2222",
+ "id03": "33333333-3333",
+ "id04": "44444444-4444",
+ "id05": "55555555-5555-5555-5555-555555555555",
+ "id06": "66666666-6666-6666-6666-666666666666",
+ "id07": "52894610484658920398",
+ "id08": "86753098675309867530",
+ "id09": "99999999-9999-9999-9999-999999999999",
+ }
+
+ blkid_out = dedent("""\
+ /dev/loop0: TYPE="squashfs"
+ /dev/loop1: TYPE="squashfs"
+ /dev/loop2: TYPE="squashfs"
+ /dev/loop3: TYPE="squashfs"
+ /dev/sda1: UUID="{id01}" TYPE="vfat" PARTUUID="{id02}"
+ /dev/sda2: UUID="{id03}" TYPE="ext4" PARTUUID="{id04}"
+ /dev/sda3: UUID="{id05}" TYPE="ext4" PARTUUID="{id06}"
+ /dev/sda4: LABEL="default" UUID="{id07}" UUID_SUB="{id08}" """
+ """TYPE="zfs_member" PARTUUID="{id09}"
+ /dev/loop4: TYPE="squashfs"
+ """)
+
+ maxDiff = None
+
+ def _get_expected(self):
+ return ({
+ "/dev/loop0": {"DEVNAME": "/dev/loop0", "TYPE": "squashfs"},
+ "/dev/loop1": {"DEVNAME": "/dev/loop1", "TYPE": "squashfs"},
+ "/dev/loop2": {"DEVNAME": "/dev/loop2", "TYPE": "squashfs"},
+ "/dev/loop3": {"DEVNAME": "/dev/loop3", "TYPE": "squashfs"},
+ "/dev/loop4": {"DEVNAME": "/dev/loop4", "TYPE": "squashfs"},
+ "/dev/sda1": {"DEVNAME": "/dev/sda1", "TYPE": "vfat",
+ "UUID": self.ids["id01"],
+ "PARTUUID": self.ids["id02"]},
+ "/dev/sda2": {"DEVNAME": "/dev/sda2", "TYPE": "ext4",
+ "UUID": self.ids["id03"],
+ "PARTUUID": self.ids["id04"]},
+ "/dev/sda3": {"DEVNAME": "/dev/sda3", "TYPE": "ext4",
+ "UUID": self.ids["id05"],
+ "PARTUUID": self.ids["id06"]},
+ "/dev/sda4": {"DEVNAME": "/dev/sda4", "TYPE": "zfs_member",
+ "LABEL": "default",
+ "UUID": self.ids["id07"],
+ "UUID_SUB": self.ids["id08"],
+ "PARTUUID": self.ids["id09"]},
+ })
+
+ @mock.patch("cloudinit.util.subp")
+ def test_functional_blkid(self, m_subp):
+ m_subp.return_value = (
+ self.blkid_out.format(**self.ids), "")
+ self.assertEqual(self._get_expected(), util.blkid())
+ m_subp.assert_called_with(["blkid", "-o", "full"], capture=True,
+ decode="replace")
+
+ @mock.patch("cloudinit.util.subp")
+ def test_blkid_no_cache_uses_no_cache(self, m_subp):
+ """blkid should turn off cache if disable_cache is true."""
+ m_subp.return_value = (
+ self.blkid_out.format(**self.ids), "")
+ self.assertEqual(self._get_expected(),
+ util.blkid(disable_cache=True))
+ m_subp.assert_called_with(["blkid", "-o", "full", "-c", "/dev/null"],
+ capture=True, decode="replace")
+
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/url_helper.py b/cloudinit/url_helper.py
index 0a5be0b3..03a573af 100644
--- a/cloudinit/url_helper.py
+++ b/cloudinit/url_helper.py
@@ -16,7 +16,7 @@ import time
from email.utils import parsedate
from functools import partial
-
+from itertools import count
from requests import exceptions
from six.moves.urllib.parse import (
@@ -47,7 +47,7 @@ try:
_REQ_VER = LooseVersion(_REQ.version) # pylint: disable=no-member
if _REQ_VER >= LooseVersion('0.8.8'):
SSL_ENABLED = True
- if _REQ_VER >= LooseVersion('0.7.0') and _REQ_VER < LooseVersion('1.0.0'):
+ if LooseVersion('0.7.0') <= _REQ_VER < LooseVersion('1.0.0'):
CONFIG_ENABLED = True
except ImportError:
pass
@@ -121,7 +121,7 @@ class UrlResponse(object):
upper = 300
if redirects_ok:
upper = 400
- if self.code >= 200 and self.code < upper:
+ if 200 <= self.code < upper:
return True
else:
return False
@@ -172,7 +172,7 @@ def _get_ssl_args(url, ssl_details):
def readurl(url, data=None, timeout=None, retries=0, sec_between=1,
headers=None, headers_cb=None, ssl_details=None,
check_status=True, allow_redirects=True, exception_cb=None,
- session=None):
+ session=None, infinite=False):
url = _cleanurl(url)
req_args = {
'url': url,
@@ -220,7 +220,8 @@ def readurl(url, data=None, timeout=None, retries=0, sec_between=1,
excps = []
# Handle retrying ourselves since the built-in support
# doesn't handle sleeping between tries...
- for i in range(0, manual_tries):
+ # Infinitely retry if infinite is True
+ for i in count() if infinite else range(0, manual_tries):
req_args['headers'] = headers_cb(url)
filtered_req_args = {}
for (k, v) in req_args.items():
@@ -229,7 +230,8 @@ def readurl(url, data=None, timeout=None, retries=0, sec_between=1,
filtered_req_args[k] = v
try:
LOG.debug("[%s/%s] open '%s' with %s configuration", i,
- manual_tries, url, filtered_req_args)
+ "infinite" if infinite else manual_tries, url,
+ filtered_req_args)
if session is None:
session = requests.Session()
@@ -258,11 +260,13 @@ def readurl(url, data=None, timeout=None, retries=0, sec_between=1,
# ssl exceptions are not going to get fixed by waiting a
# few seconds
break
- if exception_cb and exception_cb(req_args.copy(), excps[-1]):
- # if an exception callback was given it should return None
- # a true-ish value means to break and re-raise the exception
+ if exception_cb and not exception_cb(req_args.copy(), excps[-1]):
+ # if an exception callback was given, it should return True
+ # to continue retrying and False to break and re-raise the
+ # exception
break
- if i + 1 < manual_tries and sec_between > 0:
+ if (infinite and sec_between > 0) or \
+ (i + 1 < manual_tries and sec_between > 0):
LOG.debug("Please wait %s seconds while we wait to try again",
sec_between)
time.sleep(sec_between)
diff --git a/cloudinit/util.py b/cloudinit/util.py
index 338fb971..0ab2c484 100644
--- a/cloudinit/util.py
+++ b/cloudinit/util.py
@@ -546,7 +546,7 @@ def is_ipv4(instr):
return False
try:
- toks = [x for x in toks if int(x) < 256 and int(x) >= 0]
+ toks = [x for x in toks if 0 <= int(x) < 256]
except Exception:
return False
@@ -716,8 +716,7 @@ def redirect_output(outfmt, errfmt, o_out=None, o_err=None):
def make_url(scheme, host, port=None,
path='', params='', query='', fragment=''):
- pieces = []
- pieces.append(scheme or '')
+ pieces = [scheme or '']
netloc = ''
if host:
@@ -1026,9 +1025,16 @@ def dos2unix(contents):
return contents.replace('\r\n', '\n')
-def get_hostname_fqdn(cfg, cloud):
- # return the hostname and fqdn from 'cfg'. If not found in cfg,
- # then fall back to data from cloud
+def get_hostname_fqdn(cfg, cloud, metadata_only=False):
+ """Get hostname and fqdn from config if present and fallback to cloud.
+
+ @param cfg: Dictionary of merged user-data configuration (from init.cfg).
+ @param cloud: Cloud instance from init.cloudify().
+ @param metadata_only: Boolean, set True to only query cloud meta-data,
+ returning None if not present in meta-data.
+ @return: a Tuple of strings <hostname>, <fqdn>. Values can be none when
+ metadata_only is True and no cfg or metadata provides hostname info.
+ """
if "fqdn" in cfg:
# user specified a fqdn. Default hostname then is based off that
fqdn = cfg['fqdn']
@@ -1042,11 +1048,11 @@ def get_hostname_fqdn(cfg, cloud):
else:
# no fqdn set, get fqdn from cloud.
# get hostname from cfg if available otherwise cloud
- fqdn = cloud.get_hostname(fqdn=True)
+ fqdn = cloud.get_hostname(fqdn=True, metadata_only=metadata_only)
if "hostname" in cfg:
hostname = cfg['hostname']
else:
- hostname = cloud.get_hostname()
+ hostname = cloud.get_hostname(metadata_only=metadata_only)
return (hostname, fqdn)
@@ -1231,6 +1237,37 @@ def find_devs_with(criteria=None, oformat='device',
return entries
+def blkid(devs=None, disable_cache=False):
+ """Get all device tags details from blkid.
+
+ @param devs: Optional list of device paths you wish to query.
+ @param disable_cache: Bool, set True to start with clean cache.
+
+ @return: Dict of key value pairs of info for the device.
+ """
+ if devs is None:
+ devs = []
+ else:
+ devs = list(devs)
+
+ cmd = ['blkid', '-o', 'full']
+ if disable_cache:
+ cmd.extend(['-c', '/dev/null'])
+ cmd.extend(devs)
+
+ # we have to decode with 'replace' as shelx.split (called by
+ # load_shell_content) can't take bytes. So this is potentially
+ # lossy of non-utf-8 chars in blkid output.
+ out, _ = subp(cmd, capture=True, decode="replace")
+ ret = {}
+ for line in out.splitlines():
+ dev, _, data = line.partition(":")
+ ret[dev] = load_shell_content(data)
+ ret[dev]["DEVNAME"] = dev
+
+ return ret
+
+
def peek_file(fname, max_bytes):
LOG.debug("Peeking at %s (max_bytes=%s)", fname, max_bytes)
with open(fname, 'rb') as ifh:
@@ -1746,7 +1783,7 @@ def chmod(path, mode):
def write_file(filename, content, mode=0o644, omode="wb", copy_mode=False):
"""
Writes a file with the given content and sets the file mode as specified.
- Resotres the SELinux context if possible.
+ Restores the SELinux context if possible.
@param filename: The full path of the file to write.
@param content: The content to write to the file.
@@ -1821,7 +1858,8 @@ def subp_blob_in_tempfile(blob, *args, **kwargs):
def subp(args, data=None, rcs=None, env=None, capture=True, shell=False,
- logstring=False, decode="replace", target=None, update_env=None):
+ logstring=False, decode="replace", target=None, update_env=None,
+ status_cb=None):
# not supported in cloud-init (yet), for now kept in the call signature
# to ease maintaining code shared between cloud-init and curtin
@@ -1842,6 +1880,9 @@ def subp(args, data=None, rcs=None, env=None, capture=True, shell=False,
if target_path(target) != "/":
args = ['chroot', target] + list(args)
+ if status_cb:
+ command = ' '.join(args) if isinstance(args, list) else args
+ status_cb('Begin run command: {command}\n'.format(command=command))
if not logstring:
LOG.debug(("Running command %s with allowed return codes %s"
" (shell=%s, capture=%s)"), args, rcs, shell, capture)
@@ -1865,12 +1906,25 @@ def subp(args, data=None, rcs=None, env=None, capture=True, shell=False,
if not isinstance(data, bytes):
data = data.encode()
+ # Popen converts entries in the arguments array from non-bytes to bytes.
+ # When locale is unset it may use ascii for that encoding which can
+ # cause UnicodeDecodeErrors. (LP: #1751051)
+ if isinstance(args, six.binary_type):
+ bytes_args = args
+ elif isinstance(args, six.string_types):
+ bytes_args = args.encode("utf-8")
+ else:
+ bytes_args = [
+ x if isinstance(x, six.binary_type) else x.encode("utf-8")
+ for x in args]
try:
- sp = subprocess.Popen(args, stdout=stdout,
+ sp = subprocess.Popen(bytes_args, stdout=stdout,
stderr=stderr, stdin=stdin,
env=env, shell=shell)
(out, err) = sp.communicate(data)
except OSError as e:
+ if status_cb:
+ status_cb('ERROR: End run command: invalid command provided\n')
raise ProcessExecutionError(
cmd=args, reason=e, errno=e.errno,
stdout="-" if decode else b"-",
@@ -1895,9 +1949,14 @@ def subp(args, data=None, rcs=None, env=None, capture=True, shell=False,
rc = sp.returncode
if rc not in rcs:
+ if status_cb:
+ status_cb(
+ 'ERROR: End run command: exit({code})\n'.format(code=rc))
raise ProcessExecutionError(stdout=out, stderr=err,
exit_code=rc,
cmd=args)
+ if status_cb:
+ status_cb('End run command: exit({code})\n'.format(code=rc))
return (out, err)
@@ -1918,6 +1977,11 @@ def abs_join(*paths):
# if it is an array, shell protect it (with single ticks)
# if it is a string, do nothing
def shellify(cmdlist, add_header=True):
+ if not isinstance(cmdlist, (tuple, list)):
+ raise TypeError(
+ "Input to shellify was type '%s'. Expected list or tuple." %
+ (type_utils.obj_name(cmdlist)))
+
content = ''
if add_header:
content += "#!/bin/sh\n"
@@ -1926,7 +1990,7 @@ def shellify(cmdlist, add_header=True):
for args in cmdlist:
# If the item is a list, wrap all items in single tick.
# If its not, then just write it directly.
- if isinstance(args, list):
+ if isinstance(args, (list, tuple)):
fixed = []
for f in args:
fixed.append("'%s'" % (six.text_type(f).replace("'", escaped)))
@@ -1936,9 +2000,10 @@ def shellify(cmdlist, add_header=True):
content = "%s%s\n" % (content, args)
cmds_made += 1
else:
- raise RuntimeError(("Unable to shellify type %s"
- " which is not a list or string")
- % (type_utils.obj_name(args)))
+ raise TypeError(
+ "Unable to shellify type '%s'. Expected list, string, tuple. "
+ "Got: %s" % (type_utils.obj_name(args), args))
+
LOG.debug("Shellified %s commands.", cmds_made)
return content
@@ -2169,7 +2234,7 @@ def get_path_dev_freebsd(path, mnt_list):
return path_found
-def get_mount_info_freebsd(path, log=LOG):
+def get_mount_info_freebsd(path):
(result, err) = subp(['mount', '-p', path], rcs=[0, 1])
if len(err):
# find a path if the input is not a mounting point
@@ -2183,23 +2248,49 @@ def get_mount_info_freebsd(path, log=LOG):
return "/dev/" + label_part, ret[2], ret[1]
+def get_device_info_from_zpool(zpool):
+ (zpoolstatus, err) = subp(['zpool', 'status', zpool])
+ if len(err):
+ return None
+ r = r'.*(ONLINE).*'
+ for line in zpoolstatus.split("\n"):
+ if re.search(r, line) and zpool not in line and "state" not in line:
+ disk = line.split()[0]
+ LOG.debug('found zpool "%s" on disk %s', zpool, disk)
+ return disk
+
+
def parse_mount(path):
- (mountoutput, _err) = subp("mount")
+ (mountoutput, _err) = subp(['mount'])
mount_locs = mountoutput.splitlines()
+ # there are 2 types of mount outputs we have to parse therefore
+ # the regex is a bit complex. to better understand this regex see:
+ # https://regex101.com/r/2F6c1k/1
+ # https://regex101.com/r/T2en7a/1
+ regex = r'^(/dev/[\S]+|.*zroot\S*?) on (/[\S]*) ' + \
+ '(?=(?:type)[\s]+([\S]+)|\(([^,]*))'
for line in mount_locs:
- m = re.search(r'^(/dev/[\S]+) on (/.*) \((.+), .+, (.+)\)$', line)
+ m = re.search(regex, line)
if not m:
continue
+ devpth = m.group(1)
+ mount_point = m.group(2)
+ # above regex will either fill the fs_type in group(3)
+ # or group(4) depending on the format we have.
+ fs_type = m.group(3)
+ if fs_type is None:
+ fs_type = m.group(4)
+ LOG.debug('found line in mount -> devpth: %s, mount_point: %s, '
+ 'fs_type: %s', devpth, mount_point, fs_type)
# check whether the dev refers to a label on FreeBSD
# for example, if dev is '/dev/label/rootfs', we should
# continue finding the real device like '/dev/da0'.
- devm = re.search('^(/dev/.+)p([0-9])$', m.group(1))
- if (not devm and is_FreeBSD()):
+ # this is only valid for non zfs file systems as a zpool
+ # can have gpt labels as disk.
+ devm = re.search('^(/dev/.+)p([0-9])$', devpth)
+ if not devm and is_FreeBSD() and fs_type != 'zfs':
return get_mount_info_freebsd(path)
- devpth = m.group(1)
- mount_point = m.group(2)
- fs_type = m.group(3)
- if mount_point == path:
+ elif mount_point == path:
return devpth, fs_type, mount_point
return None
diff --git a/cloudinit/version.py b/cloudinit/version.py
index be6262d6..ccd0f84e 100644
--- a/cloudinit/version.py
+++ b/cloudinit/version.py
@@ -4,7 +4,7 @@
#
# This file is part of cloud-init. See LICENSE file for license information.
-__VERSION__ = "17.2"
+__VERSION__ = "18.2"
FEATURES = [
# supports network config version 1
diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl
index 32de9c9b..3129d4eb 100644
--- a/config/cloud.cfg.tmpl
+++ b/config/cloud.cfg.tmpl
@@ -4,6 +4,8 @@
{% if variant in ["freebsd"] %}
syslog_fix_perms: root:wheel
+{% elif variant in ["suse"] %}
+syslog_fix_perms: root:root
{% endif %}
# A set of users which may be applied and/or used by various modules
# when a 'default' entry is found it will reference the 'default_user'
@@ -70,7 +72,8 @@ cloud_config_modules:
# Emit the cloud config ready event
# this can be used by upstart jobs for 'start on cloud-config'.
- emit_upstart
- - snap_config
+ - snap
+ - snap_config # DEPRECATED- Drop in version 18.2
{% endif %}
- ssh-import-id
- locale
@@ -84,6 +87,9 @@ cloud_config_modules:
- apt-pipelining
- apt-configure
{% endif %}
+{% if variant in ["ubuntu"] %}
+ - ubuntu-advantage
+{% endif %}
{% if variant in ["suse"] %}
- zypper-add-repo
{% endif %}
@@ -100,7 +106,7 @@ cloud_config_modules:
# The modules that run in the 'final' stage
cloud_final_modules:
{% if variant in ["ubuntu", "unknown", "debian"] %}
- - snappy
+ - snappy # DEPRECATED- Drop in version 18.2
{% endif %}
- package-update-upgrade-install
{% if variant in ["ubuntu", "unknown", "debian"] %}
@@ -111,9 +117,9 @@ cloud_final_modules:
{% if variant not in ["freebsd"] %}
- puppet
- chef
- - salt-minion
- mcollective
{% endif %}
+ - salt-minion
- rightscale_userdata
- scripts-vendor
- scripts-per-once
diff --git a/doc/examples/cloud-config-chef.txt b/doc/examples/cloud-config-chef.txt
index 58d5fdc7..defc5a54 100644
--- a/doc/examples/cloud-config-chef.txt
+++ b/doc/examples/cloud-config-chef.txt
@@ -12,8 +12,8 @@
# Key from https://packages.chef.io/chef.asc
apt:
- source1:
- source: "deb http://packages.chef.io/repos/apt/stable $RELEASE main"
+ sources:
+ source1: "deb http://packages.chef.io/repos/apt/stable $RELEASE main"
key: |
-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: GnuPG v1.4.12 (Darwin)
diff --git a/doc/rtd/conf.py b/doc/rtd/conf.py
index 0ea3b6bf..50eb05cf 100644
--- a/doc/rtd/conf.py
+++ b/doc/rtd/conf.py
@@ -29,6 +29,7 @@ project = 'Cloud-Init'
extensions = [
'sphinx.ext.intersphinx',
'sphinx.ext.autodoc',
+ 'sphinx.ext.autosectionlabel',
'sphinx.ext.viewcode',
]
diff --git a/doc/rtd/topics/capabilities.rst b/doc/rtd/topics/capabilities.rst
index ae3a0c74..3e2c9e31 100644
--- a/doc/rtd/topics/capabilities.rst
+++ b/doc/rtd/topics/capabilities.rst
@@ -44,13 +44,14 @@ Currently defined feature names include:
CLI Interface
=============
- The command line documentation is accessible on any cloud-init
-installed system:
+The command line documentation is accessible on any cloud-init installed
+system:
-.. code-block:: bash
+.. code-block:: shell-session
% cloud-init --help
usage: cloud-init [-h] [--version] [--file FILES]
+
[--debug] [--force]
{init,modules,single,dhclient-hook,features,analyze,devel,collect-logs,clean,status}
...
@@ -88,7 +89,7 @@ Print out each feature supported. If cloud-init does not have the
features subcommand, it also does not support any features described in
this document.
-.. code-block:: bash
+.. code-block:: shell-session
% cloud-init features
NETWORK_CONFIG_V1
@@ -100,10 +101,11 @@ cloud-init status
-----------------
Report whether cloud-init is running, done, disabled or errored. Exits
non-zero if an error is detected in cloud-init.
+
* **--long**: Detailed status information.
* **--wait**: Block until cloud-init completes.
-.. code-block:: bash
+.. code-block:: shell-session
% cloud-init status --long
status: done
@@ -214,7 +216,7 @@ of once-per-instance:
* **--frequency**: Optionally override the declared module frequency
with one of (always|once-per-instance|once)
-.. code-block:: bash
+.. code-block:: shell-session
% cloud-init single --name set_hostname --frequency always
diff --git a/doc/rtd/topics/debugging.rst b/doc/rtd/topics/debugging.rst
index c2b47edc..cacc8a27 100644
--- a/doc/rtd/topics/debugging.rst
+++ b/doc/rtd/topics/debugging.rst
@@ -1,6 +1,6 @@
-**********************
+********************************
Testing and debugging cloud-init
-**********************
+********************************
Overview
========
@@ -10,7 +10,7 @@ deployed instances.
.. _boot_time_analysis:
Boot Time Analysis - cloud-init analyze
-======================================
+=======================================
Occasionally instances don't appear as performant as we would like and
cloud-init packages a simple facility to inspect what operations took
cloud-init the longest during boot and setup.
@@ -22,9 +22,9 @@ determine the long-pole in cloud-init configuration and setup. These
subcommands default to reading /var/log/cloud-init.log.
* ``analyze show`` Parse and organize cloud-init.log events by stage and
-include each sub-stage granularity with time delta reports.
+ include each sub-stage granularity with time delta reports.
-.. code-block:: bash
+.. code-block:: shell-session
$ cloud-init analyze show -i my-cloud-init.log
-- Boot Record 01 --
@@ -41,9 +41,9 @@ include each sub-stage granularity with time delta reports.
* ``analyze dump`` Parse cloud-init.log into event records and return a list of
-dictionaries that can be consumed for other reporting needs.
+ dictionaries that can be consumed for other reporting needs.
-.. code-block:: bash
+.. code-block:: shell-session
$ cloud-init analyze blame -i my-cloud-init.log
[
@@ -56,10 +56,10 @@ dictionaries that can be consumed for other reporting needs.
},...
* ``analyze blame`` Parse cloud-init.log into event records and sort them based
-on highest time cost for quick assessment of areas of cloud-init that may need
-improvement.
+ on highest time cost for quick assessment of areas of cloud-init that may
+ need improvement.
-.. code-block:: bash
+.. code-block:: shell-session
$ cloud-init analyze blame -i my-cloud-init.log
-- Boot Record 11 --
@@ -73,31 +73,36 @@ Analyze quickstart - LXC
---------------------------
To quickly obtain a cloud-init log try using lxc on any ubuntu system:
-.. code-block:: bash
+.. code-block:: shell-session
+
+ $ lxc init ubuntu-daily:xenial x1
+ $ lxc start x1
+ $ # Take lxc's cloud-init.log and pipe it to the analyzer
+ $ lxc file pull x1/var/log/cloud-init.log - | cloud-init analyze dump -i -
+ $ lxc file pull x1/var/log/cloud-init.log - | \
+ python3 -m cloudinit.analyze dump -i -
- $ lxc init ubuntu-daily:xenial x1
- $ lxc start x1
- # Take lxc's cloud-init.log and pipe it to the analyzer
- $ lxc file pull x1/var/log/cloud-init.log - | cloud-init analyze dump -i -
- $ lxc file pull x1/var/log/cloud-init.log - | \
- python3 -m cloudinit.analyze dump -i -
Analyze quickstart - KVM
---------------------------
To quickly analyze a KVM a cloud-init log:
1. Download the current cloud image
- wget https://cloud-images.ubuntu.com/daily/server/xenial/current/xenial-server-cloudimg-amd64.img
+
+.. code-block:: shell-session
+
+ $ wget https://cloud-images.ubuntu.com/daily/server/xenial/current/xenial-server-cloudimg-amd64.img
+
2. Create a snapshot image to preserve the original cloud-image
-.. code-block:: bash
+.. code-block:: shell-session
$ qemu-img create -b xenial-server-cloudimg-amd64.img -f qcow2 \
test-cloudinit.qcow2
3. Create a seed image with metadata using `cloud-localds`
-.. code-block:: bash
+.. code-block:: shell-session
$ cat > user-data <<EOF
#cloud-config
@@ -108,18 +113,18 @@ To quickly analyze a KVM a cloud-init log:
4. Launch your modified VM
-.. code-block:: bash
+.. code-block:: shell-session
$ kvm -m 512 -net nic -net user -redir tcp:2222::22 \
- -drive file=test-cloudinit.qcow2,if=virtio,format=qcow2 \
- -drive file=my-seed.img,if=virtio,format=raw
+ -drive file=test-cloudinit.qcow2,if=virtio,format=qcow2 \
+ -drive file=my-seed.img,if=virtio,format=raw
5. Analyze the boot (blame, dump, show)
-.. code-block:: bash
+.. code-block:: shell-session
$ ssh -p 2222 ubuntu@localhost 'cat /var/log/cloud-init.log' | \
- cloud-init analyze blame -i -
+ cloud-init analyze blame -i -
Running single cloud config modules
@@ -136,7 +141,7 @@ prevents a module from running again if it has already been run. To ensure that
a module is run again, the desired frequency can be overridden on the
commandline:
-.. code-block:: bash
+.. code-block:: shell-session
$ sudo cloud-init single --name cc_ssh --frequency always
...
diff --git a/doc/rtd/topics/modules.rst b/doc/rtd/topics/modules.rst
index 7b146751..d9720f6a 100644
--- a/doc/rtd/topics/modules.rst
+++ b/doc/rtd/topics/modules.rst
@@ -45,6 +45,7 @@ Modules
.. automodule:: cloudinit.config.cc_seed_random
.. automodule:: cloudinit.config.cc_set_hostname
.. automodule:: cloudinit.config.cc_set_passwords
+.. automodule:: cloudinit.config.cc_snap
.. automodule:: cloudinit.config.cc_snappy
.. automodule:: cloudinit.config.cc_snap_config
.. automodule:: cloudinit.config.cc_spacewalk
@@ -52,6 +53,7 @@ Modules
.. automodule:: cloudinit.config.cc_ssh_authkey_fingerprints
.. automodule:: cloudinit.config.cc_ssh_import_id
.. automodule:: cloudinit.config.cc_timezone
+.. automodule:: cloudinit.config.cc_ubuntu_advantage
.. automodule:: cloudinit.config.cc_update_etc_hosts
.. automodule:: cloudinit.config.cc_update_hostname
.. automodule:: cloudinit.config.cc_users_groups
diff --git a/doc/rtd/topics/network-config.rst b/doc/rtd/topics/network-config.rst
index 96c1cf59..1e994551 100644
--- a/doc/rtd/topics/network-config.rst
+++ b/doc/rtd/topics/network-config.rst
@@ -202,7 +202,7 @@ is helpful for examining expected output for a given input format.
CLI Interface :
-.. code-block:: bash
+.. code-block:: shell-session
% tools/net-convert.py --help
usage: net-convert.py [-h] --network-data PATH --kind
@@ -222,7 +222,7 @@ CLI Interface :
Example output converting V2 to sysconfig:
-.. code-block:: bash
+.. code-block:: shell-session
% tools/net-convert.py --network-data v2.yaml --kind yaml \
--output-kind sysconfig -d target
diff --git a/doc/rtd/topics/tests.rst b/doc/rtd/topics/tests.rst
index bf04bb3c..cac4a6e4 100644
--- a/doc/rtd/topics/tests.rst
+++ b/doc/rtd/topics/tests.rst
@@ -21,7 +21,7 @@ Overview
In order to avoid the need for dependencies and ease the setup and
configuration users can run the integration tests via tox:
-.. code-block:: bash
+.. code-block:: shell-session
$ git clone https://git.launchpad.net/cloud-init
$ cd cloud-init
@@ -51,7 +51,7 @@ The first example will provide a complete end-to-end run of data
collection and verification. There are additional examples below
explaining how to run one or the other independently.
-.. code-block:: bash
+.. code-block:: shell-session
$ git clone https://git.launchpad.net/cloud-init
$ cd cloud-init
@@ -93,7 +93,7 @@ If developing tests it may be necessary to see if cloud-config works as
expected and the correct files are pulled down. In this case only a
collect can be ran by running:
-.. code-block:: bash
+.. code-block:: shell-session
$ tox -e citest -- collect -n xenial --data-dir /tmp/collection
@@ -106,7 +106,7 @@ Verify
When developing tests it is much easier to simply rerun the verify scripts
without the more lengthy collect process. This can be done by running:
-.. code-block:: bash
+.. code-block:: shell-session
$ tox -e citest -- verify --data-dir /tmp/collection
@@ -133,7 +133,7 @@ cloud-init deb from or use the ``tree_run`` command using a copy of
cloud-init located in a different directory, use the option ``--cloud-init
/path/to/cloud-init``.
-.. code-block:: bash
+.. code-block:: shell-session
$ tox -e citest -- tree_run --verbose \
--os-name xenial --os-name stretch \
@@ -331,7 +331,7 @@ Integration tests are located under the `tests/cloud_tests` directory.
Test configurations are placed under `configs` and the test verification
scripts under `testcases`:
-.. code-block:: bash
+.. code-block:: shell-session
cloud-init$ tree -d tests/cloud_tests/
tests/cloud_tests/
@@ -362,7 +362,7 @@ The following would create a test case named ``example`` under the
``modules`` category with the given description, and cloud config data read
in from ``/tmp/user_data``.
-.. code-block:: bash
+.. code-block:: shell-session
$ tox -e citest -- create modules/example \
-d "a simple example test case" -c "$(< /tmp/user_data)"
@@ -385,7 +385,7 @@ Development Checklist
* Placed in the appropriate sub-folder in the test cases directory
* Tested by running the test:
- .. code-block:: bash
+ .. code-block:: shell-session
$ tox -e citest -- run -verbose \
--os-name <release target> \
@@ -404,14 +404,14 @@ These configuration files are the standard that the AWS cli and other AWS
tools utilize for interacting directly with AWS itself and are normally
generated when running ``aws configure``:
-.. code-block:: bash
+.. code-block:: shell-session
$ cat $HOME/.aws/credentials
[default]
aws_access_key_id = <KEY HERE>
aws_secret_access_key = <KEY HERE>
-.. code-block:: bash
+.. code-block:: shell-session
$ cat $HOME/.aws/config
[default]
diff --git a/packages/debian/control.in b/packages/debian/control.in
index 265b261f..46da6dff 100644
--- a/packages/debian/control.in
+++ b/packages/debian/control.in
@@ -10,7 +10,8 @@ Standards-Version: 3.9.6
Package: cloud-init
Architecture: all
Depends: ${misc:Depends},
- ${${python}:Depends}
+ ${${python}:Depends},
+ isc-dhcp-client
Recommends: eatmydata, sudo, software-properties-common, gdisk
XB-Python-Version: ${python:Versions}
Description: Init scripts for cloud instances
diff --git a/tests/cloud_tests/bddeb.py b/tests/cloud_tests/bddeb.py
index a6d5069f..b9cfcfa6 100644
--- a/tests/cloud_tests/bddeb.py
+++ b/tests/cloud_tests/bddeb.py
@@ -16,7 +16,7 @@ pre_reqs = ['devscripts', 'equivs', 'git', 'tar']
def _out(cmd_res):
"""Get clean output from cmd result."""
- return cmd_res[0].strip()
+ return cmd_res[0].decode("utf-8").strip()
def build_deb(args, instance):
diff --git a/tests/cloud_tests/platforms/ec2/__init__.py b/tests/cloud_tests/platforms/ec2/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/tests/cloud_tests/platforms/ec2/__init__.py
diff --git a/tests/cloud_tests/platforms/lxd/__init__.py b/tests/cloud_tests/platforms/lxd/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/tests/cloud_tests/platforms/lxd/__init__.py
diff --git a/tests/cloud_tests/platforms/lxd/platform.py b/tests/cloud_tests/platforms/lxd/platform.py
index 6a016929..f7251a07 100644
--- a/tests/cloud_tests/platforms/lxd/platform.py
+++ b/tests/cloud_tests/platforms/lxd/platform.py
@@ -101,8 +101,4 @@ class LXDPlatform(Platform):
"""
return self.client.images.get_by_alias(alias)
- def destroy(self):
- """Clean up platform data."""
- super(LXDPlatform, self).destroy()
-
# vi: ts=4 expandtab
diff --git a/tests/cloud_tests/platforms/nocloudkvm/__init__.py b/tests/cloud_tests/platforms/nocloudkvm/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/tests/cloud_tests/platforms/nocloudkvm/__init__.py
diff --git a/tests/cloud_tests/platforms/nocloudkvm/instance.py b/tests/cloud_tests/platforms/nocloudkvm/instance.py
index 932dc0fa..33ff3f24 100644
--- a/tests/cloud_tests/platforms/nocloudkvm/instance.py
+++ b/tests/cloud_tests/platforms/nocloudkvm/instance.py
@@ -109,7 +109,7 @@ class NoCloudKVMInstance(Instance):
if self.pid:
try:
c_util.subp(['kill', '-9', self.pid])
- except util.ProcessExectuionError:
+ except c_util.ProcessExecutionError:
pass
if self.pid_file:
diff --git a/tests/cloud_tests/platforms/nocloudkvm/platform.py b/tests/cloud_tests/platforms/nocloudkvm/platform.py
index a7e6f5de..85933463 100644
--- a/tests/cloud_tests/platforms/nocloudkvm/platform.py
+++ b/tests/cloud_tests/platforms/nocloudkvm/platform.py
@@ -21,10 +21,6 @@ class NoCloudKVMPlatform(Platform):
platform_name = 'nocloud-kvm'
- def __init__(self, config):
- """Set up platform."""
- super(NoCloudKVMPlatform, self).__init__(config)
-
def get_image(self, img_conf):
"""Get image using specified image configuration.
diff --git a/tests/cloud_tests/platforms/platforms.py b/tests/cloud_tests/platforms/platforms.py
index 1542b3be..abbfebba 100644
--- a/tests/cloud_tests/platforms/platforms.py
+++ b/tests/cloud_tests/platforms/platforms.py
@@ -2,12 +2,15 @@
"""Base platform class."""
import os
+import shutil
from simplestreams import filters, mirrors
from simplestreams import util as s_util
from cloudinit import util as c_util
+from tests.cloud_tests import util
+
class Platform(object):
"""Base class for platforms."""
@@ -17,7 +20,14 @@ class Platform(object):
def __init__(self, config):
"""Set up platform."""
self.config = config
- self._generate_ssh_keys(config['data_dir'])
+ self.tmpdir = util.mkdtemp()
+ if 'data_dir' in config:
+ self.data_dir = config['data_dir']
+ else:
+ self.data_dir = os.path.join(self.tmpdir, "data_dir")
+ os.mkdir(self.data_dir)
+
+ self._generate_ssh_keys(self.data_dir)
def get_image(self, img_conf):
"""Get image using specified image configuration.
@@ -29,7 +39,7 @@ class Platform(object):
def destroy(self):
"""Clean up platform data."""
- pass
+ shutil.rmtree(self.tmpdir)
def _generate_ssh_keys(self, data_dir):
"""Generate SSH keys to be used with image."""
diff --git a/tests/cloud_tests/releases.yaml b/tests/cloud_tests/releases.yaml
index d8bc170f..c7dcbe83 100644
--- a/tests/cloud_tests/releases.yaml
+++ b/tests/cloud_tests/releases.yaml
@@ -30,6 +30,9 @@ default_release_config:
mirror_url: https://cloud-images.ubuntu.com/daily
mirror_dir: '/srv/citest/images'
keyring: /usr/share/keyrings/ubuntu-cloudimage-keyring.gpg
+ # The OS version formatted as Major.Minor is used to compare releases
+ version: null # Each release needs to define this, for example 16.04
+
ec2:
# Choose from: [ebs, instance-store]
root-store: ebs
diff --git a/tests/cloud_tests/testcases.yaml b/tests/cloud_tests/testcases.yaml
index 8e0fb62f..a3e29900 100644
--- a/tests/cloud_tests/testcases.yaml
+++ b/tests/cloud_tests/testcases.yaml
@@ -15,6 +15,9 @@ base_test_data:
instance-id: |
#!/bin/sh
cat /run/cloud-init/.instance-id
+ instance-data.json: |
+ #!/bin/sh
+ cat /run/cloud-init/instance-data.json
result.json: |
#!/bin/sh
cat /run/cloud-init/result.json
diff --git a/tests/cloud_tests/testcases/__init__.py b/tests/cloud_tests/testcases/__init__.py
index a29a0928..bd548f5a 100644
--- a/tests/cloud_tests/testcases/__init__.py
+++ b/tests/cloud_tests/testcases/__init__.py
@@ -7,6 +7,8 @@ import inspect
import unittest
from unittest.util import strclass
+from cloudinit.util import read_conf
+
from tests.cloud_tests import config
from tests.cloud_tests.testcases.base import CloudTestCase as base_test
@@ -48,6 +50,7 @@ def get_suite(test_name, data, conf):
def setUpClass(cls):
cls.data = data
cls.conf = conf
+ cls.release_conf = read_conf(config.RELEASES_CONF)['releases']
suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(tmp))
diff --git a/tests/cloud_tests/testcases/base.py b/tests/cloud_tests/testcases/base.py
index 20e95955..324c7c91 100644
--- a/tests/cloud_tests/testcases/base.py
+++ b/tests/cloud_tests/testcases/base.py
@@ -4,10 +4,14 @@
import crypt
import json
+import re
import unittest
+
from cloudinit import util as c_util
+SkipTest = unittest.SkipTest
+
class CloudTestCase(unittest.TestCase):
"""Base test class for verifiers."""
@@ -16,6 +20,43 @@ class CloudTestCase(unittest.TestCase):
data = {}
conf = None
_cloud_config = None
+ release_conf = {} # The platform's os release configuration
+
+ expected_warnings = () # Subclasses set to ignore expected WARN logs
+
+ @property
+ def os_cfg(self):
+ return self.release_conf[self.os_name]['default']
+
+ def is_distro(self, distro_name):
+ return self.os_cfg['os'] == distro_name
+
+ def os_version_cmp(self, cmp_version):
+ """Compare the version of the test to comparison_version.
+
+ @param: cmp_version: Either a float or a string representing
+ a release os from releases.yaml (e.g. centos66)
+
+ @return: -1 when version < cmp_version, 0 when version=cmp_version and
+ 1 when version > cmp_version.
+ """
+ version = self.release_conf[self.os_name]['default']['version']
+ if isinstance(cmp_version, str):
+ cmp_version = self.release_conf[cmp_version]['default']['version']
+ if version < cmp_version:
+ return -1
+ elif version == cmp_version:
+ return 0
+ else:
+ return 1
+
+ @property
+ def os_name(self):
+ return self.data.get('os_name', 'UNKNOWN')
+
+ @property
+ def platform(self):
+ return self.data.get('platform', 'UNKNOWN')
@property
def cloud_config(self):
@@ -72,12 +113,134 @@ class CloudTestCase(unittest.TestCase):
self.assertEqual(len(result['errors']), 0)
def test_no_warnings_in_log(self):
- """Warnings should not be found in the log."""
+ """Unexpected warnings should not be found in the log."""
+ warnings = [
+ l for l in self.get_data_file('cloud-init.log').splitlines()
+ if 'WARN' in l]
+ joined_warnings = '\n'.join(warnings)
+ for expected_warning in self.expected_warnings:
+ self.assertIn(
+ expected_warning, joined_warnings,
+ msg="Did not find %s in cloud-init.log" % expected_warning)
+ # Prune expected from discovered warnings
+ warnings = [w for w in warnings if expected_warning not in w]
+ self.assertEqual(
+ [], warnings, msg="'WARN' found inside cloud-init.log")
+
+ def test_instance_data_json_ec2(self):
+ """Validate instance-data.json content by ec2 platform.
+
+ This content is sourced by snapd when determining snapstore endpoints.
+ We validate expected values per cloud type to ensure we don't break
+ snapd.
+ """
+ if self.platform != 'ec2':
+ raise SkipTest(
+ 'Skipping ec2 instance-data.json on %s' % self.platform)
+ out = self.get_data_file('instance-data.json')
+ if not out:
+ if self.is_distro('ubuntu') and self.os_version_cmp('bionic') >= 0:
+ raise AssertionError(
+ 'No instance-data.json found on %s' % self.os_name)
+ raise SkipTest(
+ 'Skipping instance-data.json test.'
+ ' OS: %s not bionic or newer' % self.os_name)
+ instance_data = json.loads(out)
+ self.assertEqual(
+ ['ds/user-data'], instance_data['base64-encoded-keys'])
+ ds = instance_data.get('ds', {})
+ macs = ds.get('network', {}).get('interfaces', {}).get('macs', {})
+ if not macs:
+ raise AssertionError('No network data from EC2 meta-data')
+ # Check meta-data items we depend on
+ expected_net_keys = [
+ 'public-ipv4s', 'ipv4-associations', 'local-hostname',
+ 'public-hostname']
+ for mac, mac_data in macs.items():
+ for key in expected_net_keys:
+ self.assertIn(key, mac_data)
+ self.assertIsNotNone(
+ ds.get('placement', {}).get('availability-zone'),
+ 'Could not determine EC2 Availability zone placement')
+ ds = instance_data.get('ds', {})
+ v1_data = instance_data.get('v1', {})
+ self.assertIsNotNone(
+ v1_data['availability-zone'], 'expected ec2 availability-zone')
+ self.assertEqual('aws', v1_data['cloud-name'])
+ self.assertIn('i-', v1_data['instance-id'])
+ self.assertIn('ip-', v1_data['local-hostname'])
+ self.assertIsNotNone(v1_data['region'], 'expected ec2 region')
+
+ def test_instance_data_json_lxd(self):
+ """Validate instance-data.json content by lxd platform.
+
+ This content is sourced by snapd when determining snapstore endpoints.
+ We validate expected values per cloud type to ensure we don't break
+ snapd.
+ """
+ if self.platform != 'lxd':
+ raise SkipTest(
+ 'Skipping lxd instance-data.json on %s' % self.platform)
+ out = self.get_data_file('instance-data.json')
+ if not out:
+ if self.is_distro('ubuntu') and self.os_version_cmp('bionic') >= 0:
+ raise AssertionError(
+ 'No instance-data.json found on %s' % self.os_name)
+ raise SkipTest(
+ 'Skipping instance-data.json test.'
+ ' OS: %s not bionic or newer' % self.os_name)
+ instance_data = json.loads(out)
+ v1_data = instance_data.get('v1', {})
+ self.assertEqual(
+ ['ds/user-data', 'ds/vendor-data'],
+ sorted(instance_data['base64-encoded-keys']))
+ self.assertEqual('nocloud', v1_data['cloud-name'])
+ self.assertIsNone(
+ v1_data['availability-zone'],
+ 'found unexpected lxd availability-zone %s' %
+ v1_data['availability-zone'])
+ self.assertIn('cloud-test', v1_data['instance-id'])
+ self.assertIn('cloud-test', v1_data['local-hostname'])
+ self.assertIsNone(
+ v1_data['region'],
+ 'found unexpected lxd region %s' % v1_data['region'])
+
+ def test_instance_data_json_kvm(self):
+ """Validate instance-data.json content by nocloud-kvm platform.
+
+ This content is sourced by snapd when determining snapstore endpoints.
+ We validate expected values per cloud type to ensure we don't break
+ snapd.
+ """
+ if self.platform != 'nocloud-kvm':
+ raise SkipTest(
+ 'Skipping nocloud-kvm instance-data.json on %s' %
+ self.platform)
+ out = self.get_data_file('instance-data.json')
+ if not out:
+ if self.is_distro('ubuntu') and self.os_version_cmp('bionic') >= 0:
+ raise AssertionError(
+ 'No instance-data.json found on %s' % self.os_name)
+ raise SkipTest(
+ 'Skipping instance-data.json test.'
+ ' OS: %s not bionic or newer' % self.os_name)
+ instance_data = json.loads(out)
+ v1_data = instance_data.get('v1', {})
self.assertEqual(
- [],
- [l for l in self.get_data_file('cloud-init.log').splitlines()
- if 'WARN' in l],
- msg="'WARN' found inside cloud-init.log")
+ ['ds/user-data'], instance_data['base64-encoded-keys'])
+ self.assertEqual('nocloud', v1_data['cloud-name'])
+ self.assertIsNone(
+ v1_data['availability-zone'],
+ 'found unexpected kvm availability-zone %s' %
+ v1_data['availability-zone'])
+ self.assertIsNotNone(
+ re.match('[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}',
+ v1_data['instance-id']),
+ 'kvm instance-id is not a UUID: %s' % v1_data['instance-id'])
+ self.assertIn('ubuntu', v1_data['local-hostname'])
+ self.assertIsNone(
+ v1_data['region'],
+ 'found unexpected lxd region %s' % v1_data['region'])
class PasswordListTest(CloudTestCase):
diff --git a/tests/cloud_tests/testcases/main/command_output_simple.py b/tests/cloud_tests/testcases/main/command_output_simple.py
index 857881cb..80a2c8d7 100644
--- a/tests/cloud_tests/testcases/main/command_output_simple.py
+++ b/tests/cloud_tests/testcases/main/command_output_simple.py
@@ -7,6 +7,8 @@ from tests.cloud_tests.testcases import base
class TestCommandOutputSimple(base.CloudTestCase):
"""Test functionality of simple output redirection."""
+ expected_warnings = ('Stdout, stderr changing to',)
+
def test_output_file(self):
"""Ensure that the output file is not empty and has all stages."""
data = self.get_data_file('cloud-init-test-output')
@@ -15,20 +17,5 @@ class TestCommandOutputSimple(base.CloudTestCase):
data.splitlines()[-1].strip())
# TODO: need to test that all stages redirected here
- def test_no_warnings_in_log(self):
- """Warnings should not be found in the log.
-
- This class redirected stderr and stdout, so it expects to find
- a warning in cloud-init.log to that effect."""
- redirect_msg = 'Stdout, stderr changing to'
- warnings = [
- l for l in self.get_data_file('cloud-init.log').splitlines()
- if 'WARN' in l]
- self.assertEqual(
- [], [w for w in warnings if redirect_msg not in w],
- msg="'WARN' found inside cloud-init.log")
- self.assertEqual(
- 1, len(warnings),
- msg="Did not find %s in cloud-init.log" % redirect_msg)
# vi: ts=4 expandtab
diff --git a/tests/cloud_tests/testcases/modules/salt_minion.py b/tests/cloud_tests/testcases/modules/salt_minion.py
index c697db2d..70917a4c 100644
--- a/tests/cloud_tests/testcases/modules/salt_minion.py
+++ b/tests/cloud_tests/testcases/modules/salt_minion.py
@@ -26,4 +26,14 @@ class Test(base.CloudTestCase):
self.assertIn('<key data>', out)
self.assertIn('------END PUBLIC KEY-------', out)
+ def test_grains(self):
+ """Test master value in config."""
+ out = self.get_data_file('grains')
+ self.assertIn('role: web', out)
+
+ def test_minion_installed(self):
+ """Test if the salt-minion package is installed"""
+ out = self.get_data_file('minion_installed')
+ self.assertEqual(1, int(out))
+
# vi: ts=4 expandtab
diff --git a/tests/cloud_tests/testcases/modules/salt_minion.yaml b/tests/cloud_tests/testcases/modules/salt_minion.yaml
index f20d24f0..f20b9765 100644
--- a/tests/cloud_tests/testcases/modules/salt_minion.yaml
+++ b/tests/cloud_tests/testcases/modules/salt_minion.yaml
@@ -3,7 +3,7 @@
#
# 2016-11-17: Currently takes >60 seconds results in test failure
#
-enabled: False
+enabled: True
cloud_config: |
#cloud-config
salt_minion:
@@ -17,6 +17,8 @@ cloud_config: |
------BEGIN PRIVATE KEY------
<key data>
------END PRIVATE KEY-------
+ grains:
+ role: web
collect_scripts:
minion: |
#!/bin/bash
@@ -30,5 +32,11 @@ collect_scripts:
minion.pub: |
#!/bin/bash
cat /etc/salt/pki/minion/minion.pub
+ grains: |
+ #!/bin/bash
+ cat /etc/salt/grains
+ minion_installed: |
+ #!/bin/bash
+ dpkg -l | grep salt-minion | grep ii | wc -l
# vi: ts=4 expandtab
diff --git a/tests/cloud_tests/testcases/modules/snap.py b/tests/cloud_tests/testcases/modules/snap.py
new file mode 100644
index 00000000..ff68abbe
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/snap.py
@@ -0,0 +1,16 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestSnap(base.CloudTestCase):
+ """Test snap module"""
+
+ def test_snappy_version(self):
+ """Expect hello-world and core snaps are installed."""
+ out = self.get_data_file('snaplist')
+ self.assertIn('core', out)
+ self.assertIn('hello-world', out)
+
+# vi: ts=4 expandtab
diff --git a/tests/cloud_tests/testcases/modules/snap.yaml b/tests/cloud_tests/testcases/modules/snap.yaml
new file mode 100644
index 00000000..44043f31
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/snap.yaml
@@ -0,0 +1,18 @@
+#
+# Install snappy
+#
+required_features:
+ - snap
+cloud_config: |
+ #cloud-config
+ package_update: true
+ snap:
+ squashfuse_in_container: true
+ commands:
+ - snap install hello-world
+collect_scripts:
+ snaplist: |
+ #!/bin/bash
+ snap list
+
+# vi: ts=4 expandtab
diff --git a/tests/cloud_tests/testcases/modules/snappy.py b/tests/cloud_tests/testcases/modules/snappy.py
index b92271c1..7d17fc5b 100644
--- a/tests/cloud_tests/testcases/modules/snappy.py
+++ b/tests/cloud_tests/testcases/modules/snappy.py
@@ -7,6 +7,8 @@ from tests.cloud_tests.testcases import base
class TestSnappy(base.CloudTestCase):
"""Test snappy module"""
+ expected_warnings = ('DEPRECATION',)
+
def test_snappy_version(self):
"""Test snappy version output"""
out = self.get_data_file('snapd')
diff --git a/tests/cloud_tests/util.py b/tests/cloud_tests/util.py
index 6ff285e7..3dd4996d 100644
--- a/tests/cloud_tests/util.py
+++ b/tests/cloud_tests/util.py
@@ -460,6 +460,10 @@ class PlatformError(IOError):
IOError.__init__(self, message)
+def mkdtemp(prefix='cloud_test_data'):
+ return tempfile.mkdtemp(prefix=prefix)
+
+
class TempDir(object):
"""Configurable temporary directory like tempfile.TemporaryDirectory."""
@@ -480,7 +484,7 @@ class TempDir(object):
@return_value: tempdir path
"""
if not self.tmpdir:
- self.tmpdir = tempfile.mkdtemp(prefix=self.prefix)
+ self.tmpdir = mkdtemp(prefix=self.prefix)
LOG.debug('using tmpdir: %s', self.tmpdir)
return self.tmpdir
diff --git a/tests/cloud_tests/verify.py b/tests/cloud_tests/verify.py
index 2a9fd520..5a68a484 100644
--- a/tests/cloud_tests/verify.py
+++ b/tests/cloud_tests/verify.py
@@ -8,13 +8,16 @@ import unittest
from tests.cloud_tests import (config, LOG, util, testcases)
-def verify_data(base_dir, tests):
+def verify_data(data_dir, platform, os_name, tests):
"""Verify test data is correct.
- @param base_dir: base directory for data
+ @param data_dir: top level directory for all tests
+ @param platform: The platform name we for this test data (e.g. lxd)
+ @param os_name: The operating system under test (xenial, artful, etc.).
@param tests: list of test names
@return_value: {<test_name>: {passed: True/False, failures: []}}
"""
+ base_dir = os.sep.join((data_dir, platform, os_name))
runner = unittest.TextTestRunner(verbosity=util.current_verbosity())
res = {}
for test_name in tests:
@@ -26,7 +29,7 @@ def verify_data(base_dir, tests):
cloud_conf = test_conf['cloud_config']
# load script outputs
- data = {}
+ data = {'platform': platform, 'os_name': os_name}
test_dir = os.path.join(base_dir, test_name)
for script_name in os.listdir(test_dir):
with open(os.path.join(test_dir, script_name), 'rb') as fp:
@@ -73,7 +76,7 @@ def verify(args):
# run test
res[platform][os_name] = verify_data(
- os.sep.join((args.data_dir, platform, os_name)),
+ args.data_dir, platform, os_name,
tests[platform][os_name])
# handle results
diff --git a/tests/data/mount_parse_ext.txt b/tests/data/mount_parse_ext.txt
new file mode 100644
index 00000000..da0c870d
--- /dev/null
+++ b/tests/data/mount_parse_ext.txt
@@ -0,0 +1,19 @@
+/dev/mapper/vg00-lv_root on / type ext4 (rw,errors=remount-ro)
+proc on /proc type proc (rw,noexec,nosuid,nodev)
+sysfs on /sys type sysfs (rw,noexec,nosuid,nodev)
+none on /sys/fs/cgroup type tmpfs (rw)
+none on /sys/fs/fuse/connections type fusectl (rw)
+none on /sys/kernel/debug type debugfs (rw)
+none on /sys/kernel/security type securityfs (rw)
+udev on /dev type devtmpfs (rw,mode=0755)
+devpts on /dev/pts type devpts (rw,noexec,nosuid,gid=5,mode=0620)
+none on /tmp type tmpfs (rw)
+tmpfs on /run type tmpfs (rw,noexec,nosuid,size=10%,mode=0755)
+none on /run/lock type tmpfs (rw,noexec,nosuid,nodev,size=5242880)
+none on /run/shm type tmpfs (rw,nosuid,nodev)
+none on /run/user type tmpfs (rw,noexec,nosuid,nodev,size=104857600,mode=0755)
+none on /sys/fs/pstore type pstore (rw)
+/dev/mapper/vg00-lv_var on /var type ext4 (rw)
+rpc_pipefs on /run/rpc_pipefs type rpc_pipefs (rw)
+systemd on /sys/fs/cgroup/systemd type cgroup (rw,noexec,nosuid,nodev,none,name=systemd)
+10.0.1.1:/backup on /backup type nfs (rw,noexec,nosuid,nodev,bg,nolock,tcp,nfsvers=3,hard,addr=10.0.1.1) \ No newline at end of file
diff --git a/tests/data/mount_parse_zfs.txt b/tests/data/mount_parse_zfs.txt
new file mode 100644
index 00000000..08af04fc
--- /dev/null
+++ b/tests/data/mount_parse_zfs.txt
@@ -0,0 +1,21 @@
+vmzroot/ROOT/freebsd on / (zfs, local, nfsv4acls)
+devfs on /dev (devfs, local, multilabel)
+fdescfs on /dev/fd (fdescfs)
+vmzroot/root on /root (zfs, local, nfsv4acls)
+vmzroot/tmp on /tmp (zfs, local, nosuid, nfsv4acls)
+vmzroot/ROOT/freebsd/usr on /usr (zfs, local, nfsv4acls)
+vmzroot/ROOT/freebsd/usr/local on /usr/local (zfs, local, nfsv4acls)
+vmzroot/ROOT/freebsd/var on /var (zfs, local, nfsv4acls)
+vmzroot/ROOT/freebsd/var/cache on /var/cache (zfs, local, noexec, nosuid, nfsv4acls)
+vmzroot/ROOT/freebsd/var/crash on /var/crash (zfs, local, noexec, nosuid, nfsv4acls)
+vmzroot/var/cron on /var/cron (zfs, local, nosuid, nfsv4acls)
+vmzroot/ROOT/freebsd/var/db on /var/db (zfs, local, noatime, noexec, nosuid, nfsv4acls)
+vmzroot/ROOT/freebsd/var/empty on /var/empty (zfs, local, noexec, nosuid, read-only, nfsv4acls)
+vmzroot/var/log on /var/log (zfs, local, noexec, nosuid, nfsv4acls)
+vmzroot/var/log/pf on /var/log/pf (zfs, local, noexec, nosuid, nfsv4acls)
+vmzroot/var/mail on /var/mail (zfs, local, noexec, nosuid, nfsv4acls)
+vmzroot/ROOT/freebsd/var/run on /var/run (zfs, local, noexec, nosuid, nfsv4acls)
+vmzroot/var/spool on /var/spool (zfs, local, noexec, nosuid, nfsv4acls)
+vmzroot/var/tmp on /var/tmp (zfs, local, nosuid, nfsv4acls)
+10.0.0.1:/vol/test on /mnt/test (nfs, read-only)
+10.0.0.2:/vol/tes2 on /mnt/test2 (nfs, nosuid) \ No newline at end of file
diff --git a/tests/data/zpool_status_simple.txt b/tests/data/zpool_status_simple.txt
new file mode 100644
index 00000000..a2c573a3
--- /dev/null
+++ b/tests/data/zpool_status_simple.txt
@@ -0,0 +1,10 @@
+ pool: vmzroot
+ state: ONLINE
+ scan: none requested
+config:
+
+ NAME STATE READ WRITE CKSUM
+ vmzroot ONLINE 0 0 0
+ gpt/system ONLINE 0 0 0
+
+errors: No known data errors \ No newline at end of file
diff --git a/tests/unittests/test_datasource/test_azure.py b/tests/unittests/test_datasource/test_azure.py
index 254e9876..3e8b7913 100644
--- a/tests/unittests/test_datasource/test_azure.py
+++ b/tests/unittests/test_datasource/test_azure.py
@@ -643,6 +643,21 @@ fdescfs /dev/fd fdescfs rw 0 0
expected_config['config'].append(blacklist_config)
self.assertEqual(netconfig, expected_config)
+ @mock.patch("cloudinit.sources.DataSourceAzure.util.subp")
+ def test_get_hostname_with_no_args(self, subp):
+ dsaz.get_hostname()
+ subp.assert_called_once_with(("hostname",), capture=True)
+
+ @mock.patch("cloudinit.sources.DataSourceAzure.util.subp")
+ def test_get_hostname_with_string_arg(self, subp):
+ dsaz.get_hostname(hostname_command="hostname")
+ subp.assert_called_once_with(("hostname",), capture=True)
+
+ @mock.patch("cloudinit.sources.DataSourceAzure.util.subp")
+ def test_get_hostname_with_iterable_arg(self, subp):
+ dsaz.get_hostname(hostname_command=("hostname",))
+ subp.assert_called_once_with(("hostname",), capture=True)
+
class TestAzureBounce(CiTestCase):
@@ -1162,7 +1177,8 @@ class TestAzureDataSourcePreprovisioning(CiTestCase):
url = 'http://{0}/metadata/reprovisiondata?api-version=2017-04-02'
host = "169.254.169.254"
full_url = url.format(host)
- fake_resp.return_value = mock.MagicMock(status_code=200, text="ovf")
+ fake_resp.return_value = mock.MagicMock(status_code=200, text="ovf",
+ content="ovf")
dsa = dsaz.DataSourceAzure({}, distro=None, paths=self.paths)
self.assertTrue(len(dsa._poll_imds()) > 0)
self.assertEqual(fake_resp.call_args_list,
@@ -1170,13 +1186,8 @@ class TestAzureDataSourcePreprovisioning(CiTestCase):
headers={'Metadata': 'true',
'User-Agent':
'Cloud-Init/%s' % vs()
- }, method='GET', timeout=60.0,
- url=full_url),
- mock.call(allow_redirects=True,
- headers={'Metadata': 'true',
- 'User-Agent':
- 'Cloud-Init/%s' % vs()
- }, method='GET', url=full_url)])
+ }, method='GET', timeout=1,
+ url=full_url)])
self.assertEqual(m_dhcp.call_count, 1)
m_net.assert_any_call(
broadcast='192.168.2.255', interface='eth9', ip='192.168.2.9',
@@ -1202,7 +1213,8 @@ class TestAzureDataSourcePreprovisioning(CiTestCase):
username = "myuser"
odata = {'HostName': hostname, 'UserName': username}
content = construct_valid_ovf_env(data=odata)
- fake_resp.return_value = mock.MagicMock(status_code=200, text=content)
+ fake_resp.return_value = mock.MagicMock(status_code=200, text=content,
+ content=content)
dsa = dsaz.DataSourceAzure({}, distro=None, paths=self.paths)
md, ud, cfg, d = dsa._reprovision()
self.assertEqual(md['local-hostname'], hostname)
@@ -1212,12 +1224,7 @@ class TestAzureDataSourcePreprovisioning(CiTestCase):
headers={'Metadata': 'true',
'User-Agent':
'Cloud-Init/%s' % vs()},
- method='GET', timeout=60.0, url=full_url),
- mock.call(allow_redirects=True,
- headers={'Metadata': 'true',
- 'User-Agent':
- 'Cloud-Init/%s' % vs()},
- method='GET', url=full_url)])
+ method='GET', timeout=1, url=full_url)])
self.assertEqual(m_dhcp.call_count, 1)
m_net.assert_any_call(
broadcast='192.168.2.255', interface='eth9', ip='192.168.2.9',
diff --git a/tests/unittests/test_datasource/test_common.py b/tests/unittests/test_datasource/test_common.py
index 80b9c650..ec333888 100644
--- a/tests/unittests/test_datasource/test_common.py
+++ b/tests/unittests/test_datasource/test_common.py
@@ -14,6 +14,8 @@ from cloudinit.sources import (
DataSourceDigitalOcean as DigitalOcean,
DataSourceEc2 as Ec2,
DataSourceGCE as GCE,
+ DataSourceHetzner as Hetzner,
+ DataSourceIBMCloud as IBMCloud,
DataSourceMAAS as MAAS,
DataSourceNoCloud as NoCloud,
DataSourceOpenNebula as OpenNebula,
@@ -31,6 +33,8 @@ DEFAULT_LOCAL = [
CloudSigma.DataSourceCloudSigma,
ConfigDrive.DataSourceConfigDrive,
DigitalOcean.DataSourceDigitalOcean,
+ Hetzner.DataSourceHetzner,
+ IBMCloud.DataSourceIBMCloud,
NoCloud.DataSourceNoCloud,
OpenNebula.DataSourceOpenNebula,
OVF.DataSourceOVF,
diff --git a/tests/unittests/test_datasource/test_gce.py b/tests/unittests/test_datasource/test_gce.py
index f77c2c40..eb3cec42 100644
--- a/tests/unittests/test_datasource/test_gce.py
+++ b/tests/unittests/test_datasource/test_gce.py
@@ -38,11 +38,20 @@ GCE_META_ENCODING = {
'instance/hostname': 'server.project-baz.local',
'instance/zone': 'baz/bang',
'instance/attributes': {
- 'user-data': b64encode(b'/bin/echo baz\n').decode('utf-8'),
+ 'user-data': b64encode(b'#!/bin/echo baz\n').decode('utf-8'),
'user-data-encoding': 'base64',
}
}
+GCE_USER_DATA_TEXT = {
+ 'instance/id': '12345',
+ 'instance/hostname': 'server.project-baz.local',
+ 'instance/zone': 'baz/bang',
+ 'instance/attributes': {
+ 'user-data': '#!/bin/sh\necho hi mom\ntouch /run/up-now\n',
+ }
+}
+
HEADERS = {'Metadata-Flavor': 'Google'}
MD_URL_RE = re.compile(
r'http://metadata.google.internal/computeMetadata/v1/.*')
@@ -135,7 +144,16 @@ class TestDataSourceGCE(test_helpers.HttprettyTestCase):
shostname = GCE_META_PARTIAL.get('instance/hostname').split('.')[0]
self.assertEqual(shostname, self.ds.get_hostname())
+ def test_userdata_no_encoding(self):
+ """check that user-data is read."""
+ _set_mock_metadata(GCE_USER_DATA_TEXT)
+ self.ds.get_data()
+ self.assertEqual(
+ GCE_USER_DATA_TEXT['instance/attributes']['user-data'].encode(),
+ self.ds.get_userdata_raw())
+
def test_metadata_encoding(self):
+ """user-data is base64 encoded if user-data-encoding is 'base64'."""
_set_mock_metadata(GCE_META_ENCODING)
self.ds.get_data()
diff --git a/tests/unittests/test_datasource/test_hetzner.py b/tests/unittests/test_datasource/test_hetzner.py
new file mode 100644
index 00000000..a9c12597
--- /dev/null
+++ b/tests/unittests/test_datasource/test_hetzner.py
@@ -0,0 +1,117 @@
+# Copyright (C) 2018 Jonas Keidel
+#
+# Author: Jonas Keidel <jonas.keidel@hetzner.com>
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+
+from cloudinit.sources import DataSourceHetzner
+from cloudinit import util, settings, helpers
+
+from cloudinit.tests.helpers import mock, CiTestCase
+
+METADATA = util.load_yaml("""
+hostname: cloudinit-test
+instance-id: 123456
+local-ipv4: ''
+network-config:
+ config:
+ - mac_address: 96:00:00:08:19:da
+ name: eth0
+ subnets:
+ - dns_nameservers:
+ - 213.133.99.99
+ - 213.133.100.100
+ - 213.133.98.98
+ ipv4: true
+ type: dhcp
+ type: physical
+ - name: eth0:0
+ subnets:
+ - address: 2a01:4f8:beef:beef::1/64
+ gateway: fe80::1
+ ipv6: true
+ routes:
+ - gateway: fe80::1%eth0
+ netmask: 0
+ network: '::'
+ type: static
+ type: physical
+ version: 1
+network-sysconfig: "DEVICE='eth0'\nTYPE=Ethernet\nBOOTPROTO=dhcp\n\
+ ONBOOT='yes'\nHWADDR=96:00:00:08:19:da\n\
+ IPV6INIT=yes\nIPV6ADDR=2a01:4f8:beef:beef::1/64\n\
+ IPV6_DEFAULTGW=fe80::1%eth0\nIPV6_AUTOCONF=no\n\
+ DNS1=213.133.99.99\nDNS2=213.133.100.100\n"
+public-ipv4: 192.168.0.1
+public-keys:
+- ssh-ed25519 \
+ AAAAC3Nzac1lZdI1NTE5AaaAIaFrcac0yVITsmRrmueq6MD0qYNKlEvW8O1Ib4nkhmWh \
+ test-key@workstation
+vendor_data: "test"
+""")
+
+USERDATA = b"""#cloud-config
+runcmd:
+- [touch, /root/cloud-init-worked ]
+"""
+
+
+class TestDataSourceHetzner(CiTestCase):
+ """
+ Test reading the meta-data
+ """
+ def setUp(self):
+ super(TestDataSourceHetzner, self).setUp()
+ self.tmp = self.tmp_dir()
+
+ def get_ds(self):
+ ds = DataSourceHetzner.DataSourceHetzner(
+ settings.CFG_BUILTIN, None, helpers.Paths({'run_dir': self.tmp}))
+ return ds
+
+ @mock.patch('cloudinit.net.EphemeralIPv4Network')
+ @mock.patch('cloudinit.net.find_fallback_nic')
+ @mock.patch('cloudinit.sources.helpers.hetzner.read_metadata')
+ @mock.patch('cloudinit.sources.helpers.hetzner.read_userdata')
+ @mock.patch('cloudinit.sources.DataSourceHetzner.on_hetzner')
+ def test_read_data(self, m_on_hetzner, m_usermd, m_readmd, m_fallback_nic,
+ m_net):
+ m_on_hetzner.return_value = True
+ m_readmd.return_value = METADATA.copy()
+ m_usermd.return_value = USERDATA
+ m_fallback_nic.return_value = 'eth0'
+
+ ds = self.get_ds()
+ ret = ds.get_data()
+ self.assertTrue(ret)
+
+ m_net.assert_called_once_with(
+ 'eth0', '169.254.0.1',
+ 16, '169.254.255.255'
+ )
+
+ self.assertTrue(m_readmd.called)
+
+ self.assertEqual(METADATA.get('hostname'), ds.get_hostname())
+
+ self.assertEqual(METADATA.get('public-keys'),
+ ds.get_public_ssh_keys())
+
+ self.assertIsInstance(ds.get_public_ssh_keys(), list)
+ self.assertEqual(ds.get_userdata_raw(), USERDATA)
+ self.assertEqual(ds.get_vendordata_raw(), METADATA.get('vendor_data'))
+
+ @mock.patch('cloudinit.sources.helpers.hetzner.read_metadata')
+ @mock.patch('cloudinit.net.find_fallback_nic')
+ @mock.patch('cloudinit.sources.DataSourceHetzner.on_hetzner')
+ def test_not_on_hetzner_returns_false(self, m_on_hetzner, m_find_fallback,
+ m_read_md):
+ """If helper 'on_hetzner' returns False, return False from get_data."""
+ m_on_hetzner.return_value = False
+ ds = self.get_ds()
+ ret = ds.get_data()
+
+ self.assertFalse(ret)
+ # These are a white box attempt to ensure it did not search.
+ m_find_fallback.assert_not_called()
+ m_read_md.assert_not_called()
diff --git a/tests/unittests/test_datasource/test_ibmcloud.py b/tests/unittests/test_datasource/test_ibmcloud.py
new file mode 100644
index 00000000..621cfe49
--- /dev/null
+++ b/tests/unittests/test_datasource/test_ibmcloud.py
@@ -0,0 +1,262 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+from cloudinit.sources import DataSourceIBMCloud as ibm
+from cloudinit.tests import helpers as test_helpers
+
+import base64
+import copy
+import json
+import mock
+from textwrap import dedent
+
+D_PATH = "cloudinit.sources.DataSourceIBMCloud."
+
+
+class TestIBMCloud(test_helpers.CiTestCase):
+ """Test the datasource."""
+ def setUp(self):
+ super(TestIBMCloud, self).setUp()
+ pass
+
+
+@mock.patch(D_PATH + "_is_xen", return_value=True)
+@mock.patch(D_PATH + "_is_ibm_provisioning")
+@mock.patch(D_PATH + "util.blkid")
+class TestGetIBMPlatform(test_helpers.CiTestCase):
+ """Test the get_ibm_platform helper."""
+
+ blkid_base = {
+ "/dev/xvda1": {
+ "DEVNAME": "/dev/xvda1", "LABEL": "cloudimg-bootfs",
+ "TYPE": "ext3"},
+ "/dev/xvda2": {
+ "DEVNAME": "/dev/xvda2", "LABEL": "cloudimg-rootfs",
+ "TYPE": "ext4"},
+ }
+
+ blkid_metadata_disk = {
+ "/dev/xvdh1": {
+ "DEVNAME": "/dev/xvdh1", "LABEL": "METADATA", "TYPE": "vfat",
+ "SEC_TYPE": "msdos", "UUID": "681B-8C5D",
+ "PARTUUID": "3d631e09-01"},
+ }
+
+ blkid_oscode_disk = {
+ "/dev/xvdh": {
+ "DEVNAME": "/dev/xvdh", "LABEL": "config-2", "TYPE": "vfat",
+ "SEC_TYPE": "msdos", "UUID": ibm.IBM_CONFIG_UUID}
+ }
+
+ def setUp(self):
+ self.blkid_metadata = copy.deepcopy(self.blkid_base)
+ self.blkid_metadata.update(copy.deepcopy(self.blkid_metadata_disk))
+
+ self.blkid_oscode = copy.deepcopy(self.blkid_base)
+ self.blkid_oscode.update(copy.deepcopy(self.blkid_oscode_disk))
+
+ def test_id_template_live_metadata(self, m_blkid, m_is_prov, _m_xen):
+ """identify TEMPLATE_LIVE_METADATA."""
+ m_blkid.return_value = self.blkid_metadata
+ m_is_prov.return_value = False
+ self.assertEqual(
+ (ibm.Platforms.TEMPLATE_LIVE_METADATA, "/dev/xvdh1"),
+ ibm.get_ibm_platform())
+
+ def test_id_template_prov_metadata(self, m_blkid, m_is_prov, _m_xen):
+ """identify TEMPLATE_PROVISIONING_METADATA."""
+ m_blkid.return_value = self.blkid_metadata
+ m_is_prov.return_value = True
+ self.assertEqual(
+ (ibm.Platforms.TEMPLATE_PROVISIONING_METADATA, "/dev/xvdh1"),
+ ibm.get_ibm_platform())
+
+ def test_id_template_prov_nodata(self, m_blkid, m_is_prov, _m_xen):
+ """identify TEMPLATE_PROVISIONING_NODATA."""
+ m_blkid.return_value = self.blkid_base
+ m_is_prov.return_value = True
+ self.assertEqual(
+ (ibm.Platforms.TEMPLATE_PROVISIONING_NODATA, None),
+ ibm.get_ibm_platform())
+
+ def test_id_os_code(self, m_blkid, m_is_prov, _m_xen):
+ """Identify OS_CODE."""
+ m_blkid.return_value = self.blkid_oscode
+ m_is_prov.return_value = False
+ self.assertEqual((ibm.Platforms.OS_CODE, "/dev/xvdh"),
+ ibm.get_ibm_platform())
+
+ def test_id_os_code_must_match_uuid(self, m_blkid, m_is_prov, _m_xen):
+ """Test against false positive on openstack with non-ibm UUID."""
+ blkid = self.blkid_oscode
+ blkid["/dev/xvdh"]["UUID"] = "9999-9999"
+ m_blkid.return_value = blkid
+ m_is_prov.return_value = False
+ self.assertEqual((None, None), ibm.get_ibm_platform())
+
+
+@mock.patch(D_PATH + "_read_system_uuid", return_value=None)
+@mock.patch(D_PATH + "get_ibm_platform")
+class TestReadMD(test_helpers.CiTestCase):
+ """Test the read_datasource helper."""
+
+ template_md = {
+ "files": [],
+ "network_config": {"content_path": "/content/interfaces"},
+ "hostname": "ci-fond-ram",
+ "name": "ci-fond-ram",
+ "domain": "testing.ci.cloud-init.org",
+ "meta": {"dsmode": "net"},
+ "uuid": "8e636730-9f5d-c4a5-327c-d7123c46e82f",
+ "public_keys": {"1091307": "ssh-rsa AAAAB3NzaC1...Hw== ci-pubkey"},
+ }
+
+ oscode_md = {
+ "hostname": "ci-grand-gannet.testing.ci.cloud-init.org",
+ "name": "ci-grand-gannet",
+ "uuid": "2f266908-8e6c-4818-9b5c-42e9cc66a785",
+ "random_seed": "bm90LXJhbmRvbQo=",
+ "crypt_key": "ssh-rsa AAAAB3NzaC1yc2..n6z/",
+ "configuration_token": "eyJhbGciOi..M3ZA",
+ "public_keys": {"1091307": "ssh-rsa AAAAB3N..Hw== ci-pubkey"},
+ }
+
+ content_interfaces = dedent("""\
+ auto lo
+ iface lo inet loopback
+
+ auto eth0
+ allow-hotplug eth0
+ iface eth0 inet static
+ address 10.82.43.5
+ netmask 255.255.255.192
+ """)
+
+ userdata = b"#!/bin/sh\necho hi mom\n"
+ # meta.js file gets json encoded userdata as a list.
+ meta_js = '["#!/bin/sh\necho hi mom\n"]'
+ vendor_data = {
+ "cloud-init": "#!/bin/bash\necho 'root:$6$5ab01p1m1' | chpasswd -e"}
+
+ network_data = {
+ "links": [
+ {"id": "interface_29402281", "name": "eth0", "mtu": None,
+ "type": "phy", "ethernet_mac_address": "06:00:f1:bd:da:25"},
+ {"id": "interface_29402279", "name": "eth1", "mtu": None,
+ "type": "phy", "ethernet_mac_address": "06:98:5e:d0:7f:86"}
+ ],
+ "networks": [
+ {"id": "network_109887563", "link": "interface_29402281",
+ "type": "ipv4", "ip_address": "10.82.43.2",
+ "netmask": "255.255.255.192",
+ "routes": [
+ {"network": "10.0.0.0", "netmask": "255.0.0.0",
+ "gateway": "10.82.43.1"},
+ {"network": "161.26.0.0", "netmask": "255.255.0.0",
+ "gateway": "10.82.43.1"}]},
+ {"id": "network_109887551", "link": "interface_29402279",
+ "type": "ipv4", "ip_address": "108.168.194.252",
+ "netmask": "255.255.255.248",
+ "routes": [
+ {"network": "0.0.0.0", "netmask": "0.0.0.0",
+ "gateway": "108.168.194.249"}]}
+ ],
+ "services": [
+ {"type": "dns", "address": "10.0.80.11"},
+ {"type": "dns", "address": "10.0.80.12"}
+ ],
+ }
+
+ sysuuid = '7f79ebf5-d791-43c3-a723-854e8389d59f'
+
+ def _get_expected_metadata(self, os_md):
+ """return expected 'metadata' for data loaded from meta_data.json."""
+ os_md = copy.deepcopy(os_md)
+ renames = (
+ ('hostname', 'local-hostname'),
+ ('uuid', 'instance-id'),
+ ('public_keys', 'public-keys'))
+ ret = {}
+ for osname, mdname in renames:
+ if osname in os_md:
+ ret[mdname] = os_md[osname]
+ if 'random_seed' in os_md:
+ ret['random_seed'] = base64.b64decode(os_md['random_seed'])
+
+ return ret
+
+ def test_provisioning_md(self, m_platform, m_sysuuid):
+ """Provisioning env with a metadata disk should return None."""
+ m_platform.return_value = (
+ ibm.Platforms.TEMPLATE_PROVISIONING_METADATA, "/dev/xvdh")
+ self.assertIsNone(ibm.read_md())
+
+ def test_provisioning_no_metadata(self, m_platform, m_sysuuid):
+ """Provisioning env with no metadata disk should return None."""
+ m_platform.return_value = (
+ ibm.Platforms.TEMPLATE_PROVISIONING_NODATA, None)
+ self.assertIsNone(ibm.read_md())
+
+ def test_provisioning_not_ibm(self, m_platform, m_sysuuid):
+ """Provisioning env but not identified as IBM should return None."""
+ m_platform.return_value = (None, None)
+ self.assertIsNone(ibm.read_md())
+
+ def test_template_live(self, m_platform, m_sysuuid):
+ """Template live environment should be identified."""
+ tmpdir = self.tmp_dir()
+ m_platform.return_value = (
+ ibm.Platforms.TEMPLATE_LIVE_METADATA, tmpdir)
+ m_sysuuid.return_value = self.sysuuid
+
+ test_helpers.populate_dir(tmpdir, {
+ 'openstack/latest/meta_data.json': json.dumps(self.template_md),
+ 'openstack/latest/user_data': self.userdata,
+ 'openstack/content/interfaces': self.content_interfaces,
+ 'meta.js': self.meta_js})
+
+ ret = ibm.read_md()
+ self.assertEqual(ibm.Platforms.TEMPLATE_LIVE_METADATA,
+ ret['platform'])
+ self.assertEqual(tmpdir, ret['source'])
+ self.assertEqual(self.userdata, ret['userdata'])
+ self.assertEqual(self._get_expected_metadata(self.template_md),
+ ret['metadata'])
+ self.assertEqual(self.sysuuid, ret['system-uuid'])
+
+ def test_os_code_live(self, m_platform, m_sysuuid):
+ """Verify an os_code metadata path."""
+ tmpdir = self.tmp_dir()
+ m_platform.return_value = (ibm.Platforms.OS_CODE, tmpdir)
+ netdata = json.dumps(self.network_data)
+ test_helpers.populate_dir(tmpdir, {
+ 'openstack/latest/meta_data.json': json.dumps(self.oscode_md),
+ 'openstack/latest/user_data': self.userdata,
+ 'openstack/latest/vendor_data.json': json.dumps(self.vendor_data),
+ 'openstack/latest/network_data.json': netdata,
+ })
+
+ ret = ibm.read_md()
+ self.assertEqual(ibm.Platforms.OS_CODE, ret['platform'])
+ self.assertEqual(tmpdir, ret['source'])
+ self.assertEqual(self.userdata, ret['userdata'])
+ self.assertEqual(self._get_expected_metadata(self.oscode_md),
+ ret['metadata'])
+
+ def test_os_code_live_no_userdata(self, m_platform, m_sysuuid):
+ """Verify os_code without user-data."""
+ tmpdir = self.tmp_dir()
+ m_platform.return_value = (ibm.Platforms.OS_CODE, tmpdir)
+ test_helpers.populate_dir(tmpdir, {
+ 'openstack/latest/meta_data.json': json.dumps(self.oscode_md),
+ 'openstack/latest/vendor_data.json': json.dumps(self.vendor_data),
+ })
+
+ ret = ibm.read_md()
+ self.assertEqual(ibm.Platforms.OS_CODE, ret['platform'])
+ self.assertEqual(tmpdir, ret['source'])
+ self.assertIsNone(ret['userdata'])
+ self.assertEqual(self._get_expected_metadata(self.oscode_md),
+ ret['metadata'])
+
+
+# vi: ts=4 expandtab
diff --git a/tests/unittests/test_datasource/test_opennebula.py b/tests/unittests/test_datasource/test_opennebula.py
index 5c3ba012..ab42f344 100644
--- a/tests/unittests/test_datasource/test_opennebula.py
+++ b/tests/unittests/test_datasource/test_opennebula.py
@@ -4,7 +4,6 @@ from cloudinit import helpers
from cloudinit.sources import DataSourceOpenNebula as ds
from cloudinit import util
from cloudinit.tests.helpers import mock, populate_dir, CiTestCase
-from textwrap import dedent
import os
import pwd
@@ -33,6 +32,11 @@ HOSTNAME = 'foo.example.com'
PUBLIC_IP = '10.0.0.3'
MACADDR = '02:00:0a:12:01:01'
IP_BY_MACADDR = '10.18.1.1'
+IP4_PREFIX = '24'
+IP6_GLOBAL = '2001:db8:1:0:400:c0ff:fea8:1ba'
+IP6_ULA = 'fd01:dead:beaf:0:400:c0ff:fea8:1ba'
+IP6_GW = '2001:db8:1::ffff'
+IP6_PREFIX = '48'
DS_PATH = "cloudinit.sources.DataSourceOpenNebula"
@@ -221,7 +225,9 @@ class TestOpenNebulaDataSource(CiTestCase):
results = ds.read_context_disk_dir(self.seed_dir)
self.assertTrue('network-interfaces' in results)
- self.assertTrue(IP_BY_MACADDR in results['network-interfaces'])
+ self.assertTrue(
+ IP_BY_MACADDR + '/' + IP4_PREFIX in
+ results['network-interfaces']['ethernets'][dev]['addresses'])
# ETH0_IP and ETH0_MAC
populate_context_dir(
@@ -229,7 +235,9 @@ class TestOpenNebulaDataSource(CiTestCase):
results = ds.read_context_disk_dir(self.seed_dir)
self.assertTrue('network-interfaces' in results)
- self.assertTrue(IP_BY_MACADDR in results['network-interfaces'])
+ self.assertTrue(
+ IP_BY_MACADDR + '/' + IP4_PREFIX in
+ results['network-interfaces']['ethernets'][dev]['addresses'])
# ETH0_IP with empty string and ETH0_MAC
# in the case of using Virtual Network contains
@@ -239,55 +247,91 @@ class TestOpenNebulaDataSource(CiTestCase):
results = ds.read_context_disk_dir(self.seed_dir)
self.assertTrue('network-interfaces' in results)
- self.assertTrue(IP_BY_MACADDR in results['network-interfaces'])
+ self.assertTrue(
+ IP_BY_MACADDR + '/' + IP4_PREFIX in
+ results['network-interfaces']['ethernets'][dev]['addresses'])
- # ETH0_NETWORK
+ # ETH0_MASK
populate_context_dir(
self.seed_dir, {
'ETH0_IP': IP_BY_MACADDR,
'ETH0_MAC': MACADDR,
- 'ETH0_NETWORK': '10.18.0.0'
+ 'ETH0_MASK': '255.255.0.0'
})
results = ds.read_context_disk_dir(self.seed_dir)
self.assertTrue('network-interfaces' in results)
- self.assertTrue('10.18.0.0' in results['network-interfaces'])
+ self.assertTrue(
+ IP_BY_MACADDR + '/16' in
+ results['network-interfaces']['ethernets'][dev]['addresses'])
- # ETH0_NETWORK with empty string
+ # ETH0_MASK with empty string
populate_context_dir(
self.seed_dir, {
'ETH0_IP': IP_BY_MACADDR,
'ETH0_MAC': MACADDR,
- 'ETH0_NETWORK': ''
+ 'ETH0_MASK': ''
})
results = ds.read_context_disk_dir(self.seed_dir)
self.assertTrue('network-interfaces' in results)
- self.assertTrue('10.18.1.0' in results['network-interfaces'])
+ self.assertTrue(
+ IP_BY_MACADDR + '/' + IP4_PREFIX in
+ results['network-interfaces']['ethernets'][dev]['addresses'])
- # ETH0_MASK
+ # ETH0_IP6
populate_context_dir(
self.seed_dir, {
- 'ETH0_IP': IP_BY_MACADDR,
+ 'ETH0_IP6': IP6_GLOBAL,
'ETH0_MAC': MACADDR,
- 'ETH0_MASK': '255.255.0.0'
})
results = ds.read_context_disk_dir(self.seed_dir)
self.assertTrue('network-interfaces' in results)
- self.assertTrue('255.255.0.0' in results['network-interfaces'])
+ self.assertTrue(
+ IP6_GLOBAL + '/64' in
+ results['network-interfaces']['ethernets'][dev]['addresses'])
- # ETH0_MASK with empty string
+ # ETH0_IP6_ULA
populate_context_dir(
self.seed_dir, {
- 'ETH0_IP': IP_BY_MACADDR,
+ 'ETH0_IP6_ULA': IP6_ULA,
+ 'ETH0_MAC': MACADDR,
+ })
+ results = ds.read_context_disk_dir(self.seed_dir)
+
+ self.assertTrue('network-interfaces' in results)
+ self.assertTrue(
+ IP6_ULA + '/64' in
+ results['network-interfaces']['ethernets'][dev]['addresses'])
+
+ # ETH0_IP6 and ETH0_IP6_PREFIX_LENGTH
+ populate_context_dir(
+ self.seed_dir, {
+ 'ETH0_IP6': IP6_GLOBAL,
+ 'ETH0_IP6_PREFIX_LENGTH': IP6_PREFIX,
+ 'ETH0_MAC': MACADDR,
+ })
+ results = ds.read_context_disk_dir(self.seed_dir)
+
+ self.assertTrue('network-interfaces' in results)
+ self.assertTrue(
+ IP6_GLOBAL + '/' + IP6_PREFIX in
+ results['network-interfaces']['ethernets'][dev]['addresses'])
+
+ # ETH0_IP6 and ETH0_IP6_PREFIX_LENGTH with empty string
+ populate_context_dir(
+ self.seed_dir, {
+ 'ETH0_IP6': IP6_GLOBAL,
+ 'ETH0_IP6_PREFIX_LENGTH': '',
'ETH0_MAC': MACADDR,
- 'ETH0_MASK': ''
})
results = ds.read_context_disk_dir(self.seed_dir)
self.assertTrue('network-interfaces' in results)
- self.assertTrue('255.255.255.0' in results['network-interfaces'])
+ self.assertTrue(
+ IP6_GLOBAL + '/64' in
+ results['network-interfaces']['ethernets'][dev]['addresses'])
def test_find_candidates(self):
def my_devs_with(criteria):
@@ -310,108 +354,152 @@ class TestOpenNebulaNetwork(unittest.TestCase):
system_nics = ('eth0', 'ens3')
- def test_lo(self):
- net = ds.OpenNebulaNetwork(context={}, system_nics_by_mac={})
- self.assertEqual(net.gen_conf(), u'''\
-auto lo
-iface lo inet loopback
-''')
-
@mock.patch(DS_PATH + ".get_physical_nics_by_mac")
def test_eth0(self, m_get_phys_by_mac):
for nic in self.system_nics:
m_get_phys_by_mac.return_value = {MACADDR: nic}
net = ds.OpenNebulaNetwork({})
- self.assertEqual(net.gen_conf(), dedent("""\
- auto lo
- iface lo inet loopback
-
- auto {dev}
- iface {dev} inet static
- #hwaddress {macaddr}
- address 10.18.1.1
- network 10.18.1.0
- netmask 255.255.255.0
- """.format(dev=nic, macaddr=MACADDR)))
+ expected = {
+ 'version': 2,
+ 'ethernets': {
+ nic: {
+ 'match': {'macaddress': MACADDR},
+ 'addresses': [IP_BY_MACADDR + '/' + IP4_PREFIX]}}}
+
+ self.assertEqual(net.gen_conf(), expected)
def test_eth0_override(self):
+ self.maxDiff = None
context = {
'DNS': '1.2.3.8',
- 'ETH0_IP': '10.18.1.1',
- 'ETH0_NETWORK': '10.18.0.0',
- 'ETH0_MASK': '255.255.0.0',
+ 'ETH0_DNS': '1.2.3.6 1.2.3.7',
'ETH0_GATEWAY': '1.2.3.5',
- 'ETH0_DOMAIN': 'example.com',
+ 'ETH0_GATEWAY6': '',
+ 'ETH0_IP': IP_BY_MACADDR,
+ 'ETH0_IP6': '',
+ 'ETH0_IP6_PREFIX_LENGTH': '',
+ 'ETH0_IP6_ULA': '',
+ 'ETH0_MAC': '02:00:0a:12:01:01',
+ 'ETH0_MASK': '255.255.0.0',
+ 'ETH0_MTU': '',
+ 'ETH0_NETWORK': '10.18.0.0',
+ 'ETH0_SEARCH_DOMAIN': '',
+ }
+ for nic in self.system_nics:
+ net = ds.OpenNebulaNetwork(context,
+ system_nics_by_mac={MACADDR: nic})
+ expected = {
+ 'version': 2,
+ 'ethernets': {
+ nic: {
+ 'match': {'macaddress': MACADDR},
+ 'addresses': [IP_BY_MACADDR + '/16'],
+ 'gateway4': '1.2.3.5',
+ 'gateway6': None,
+ 'nameservers': {
+ 'addresses': ['1.2.3.6', '1.2.3.7', '1.2.3.8']}}}}
+
+ self.assertEqual(expected, net.gen_conf())
+
+ def test_eth0_v4v6_override(self):
+ self.maxDiff = None
+ context = {
+ 'DNS': '1.2.3.8',
'ETH0_DNS': '1.2.3.6 1.2.3.7',
- 'ETH0_MAC': '02:00:0a:12:01:01'
+ 'ETH0_GATEWAY': '1.2.3.5',
+ 'ETH0_GATEWAY6': IP6_GW,
+ 'ETH0_IP': IP_BY_MACADDR,
+ 'ETH0_IP6': IP6_GLOBAL,
+ 'ETH0_IP6_PREFIX_LENGTH': IP6_PREFIX,
+ 'ETH0_IP6_ULA': IP6_ULA,
+ 'ETH0_MAC': '02:00:0a:12:01:01',
+ 'ETH0_MASK': '255.255.0.0',
+ 'ETH0_MTU': '1280',
+ 'ETH0_NETWORK': '10.18.0.0',
+ 'ETH0_SEARCH_DOMAIN': 'example.com example.org',
}
for nic in self.system_nics:
- expected = dedent("""\
- auto lo
- iface lo inet loopback
-
- auto {dev}
- iface {dev} inet static
- #hwaddress {macaddr}
- address 10.18.1.1
- network 10.18.0.0
- netmask 255.255.0.0
- gateway 1.2.3.5
- dns-search example.com
- dns-nameservers 1.2.3.8 1.2.3.6 1.2.3.7
- """).format(dev=nic, macaddr=MACADDR)
net = ds.OpenNebulaNetwork(context,
system_nics_by_mac={MACADDR: nic})
+
+ expected = {
+ 'version': 2,
+ 'ethernets': {
+ nic: {
+ 'match': {'macaddress': MACADDR},
+ 'addresses': [
+ IP_BY_MACADDR + '/16',
+ IP6_GLOBAL + '/' + IP6_PREFIX,
+ IP6_ULA + '/' + IP6_PREFIX],
+ 'gateway4': '1.2.3.5',
+ 'gateway6': IP6_GW,
+ 'nameservers': {
+ 'addresses': ['1.2.3.6', '1.2.3.7', '1.2.3.8'],
+ 'search': ['example.com', 'example.org']},
+ 'mtu': '1280'}}}
+
self.assertEqual(expected, net.gen_conf())
def test_multiple_nics(self):
"""Test rendering multiple nics with names that differ from context."""
+ self.maxDiff = None
MAC_1 = "02:00:0a:12:01:01"
MAC_2 = "02:00:0a:12:01:02"
context = {
'DNS': '1.2.3.8',
- 'ETH0_IP': '10.18.1.1',
- 'ETH0_NETWORK': '10.18.0.0',
- 'ETH0_MASK': '255.255.0.0',
- 'ETH0_GATEWAY': '1.2.3.5',
- 'ETH0_DOMAIN': 'example.com',
'ETH0_DNS': '1.2.3.6 1.2.3.7',
+ 'ETH0_GATEWAY': '1.2.3.5',
+ 'ETH0_GATEWAY6': IP6_GW,
+ 'ETH0_IP': '10.18.1.1',
+ 'ETH0_IP6': IP6_GLOBAL,
+ 'ETH0_IP6_PREFIX_LENGTH': '',
+ 'ETH0_IP6_ULA': IP6_ULA,
'ETH0_MAC': MAC_2,
- 'ETH3_IP': '10.3.1.3',
- 'ETH3_NETWORK': '10.3.0.0',
- 'ETH3_MASK': '255.255.0.0',
- 'ETH3_GATEWAY': '10.3.0.1',
- 'ETH3_DOMAIN': 'third.example.com',
+ 'ETH0_MASK': '255.255.0.0',
+ 'ETH0_MTU': '1280',
+ 'ETH0_NETWORK': '10.18.0.0',
+ 'ETH0_SEARCH_DOMAIN': 'example.com',
'ETH3_DNS': '10.3.1.2',
+ 'ETH3_GATEWAY': '10.3.0.1',
+ 'ETH3_GATEWAY6': '',
+ 'ETH3_IP': '10.3.1.3',
+ 'ETH3_IP6': '',
+ 'ETH3_IP6_PREFIX_LENGTH': '',
+ 'ETH3_IP6_ULA': '',
'ETH3_MAC': MAC_1,
+ 'ETH3_MASK': '255.255.0.0',
+ 'ETH3_MTU': '',
+ 'ETH3_NETWORK': '10.3.0.0',
+ 'ETH3_SEARCH_DOMAIN': 'third.example.com third.example.org',
}
net = ds.OpenNebulaNetwork(
context, system_nics_by_mac={MAC_1: 'enp0s25', MAC_2: 'enp1s2'})
- expected = dedent("""\
- auto lo
- iface lo inet loopback
-
- auto enp0s25
- iface enp0s25 inet static
- #hwaddress 02:00:0a:12:01:01
- address 10.3.1.3
- network 10.3.0.0
- netmask 255.255.0.0
- gateway 10.3.0.1
- dns-search third.example.com
- dns-nameservers 1.2.3.8 10.3.1.2
-
- auto enp1s2
- iface enp1s2 inet static
- #hwaddress 02:00:0a:12:01:02
- address 10.18.1.1
- network 10.18.0.0
- netmask 255.255.0.0
- gateway 1.2.3.5
- dns-search example.com
- dns-nameservers 1.2.3.8 1.2.3.6 1.2.3.7
- """)
+ expected = {
+ 'version': 2,
+ 'ethernets': {
+ 'enp1s2': {
+ 'match': {'macaddress': MAC_2},
+ 'addresses': [
+ '10.18.1.1/16',
+ IP6_GLOBAL + '/64',
+ IP6_ULA + '/64'],
+ 'gateway4': '1.2.3.5',
+ 'gateway6': IP6_GW,
+ 'nameservers': {
+ 'addresses': ['1.2.3.6', '1.2.3.7', '1.2.3.8'],
+ 'search': ['example.com']},
+ 'mtu': '1280'},
+ 'enp0s25': {
+ 'match': {'macaddress': MAC_1},
+ 'addresses': ['10.3.1.3/16'],
+ 'gateway4': '10.3.0.1',
+ 'gateway6': None,
+ 'nameservers': {
+ 'addresses': ['10.3.1.2', '1.2.3.8'],
+ 'search': [
+ 'third.example.com',
+ 'third.example.org']}}}}
self.assertEqual(expected, net.gen_conf())
diff --git a/tests/unittests/test_ds_identify.py b/tests/unittests/test_ds_identify.py
index 31cc6223..53643989 100644
--- a/tests/unittests/test_ds_identify.py
+++ b/tests/unittests/test_ds_identify.py
@@ -9,6 +9,8 @@ from cloudinit import util
from cloudinit.tests.helpers import (
CiTestCase, dir2dict, populate_dir)
+from cloudinit.sources import DataSourceIBMCloud as dsibm
+
UNAME_MYSYS = ("Linux bart 4.4.0-62-generic #83-Ubuntu "
"SMP Wed Jan 18 14:10:15 UTC 2017 x86_64 GNU/Linux")
UNAME_PPC64EL = ("Linux diamond 4.4.0-83-generic #106-Ubuntu SMP "
@@ -37,8 +39,8 @@ BLKID_UEFI_UBUNTU = [
POLICY_FOUND_ONLY = "search,found=all,maybe=none,notfound=disabled"
POLICY_FOUND_OR_MAYBE = "search,found=all,maybe=all,notfound=disabled"
-DI_DEFAULT_POLICY = "search,found=all,maybe=all,notfound=enabled"
-DI_DEFAULT_POLICY_NO_DMI = "search,found=all,maybe=all,notfound=disabled"
+DI_DEFAULT_POLICY = "search,found=all,maybe=all,notfound=disabled"
+DI_DEFAULT_POLICY_NO_DMI = "search,found=all,maybe=all,notfound=enabled"
DI_EC2_STRICT_ID_DEFAULT = "true"
OVF_MATCH_STRING = 'http://schemas.dmtf.org/ovf/environment/1'
@@ -60,11 +62,16 @@ P_CHASSIS_ASSET_TAG = "sys/class/dmi/id/chassis_asset_tag"
P_PRODUCT_NAME = "sys/class/dmi/id/product_name"
P_PRODUCT_SERIAL = "sys/class/dmi/id/product_serial"
P_PRODUCT_UUID = "sys/class/dmi/id/product_uuid"
+P_SYS_VENDOR = "sys/class/dmi/id/sys_vendor"
P_SEED_DIR = "var/lib/cloud/seed"
P_DSID_CFG = "etc/cloud/ds-identify.cfg"
+IBM_PROVISIONING_CHECK_PATH = "/root/provisioningConfiguration.cfg"
+IBM_CONFIG_UUID = "9796-932E"
+
MOCK_VIRT_IS_KVM = {'name': 'detect_virt', 'RET': 'kvm', 'ret': 0}
MOCK_VIRT_IS_VMWARE = {'name': 'detect_virt', 'RET': 'vmware', 'ret': 0}
+MOCK_VIRT_IS_XEN = {'name': 'detect_virt', 'RET': 'xen', 'ret': 0}
MOCK_UNAME_IS_PPC64 = {'name': 'uname', 'out': UNAME_PPC64EL, 'ret': 0}
@@ -237,6 +244,57 @@ class TestDsIdentify(CiTestCase):
self._test_ds_found('ConfigDriveUpper')
return
+ def test_ibmcloud_template_userdata_in_provisioning(self):
+ """Template provisioned with user-data during provisioning stage.
+
+ Template provisioning with user-data has METADATA disk,
+ datasource should return not found."""
+ data = copy.deepcopy(VALID_CFG['IBMCloud-metadata'])
+ data['files'] = {IBM_PROVISIONING_CHECK_PATH: 'xxx'}
+ return self._check_via_dict(data, RC_NOT_FOUND)
+
+ def test_ibmcloud_template_userdata(self):
+ """Template provisioned with user-data first boot.
+
+ Template provisioning with user-data has METADATA disk.
+ datasource should return found."""
+ self._test_ds_found('IBMCloud-metadata')
+
+ def test_ibmcloud_template_no_userdata_in_provisioning(self):
+ """Template provisioned with no user-data during provisioning.
+
+ no disks attached. Datasource should return not found."""
+ data = copy.deepcopy(VALID_CFG['IBMCloud-nodisks'])
+ data['files'] = {IBM_PROVISIONING_CHECK_PATH: 'xxx'}
+ return self._check_via_dict(data, RC_NOT_FOUND)
+
+ def test_ibmcloud_template_no_userdata(self):
+ """Template provisioned with no user-data first boot.
+
+ no disks attached. Datasource should return found."""
+ self._check_via_dict(VALID_CFG['IBMCloud-nodisks'], RC_NOT_FOUND)
+
+ def test_ibmcloud_os_code(self):
+ """Launched by os code always has config-2 disk."""
+ self._test_ds_found('IBMCloud-config-2')
+
+ def test_ibmcloud_os_code_different_uuid(self):
+ """IBM cloud config-2 disks must be explicit match on UUID.
+
+ If the UUID is not 9796-932E then we actually expect ConfigDrive."""
+ data = copy.deepcopy(VALID_CFG['IBMCloud-config-2'])
+ offset = None
+ for m, d in enumerate(data['mocks']):
+ if d.get('name') == "blkid":
+ offset = m
+ break
+ if not offset:
+ raise ValueError("Expected to find 'blkid' mock, but did not.")
+ data['mocks'][offset]['out'] = d['out'].replace(dsibm.IBM_CONFIG_UUID,
+ "DEAD-BEEF")
+ self._check_via_dict(
+ data, rc=RC_FOUND, dslist=['ConfigDrive', DS_NONE])
+
def test_policy_disabled(self):
"""A Builtin policy of 'disabled' should return not found.
@@ -290,6 +348,10 @@ class TestDsIdentify(CiTestCase):
"""On Intel, openstack must be identified."""
self._test_ds_found('OpenStack')
+ def test_openstack_open_telekom_cloud(self):
+ """Open Telecom identification."""
+ self._test_ds_found('OpenStack-OpenTelekom')
+
def test_openstack_on_non_intel_is_maybe(self):
"""On non-Intel, openstack without dmi info is maybe.
@@ -337,6 +399,16 @@ class TestDsIdentify(CiTestCase):
"""OVF is identified when vmware customization is enabled."""
self._test_ds_found('OVF-vmware-customization')
+ def test_ovf_on_vmware_iso_found_open_vm_tools_64(self):
+ """OVF is identified when open-vm-tools installed in /usr/lib64."""
+ cust64 = copy.deepcopy(VALID_CFG['OVF-vmware-customization'])
+ p32 = 'usr/lib/vmware-tools/plugins/vmsvc/libdeployPkgPlugin.so'
+ open64 = 'usr/lib64/open-vm-tools/plugins/vmsvc/libdeployPkgPlugin.so'
+ cust64['files'][open64] = cust64['files'][p32]
+ del cust64['files'][p32]
+ return self._check_via_dict(
+ cust64, RC_FOUND, dslist=[cust64.get('ds'), DS_NONE])
+
def test_ovf_on_vmware_iso_found_by_cdrom_with_matching_fs_label(self):
"""OVF is identified by well-known iso9660 labels."""
ovf_cdrom_by_label = copy.deepcopy(VALID_CFG['OVF'])
@@ -350,8 +422,10 @@ class TestDsIdentify(CiTestCase):
"OVFENV", "ovfenv"]
for valid_ovf_label in valid_ovf_labels:
ovf_cdrom_by_label['mocks'][0]['out'] = blkid_out([
+ {'DEVNAME': 'sda1', 'TYPE': 'ext4', 'LABEL': 'rootfs'},
{'DEVNAME': 'sr0', 'TYPE': 'iso9660',
- 'LABEL': valid_ovf_label}])
+ 'LABEL': valid_ovf_label},
+ {'DEVNAME': 'vda1', 'TYPE': 'ntfs', 'LABEL': 'data'}])
self._check_via_dict(
ovf_cdrom_by_label, rc=RC_FOUND, dslist=['OVF', DS_NONE])
@@ -359,6 +433,18 @@ class TestDsIdentify(CiTestCase):
"""NoCloud is found with iso9660 filesystem on non-cdrom disk."""
self._test_ds_found('NoCloud')
+ def test_nocloud_seed(self):
+ """Nocloud seed directory."""
+ self._test_ds_found('NoCloud-seed')
+
+ def test_nocloud_seed_ubuntu_core_writable(self):
+ """Nocloud seed directory ubuntu core writable"""
+ self._test_ds_found('NoCloud-seed-ubuntu-core')
+
+ def test_hetzner_found(self):
+ """Hetzner cloud is identified in sys_vendor."""
+ self._test_ds_found('Hetzner')
+
def blkid_out(disks=None):
"""Convert a list of disk dictionaries into blkid content."""
@@ -422,7 +508,7 @@ VALID_CFG = {
},
'Ec2-xen': {
'ds': 'Ec2',
- 'mocks': [{'name': 'detect_virt', 'RET': 'xen', 'ret': 0}],
+ 'mocks': [MOCK_VIRT_IS_XEN],
'files': {
'sys/hypervisor/uuid': 'ec2c6e2f-5fac-4fc7-9c82-74127ec14bbb\n'
},
@@ -454,6 +540,22 @@ VALID_CFG = {
'dev/vdb': 'pretend iso content for cidata\n',
}
},
+ 'NoCloud-seed': {
+ 'ds': 'NoCloud',
+ 'files': {
+ os.path.join(P_SEED_DIR, 'nocloud', 'user-data'): 'ud\n',
+ os.path.join(P_SEED_DIR, 'nocloud', 'meta-data'): 'md\n',
+ }
+ },
+ 'NoCloud-seed-ubuntu-core': {
+ 'ds': 'NoCloud',
+ 'files': {
+ os.path.join('writable/system-data', P_SEED_DIR,
+ 'nocloud-net', 'user-data'): 'ud\n',
+ os.path.join('writable/system-data', P_SEED_DIR,
+ 'nocloud-net', 'meta-data'): 'md\n',
+ }
+ },
'OpenStack': {
'ds': 'OpenStack',
'files': {P_PRODUCT_NAME: 'OpenStack Nova\n'},
@@ -461,6 +563,12 @@ VALID_CFG = {
'policy_dmi': POLICY_FOUND_ONLY,
'policy_no_dmi': POLICY_FOUND_ONLY,
},
+ 'OpenStack-OpenTelekom': {
+ # OTC gen1 (Xen) hosts use OpenStack datasource, LP: #1756471
+ 'ds': 'OpenStack',
+ 'files': {P_CHASSIS_ASSET_TAG: 'OpenTelekomCloud\n'},
+ 'mocks': [MOCK_VIRT_IS_XEN],
+ },
'OVF-seed': {
'ds': 'OVF',
'files': {
@@ -489,8 +597,9 @@ VALID_CFG = {
'mocks': [
{'name': 'blkid', 'ret': 0,
'out': blkid_out(
- [{'DEVNAME': 'vda1', 'TYPE': 'vfat', 'PARTUUID': uuid4()},
- {'DEVNAME': 'sr0', 'TYPE': 'iso9660', 'LABEL': ''}])
+ [{'DEVNAME': 'sr0', 'TYPE': 'iso9660', 'LABEL': ''},
+ {'DEVNAME': 'sr1', 'TYPE': 'iso9660', 'LABEL': 'ignoreme'},
+ {'DEVNAME': 'vda1', 'TYPE': 'vfat', 'PARTUUID': uuid4()}]),
},
MOCK_VIRT_IS_VMWARE,
],
@@ -522,6 +631,52 @@ VALID_CFG = {
},
],
},
+ 'Hetzner': {
+ 'ds': 'Hetzner',
+ 'files': {P_SYS_VENDOR: 'Hetzner\n'},
+ },
+ 'IBMCloud-metadata': {
+ 'ds': 'IBMCloud',
+ 'mocks': [
+ MOCK_VIRT_IS_XEN,
+ {'name': 'blkid', 'ret': 0,
+ 'out': blkid_out(
+ [{'DEVNAME': 'xvda1', 'TYPE': 'vfat', 'PARTUUID': uuid4()},
+ {'DEVNAME': 'xvda2', 'TYPE': 'ext4',
+ 'LABEL': 'cloudimg-rootfs', 'PARTUUID': uuid4()},
+ {'DEVNAME': 'xvdb', 'TYPE': 'vfat', 'LABEL': 'METADATA'}]),
+ },
+ ],
+ },
+ 'IBMCloud-config-2': {
+ 'ds': 'IBMCloud',
+ 'mocks': [
+ MOCK_VIRT_IS_XEN,
+ {'name': 'blkid', 'ret': 0,
+ 'out': blkid_out(
+ [{'DEVNAME': 'xvda1', 'TYPE': 'ext3', 'PARTUUID': uuid4(),
+ 'UUID': uuid4(), 'LABEL': 'cloudimg-bootfs'},
+ {'DEVNAME': 'xvdb', 'TYPE': 'vfat', 'LABEL': 'config-2',
+ 'UUID': dsibm.IBM_CONFIG_UUID},
+ {'DEVNAME': 'xvda2', 'TYPE': 'ext4',
+ 'LABEL': 'cloudimg-rootfs', 'PARTUUID': uuid4(),
+ 'UUID': uuid4()},
+ ]),
+ },
+ ],
+ },
+ 'IBMCloud-nodisks': {
+ 'ds': 'IBMCloud',
+ 'mocks': [
+ MOCK_VIRT_IS_XEN,
+ {'name': 'blkid', 'ret': 0,
+ 'out': blkid_out(
+ [{'DEVNAME': 'xvda1', 'TYPE': 'vfat', 'PARTUUID': uuid4()},
+ {'DEVNAME': 'xvda2', 'TYPE': 'ext4',
+ 'LABEL': 'cloudimg-rootfs', 'PARTUUID': uuid4()}]),
+ },
+ ],
+ },
}
# vi: ts=4 expandtab
diff --git a/tests/unittests/test_handler/test_handler_apt_source_v1.py b/tests/unittests/test_handler/test_handler_apt_source_v1.py
index 3a3f95ca..46ca4ce4 100644
--- a/tests/unittests/test_handler/test_handler_apt_source_v1.py
+++ b/tests/unittests/test_handler/test_handler_apt_source_v1.py
@@ -569,7 +569,8 @@ class TestAptSourceConfig(TestCase):
newcfg = cc_apt_configure.convert_to_v3_apt_format(cfg_3_only)
self.assertEqual(newcfg, cfg_3_only)
# collision (unequal)
- with self.assertRaises(ValueError):
+ match = "Old and New.*unequal.*apt_proxy"
+ with self.assertRaisesRegex(ValueError, match):
cc_apt_configure.convert_to_v3_apt_format(cfgconflict)
def test_convert_to_new_format_dict_collision(self):
diff --git a/tests/unittests/test_handler/test_handler_bootcmd.py b/tests/unittests/test_handler/test_handler_bootcmd.py
index dbf43e0d..29fc25e4 100644
--- a/tests/unittests/test_handler/test_handler_bootcmd.py
+++ b/tests/unittests/test_handler/test_handler_bootcmd.py
@@ -3,17 +3,11 @@
from cloudinit.config import cc_bootcmd
from cloudinit.sources import DataSourceNone
from cloudinit import (distros, helpers, cloud, util)
-from cloudinit.tests.helpers import CiTestCase, mock, skipIf
+from cloudinit.tests.helpers import CiTestCase, mock, skipUnlessJsonSchema
import logging
import tempfile
-try:
- import jsonschema
- assert jsonschema # avoid pyflakes error F401: import unused
- _missing_jsonschema_dep = False
-except ImportError:
- _missing_jsonschema_dep = True
LOG = logging.getLogger(__name__)
@@ -69,10 +63,10 @@ class TestBootcmd(CiTestCase):
cc_bootcmd.handle('cc_bootcmd', invalid_config, cc, LOG, [])
self.assertIn('Failed to shellify bootcmd', self.logs.getvalue())
self.assertEqual(
- "'int' object is not iterable",
+ "Input to shellify was type 'int'. Expected list or tuple.",
str(context_manager.exception))
- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
+ @skipUnlessJsonSchema()
def test_handler_schema_validation_warns_non_array_type(self):
"""Schema validation warns of non-array type for bootcmd key.
@@ -88,7 +82,7 @@ class TestBootcmd(CiTestCase):
self.logs.getvalue())
self.assertIn('Failed to shellify', self.logs.getvalue())
- @skipIf(_missing_jsonschema_dep, 'No python-jsonschema dependency')
+ @skipUnlessJsonSchema()
def test_handler_schema_validation_warns_non_array_item_type(self):
"""Schema validation warns of non-array or string bootcmd items.
@@ -98,7 +92,7 @@ class TestBootcmd(CiTestCase):
invalid_config = {
'bootcmd': ['ls /', 20, ['wget', 'http://stuff/blah'], {'a': 'n'}]}
cc = self._get_cloud('ubuntu')
- with self.assertRaises(RuntimeError) as context_manager:
+ with self.assertRaises(TypeError) as context_manager:
cc_bootcmd.handle('cc_bootcmd', invalid_config, cc, LOG, [])
expected_warnings = [
'bootcmd.1: 20 is not valid under any of the given schemas',
@@ -110,7 +104,8 @@ class TestBootcmd(CiTestCase):
self.assertIn(warning, logs)
self.assertIn('Failed to shellify', logs)
self.assertEqual(
- 'Unable to shellify type int which is not a list or string',
+ ("Unable to shellify type 'int'. Expected list, string, tuple. "
+ "Got: 20"),
str(context_manager.exception))
def test_handler_creates_and_runs_bootcmd_script_with_instance_id(self):
diff --git a/tests/unittests/test_handler/test_handler_ntp.py b/tests/unittests/test_handler/test_handler_ntp.py
index 28a8455d..695897c0 100644
--- a/tests/unittests/test_handler/test_handler_ntp.py
+++ b/tests/unittests/test_handler/test_handler_ntp.py
@@ -3,7 +3,8 @@
from cloudinit.config import cc_ntp
from cloudinit.sources import DataSourceNone
from cloudinit import (distros, helpers, cloud, util)
-from cloudinit.tests.helpers import FilesystemMockingTestCase, mock, skipIf
+from cloudinit.tests.helpers import (
+ FilesystemMockingTestCase, mock, skipUnlessJsonSchema)
import os
@@ -24,13 +25,6 @@ NTP={% for host in servers|list + pools|list %}{{ host }} {% endfor -%}
{% endif -%}
"""
-try:
- import jsonschema
- assert jsonschema # avoid pyflakes error F401: import unused
- _missing_jsonschema_dep = False
-except ImportError:
- _missing_jsonschema_dep = True
-
class TestNtp(FilesystemMockingTestCase):
@@ -312,7 +306,7 @@ class TestNtp(FilesystemMockingTestCase):
content)
self.assertNotIn('Invalid config:', self.logs.getvalue())
- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
+ @skipUnlessJsonSchema()
def test_ntp_handler_schema_validation_warns_non_string_item_type(self):
"""Ntp schema validation warns of non-strings in pools or servers.
@@ -333,7 +327,7 @@ class TestNtp(FilesystemMockingTestCase):
content = stream.read()
self.assertEqual("servers ['valid', None]\npools [123]\n", content)
- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
+ @skipUnlessJsonSchema()
def test_ntp_handler_schema_validation_warns_of_non_array_type(self):
"""Ntp schema validation warns of non-array pools or servers types.
@@ -354,7 +348,7 @@ class TestNtp(FilesystemMockingTestCase):
content = stream.read()
self.assertEqual("servers non-array\npools 123\n", content)
- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
+ @skipUnlessJsonSchema()
def test_ntp_handler_schema_validation_warns_invalid_key_present(self):
"""Ntp schema validation warns of invalid keys present in ntp config.
@@ -378,7 +372,7 @@ class TestNtp(FilesystemMockingTestCase):
"servers []\npools ['0.mycompany.pool.ntp.org']\n",
content)
- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
+ @skipUnlessJsonSchema()
def test_ntp_handler_schema_validation_warns_of_duplicates(self):
"""Ntp schema validation warns of duplicates in servers or pools.
diff --git a/tests/unittests/test_handler/test_handler_resizefs.py b/tests/unittests/test_handler/test_handler_resizefs.py
index 5aa3c498..7a7ba1ff 100644
--- a/tests/unittests/test_handler/test_handler_resizefs.py
+++ b/tests/unittests/test_handler/test_handler_resizefs.py
@@ -1,27 +1,20 @@
# This file is part of cloud-init. See LICENSE file for license information.
from cloudinit.config.cc_resizefs import (
- can_skip_resize, handle, maybe_get_writable_device_path, _resize_btrfs)
+ can_skip_resize, handle, maybe_get_writable_device_path, _resize_btrfs,
+ _resize_zfs, _resize_xfs, _resize_ext, _resize_ufs)
from collections import namedtuple
import logging
import textwrap
-from cloudinit.tests.helpers import (CiTestCase, mock, skipIf, util,
- wrap_and_call)
+from cloudinit.tests.helpers import (
+ CiTestCase, mock, skipUnlessJsonSchema, util, wrap_and_call)
LOG = logging.getLogger(__name__)
-try:
- import jsonschema
- assert jsonschema # avoid pyflakes error F401: import unused
- _missing_jsonschema_dep = False
-except ImportError:
- _missing_jsonschema_dep = True
-
-
class TestResizefs(CiTestCase):
with_logs = True
@@ -68,6 +61,9 @@ class TestResizefs(CiTestCase):
res = can_skip_resize(fs_type, resize_what, devpth)
self.assertTrue(res)
+ def test_can_skip_resize_ext(self):
+ self.assertFalse(can_skip_resize('ext', '/', '/dev/sda1'))
+
def test_handle_noops_on_disabled(self):
"""The handle function logs when the configuration disables resize."""
cfg = {'resize_rootfs': False}
@@ -76,7 +72,7 @@ class TestResizefs(CiTestCase):
'DEBUG: Skipping module named cc_resizefs, resizing disabled\n',
self.logs.getvalue())
- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
+ @skipUnlessJsonSchema()
def test_handle_schema_validation_logs_invalid_resize_rootfs_value(self):
"""The handle reports json schema violations as a warning.
@@ -130,6 +126,51 @@ class TestResizefs(CiTestCase):
logs = self.logs.getvalue()
self.assertIn("WARNING: Unable to find device '/dev/root'", logs)
+ def test_resize_zfs_cmd_return(self):
+ zpool = 'zroot'
+ devpth = 'gpt/system'
+ self.assertEqual(('zpool', 'online', '-e', zpool, devpth),
+ _resize_zfs(zpool, devpth))
+
+ def test_resize_xfs_cmd_return(self):
+ mount_point = '/mnt/test'
+ devpth = '/dev/sda1'
+ self.assertEqual(('xfs_growfs', mount_point),
+ _resize_xfs(mount_point, devpth))
+
+ def test_resize_ext_cmd_return(self):
+ mount_point = '/'
+ devpth = '/dev/sdb1'
+ self.assertEqual(('resize2fs', devpth),
+ _resize_ext(mount_point, devpth))
+
+ def test_resize_ufs_cmd_return(self):
+ mount_point = '/'
+ devpth = '/dev/sda2'
+ self.assertEqual(('growfs', devpth),
+ _resize_ufs(mount_point, devpth))
+
+ @mock.patch('cloudinit.util.get_mount_info')
+ @mock.patch('cloudinit.util.get_device_info_from_zpool')
+ @mock.patch('cloudinit.util.parse_mount')
+ def test_handle_zfs_root(self, mount_info, zpool_info, parse_mount):
+ devpth = 'vmzroot/ROOT/freebsd'
+ disk = 'gpt/system'
+ fs_type = 'zfs'
+ mount_point = '/'
+
+ mount_info.return_value = (devpth, fs_type, mount_point)
+ zpool_info.return_value = disk
+ parse_mount.return_value = (devpth, fs_type, mount_point)
+
+ cfg = {'resize_rootfs': True}
+
+ with mock.patch('cloudinit.config.cc_resizefs.do_resize') as dresize:
+ handle('cc_resizefs', cfg, _cloud=None, log=LOG, args=[])
+ ret = dresize.call_args[0][0]
+
+ self.assertEqual(('zpool', 'online', '-e', 'vmzroot', disk), ret)
+
class TestRootDevFromCmdline(CiTestCase):
@@ -313,5 +354,12 @@ class TestMaybeGetDevicePathAsWritableBlock(CiTestCase):
('btrfs', 'filesystem', 'resize', 'max', '/'),
_resize_btrfs("/", "/dev/sda1"))
+ @mock.patch('cloudinit.util.is_FreeBSD')
+ def test_maybe_get_writable_device_path_zfs_freebsd(self, freebsd):
+ freebsd.return_value = True
+ info = 'dev=gpt/system mnt_point=/ path=/'
+ devpth = maybe_get_writable_device_path('gpt/system', info, LOG)
+ self.assertEqual('gpt/system', devpth)
+
# vi: ts=4 expandtab
diff --git a/tests/unittests/test_handler/test_handler_runcmd.py b/tests/unittests/test_handler/test_handler_runcmd.py
index 374c1d31..dbbb2717 100644
--- a/tests/unittests/test_handler/test_handler_runcmd.py
+++ b/tests/unittests/test_handler/test_handler_runcmd.py
@@ -3,19 +3,13 @@
from cloudinit.config import cc_runcmd
from cloudinit.sources import DataSourceNone
from cloudinit import (distros, helpers, cloud, util)
-from cloudinit.tests.helpers import FilesystemMockingTestCase, skipIf
+from cloudinit.tests.helpers import (
+ FilesystemMockingTestCase, skipUnlessJsonSchema)
import logging
import os
import stat
-try:
- import jsonschema
- assert jsonschema # avoid pyflakes error F401: import unused
- _missing_jsonschema_dep = False
-except ImportError:
- _missing_jsonschema_dep = True
-
LOG = logging.getLogger(__name__)
@@ -56,7 +50,7 @@ class TestRuncmd(FilesystemMockingTestCase):
' /var/lib/cloud/instances/iid-datasource-none/scripts/runcmd',
self.logs.getvalue())
- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
+ @skipUnlessJsonSchema()
def test_handler_schema_validation_warns_non_array_type(self):
"""Schema validation warns of non-array type for runcmd key.
@@ -71,7 +65,7 @@ class TestRuncmd(FilesystemMockingTestCase):
self.logs.getvalue())
self.assertIn('Failed to shellify', self.logs.getvalue())
- @skipIf(_missing_jsonschema_dep, 'No python-jsonschema dependency')
+ @skipUnlessJsonSchema()
def test_handler_schema_validation_warns_non_array_item_type(self):
"""Schema validation warns of non-array or string runcmd items.
diff --git a/tests/unittests/test_handler/test_handler_set_hostname.py b/tests/unittests/test_handler/test_handler_set_hostname.py
index abdc17e7..d09ec23a 100644
--- a/tests/unittests/test_handler/test_handler_set_hostname.py
+++ b/tests/unittests/test_handler/test_handler_set_hostname.py
@@ -11,6 +11,7 @@ from cloudinit.tests import helpers as t_help
from configobj import ConfigObj
import logging
+import os
import shutil
from six import BytesIO
import tempfile
@@ -19,14 +20,18 @@ LOG = logging.getLogger(__name__)
class TestHostname(t_help.FilesystemMockingTestCase):
+
+ with_logs = True
+
def setUp(self):
super(TestHostname, self).setUp()
self.tmp = tempfile.mkdtemp()
+ util.ensure_dir(os.path.join(self.tmp, 'data'))
self.addCleanup(shutil.rmtree, self.tmp)
def _fetch_distro(self, kind):
cls = distros.fetch(kind)
- paths = helpers.Paths({})
+ paths = helpers.Paths({'cloud_dir': self.tmp})
return cls(kind, {}, paths)
def test_write_hostname_rhel(self):
@@ -34,7 +39,7 @@ class TestHostname(t_help.FilesystemMockingTestCase):
'hostname': 'blah.blah.blah.yahoo.com',
}
distro = self._fetch_distro('rhel')
- paths = helpers.Paths({})
+ paths = helpers.Paths({'cloud_dir': self.tmp})
ds = None
cc = cloud.Cloud(ds, paths, {}, distro, None)
self.patchUtils(self.tmp)
@@ -51,7 +56,7 @@ class TestHostname(t_help.FilesystemMockingTestCase):
'hostname': 'blah.blah.blah.yahoo.com',
}
distro = self._fetch_distro('debian')
- paths = helpers.Paths({})
+ paths = helpers.Paths({'cloud_dir': self.tmp})
ds = None
cc = cloud.Cloud(ds, paths, {}, distro, None)
self.patchUtils(self.tmp)
@@ -65,7 +70,7 @@ class TestHostname(t_help.FilesystemMockingTestCase):
'hostname': 'blah.blah.blah.suse.com',
}
distro = self._fetch_distro('sles')
- paths = helpers.Paths({})
+ paths = helpers.Paths({'cloud_dir': self.tmp})
ds = None
cc = cloud.Cloud(ds, paths, {}, distro, None)
self.patchUtils(self.tmp)
@@ -74,4 +79,48 @@ class TestHostname(t_help.FilesystemMockingTestCase):
contents = util.load_file(distro.hostname_conf_fn)
self.assertEqual('blah', contents.strip())
+ def test_multiple_calls_skips_unchanged_hostname(self):
+ """Only new hostname or fqdn values will generate a hostname call."""
+ distro = self._fetch_distro('debian')
+ paths = helpers.Paths({'cloud_dir': self.tmp})
+ ds = None
+ cc = cloud.Cloud(ds, paths, {}, distro, None)
+ self.patchUtils(self.tmp)
+ cc_set_hostname.handle(
+ 'cc_set_hostname', {'hostname': 'hostname1.me.com'}, cc, LOG, [])
+ contents = util.load_file("/etc/hostname")
+ self.assertEqual('hostname1', contents.strip())
+ cc_set_hostname.handle(
+ 'cc_set_hostname', {'hostname': 'hostname1.me.com'}, cc, LOG, [])
+ self.assertIn(
+ 'DEBUG: No hostname changes. Skipping set-hostname\n',
+ self.logs.getvalue())
+ cc_set_hostname.handle(
+ 'cc_set_hostname', {'hostname': 'hostname2.me.com'}, cc, LOG, [])
+ contents = util.load_file("/etc/hostname")
+ self.assertEqual('hostname2', contents.strip())
+ self.assertIn(
+ 'Non-persistently setting the system hostname to hostname2',
+ self.logs.getvalue())
+
+ def test_error_on_distro_set_hostname_errors(self):
+ """Raise SetHostnameError on exceptions from distro.set_hostname."""
+ distro = self._fetch_distro('debian')
+
+ def set_hostname_error(hostname, fqdn):
+ raise Exception("OOPS on: %s" % fqdn)
+
+ distro.set_hostname = set_hostname_error
+ paths = helpers.Paths({'cloud_dir': self.tmp})
+ ds = None
+ cc = cloud.Cloud(ds, paths, {}, distro, None)
+ self.patchUtils(self.tmp)
+ with self.assertRaises(cc_set_hostname.SetHostnameError) as ctx_mgr:
+ cc_set_hostname.handle(
+ 'somename', {'hostname': 'hostname1.me.com'}, cc, LOG, [])
+ self.assertEqual(
+ 'Failed to set the hostname to hostname1.me.com (hostname1):'
+ ' OOPS on: hostname1.me.com',
+ str(ctx_mgr.exception))
+
# vi: ts=4 expandtab
diff --git a/tests/unittests/test_handler/test_schema.py b/tests/unittests/test_handler/test_schema.py
index 648573f6..ac41f124 100644
--- a/tests/unittests/test_handler/test_schema.py
+++ b/tests/unittests/test_handler/test_schema.py
@@ -6,7 +6,7 @@ from cloudinit.config.schema import (
validate_cloudconfig_schema, main)
from cloudinit.util import subp, write_file
-from cloudinit.tests.helpers import CiTestCase, mock, skipIf
+from cloudinit.tests.helpers import CiTestCase, mock, skipUnlessJsonSchema
from copy import copy
import os
@@ -14,13 +14,6 @@ from six import StringIO
from textwrap import dedent
from yaml import safe_load
-try:
- import jsonschema
- assert jsonschema # avoid pyflakes error F401: import unused
- _missing_jsonschema_dep = False
-except ImportError:
- _missing_jsonschema_dep = True
-
class GetSchemaTest(CiTestCase):
@@ -33,6 +26,8 @@ class GetSchemaTest(CiTestCase):
'cc_ntp',
'cc_resizefs',
'cc_runcmd',
+ 'cc_snap',
+ 'cc_ubuntu_advantage',
'cc_zypper_add_repo'
],
[subschema['id'] for subschema in schema['allOf']])
@@ -73,7 +68,7 @@ class ValidateCloudConfigSchemaTest(CiTestCase):
with_logs = True
- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
+ @skipUnlessJsonSchema()
def test_validateconfig_schema_non_strict_emits_warnings(self):
"""When strict is False validate_cloudconfig_schema emits warnings."""
schema = {'properties': {'p1': {'type': 'string'}}}
@@ -82,7 +77,7 @@ class ValidateCloudConfigSchemaTest(CiTestCase):
"Invalid config:\np1: -1 is not of type 'string'\n",
self.logs.getvalue())
- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
+ @skipUnlessJsonSchema()
def test_validateconfig_schema_emits_warning_on_missing_jsonschema(self):
"""Warning from validate_cloudconfig_schema when missing jsonschema."""
schema = {'properties': {'p1': {'type': 'string'}}}
@@ -92,7 +87,7 @@ class ValidateCloudConfigSchemaTest(CiTestCase):
'Ignoring schema validation. python-jsonschema is not present',
self.logs.getvalue())
- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
+ @skipUnlessJsonSchema()
def test_validateconfig_schema_strict_raises_errors(self):
"""When strict is True validate_cloudconfig_schema raises errors."""
schema = {'properties': {'p1': {'type': 'string'}}}
@@ -102,7 +97,7 @@ class ValidateCloudConfigSchemaTest(CiTestCase):
"Cloud config schema errors: p1: -1 is not of type 'string'",
str(context_mgr.exception))
- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
+ @skipUnlessJsonSchema()
def test_validateconfig_schema_honors_formats(self):
"""With strict True, validate_cloudconfig_schema errors on format."""
schema = {
@@ -153,7 +148,7 @@ class ValidateCloudConfigFileTest(CiTestCase):
self.config_file),
str(context_mgr.exception))
- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
+ @skipUnlessJsonSchema()
def test_validateconfig_file_sctricty_validates_schema(self):
"""validate_cloudconfig_file raises errors on invalid schema."""
schema = {
@@ -336,11 +331,13 @@ class MainTest(CiTestCase):
def test_main_missing_args(self):
"""Main exits non-zero and reports an error on missing parameters."""
- with mock.patch('sys.argv', ['mycmd']):
- with mock.patch('sys.stderr', new_callable=StringIO) as m_stderr:
- with self.assertRaises(SystemExit) as context_manager:
- main()
- self.assertEqual('1', str(context_manager.exception))
+ with mock.patch('sys.exit', side_effect=self.sys_exit):
+ with mock.patch('sys.argv', ['mycmd']):
+ with mock.patch('sys.stderr', new_callable=StringIO) as \
+ m_stderr:
+ with self.assertRaises(SystemExit) as context_manager:
+ main()
+ self.assertEqual(1, context_manager.exception.code)
self.assertEqual(
'Expected either --config-file argument or --doc\n',
m_stderr.getvalue())
@@ -374,7 +371,7 @@ class CloudTestsIntegrationTest(CiTestCase):
raises Warnings or errors on invalid cloud-config schema.
"""
- @skipIf(_missing_jsonschema_dep, "No python-jsonschema dependency")
+ @skipUnlessJsonSchema()
def test_all_integration_test_cloud_config_schema(self):
"""Validate schema of cloud_tests yaml files looking for warnings."""
schema = get_schema()
diff --git a/tests/unittests/test_net.py b/tests/unittests/test_net.py
index ac33e8ef..c12a487a 100644
--- a/tests/unittests/test_net.py
+++ b/tests/unittests/test_net.py
@@ -12,10 +12,8 @@ from cloudinit.sources.helpers import openstack
from cloudinit import temp_utils
from cloudinit import util
-from cloudinit.tests.helpers import CiTestCase
-from cloudinit.tests.helpers import dir2dict
-from cloudinit.tests.helpers import mock
-from cloudinit.tests.helpers import populate_dir
+from cloudinit.tests.helpers import (
+ CiTestCase, FilesystemMockingTestCase, dir2dict, mock, populate_dir)
import base64
import copy
@@ -395,12 +393,6 @@ NETWORK_CONFIGS = {
eth1:
match:
macaddress: cf:d6:af:48:e8:80
- nameservers:
- addresses:
- - 1.2.3.4
- - 5.6.7.8
- search:
- - wark.maas
set-name: eth1
eth99:
addresses:
@@ -412,12 +404,9 @@ NETWORK_CONFIGS = {
addresses:
- 8.8.8.8
- 8.8.4.4
- - 1.2.3.4
- - 5.6.7.8
search:
- barley.maas
- sach.maas
- - wark.maas
routes:
- to: 0.0.0.0/0
via: 65.61.151.37
@@ -656,81 +645,27 @@ pre-down route del -net 10.0.0.0 netmask 255.0.0.0 gw 11.0.0.1 metric 3 || true
eth0:
match:
macaddress: c0:d6:9f:2c:e8:80
- nameservers:
- addresses:
- - 8.8.8.8
- - 4.4.4.4
- - 8.8.4.4
- search:
- - barley.maas
- - wark.maas
- - foobar.maas
set-name: eth0
eth1:
match:
macaddress: aa:d6:9f:2c:e8:80
- nameservers:
- addresses:
- - 8.8.8.8
- - 4.4.4.4
- - 8.8.4.4
- search:
- - barley.maas
- - wark.maas
- - foobar.maas
set-name: eth1
eth2:
match:
macaddress: c0:bb:9f:2c:e8:80
- nameservers:
- addresses:
- - 8.8.8.8
- - 4.4.4.4
- - 8.8.4.4
- search:
- - barley.maas
- - wark.maas
- - foobar.maas
set-name: eth2
eth3:
match:
macaddress: 66:bb:9f:2c:e8:80
- nameservers:
- addresses:
- - 8.8.8.8
- - 4.4.4.4
- - 8.8.4.4
- search:
- - barley.maas
- - wark.maas
- - foobar.maas
set-name: eth3
eth4:
match:
macaddress: 98:bb:9f:2c:e8:80
- nameservers:
- addresses:
- - 8.8.8.8
- - 4.4.4.4
- - 8.8.4.4
- search:
- - barley.maas
- - wark.maas
- - foobar.maas
set-name: eth4
eth5:
dhcp4: true
match:
macaddress: 98:bb:9f:2c:e8:8a
- nameservers:
- addresses:
- - 8.8.8.8
- - 4.4.4.4
- - 8.8.4.4
- search:
- - barley.maas
- - wark.maas
- - foobar.maas
set-name: eth5
bonds:
bond0:
@@ -750,6 +685,15 @@ pre-down route del -net 10.0.0.0 netmask 255.0.0.0 gw 11.0.0.1 metric 3 || true
interfaces:
- eth3
- eth4
+ nameservers:
+ addresses:
+ - 8.8.8.8
+ - 4.4.4.4
+ - 8.8.4.4
+ search:
+ - barley.maas
+ - wark.maas
+ - foobar.maas
parameters:
ageing-time: 250
forward-delay: 1
@@ -758,6 +702,9 @@ pre-down route del -net 10.0.0.0 netmask 255.0.0.0 gw 11.0.0.1 metric 3 || true
path-cost:
eth3: 50
eth4: 75
+ port-priority:
+ eth3: 28
+ eth4: 14
priority: 22
stp: false
routes:
@@ -2183,27 +2130,49 @@ class TestCmdlineConfigParsing(CiTestCase):
self.assertEqual(found, self.simple_cfg)
-class TestCmdlineReadKernelConfig(CiTestCase):
+class TestCmdlineReadKernelConfig(FilesystemMockingTestCase):
macs = {
'eth0': '14:02:ec:42:48:00',
'eno1': '14:02:ec:42:48:01',
}
- def test_ip_cmdline_read_kernel_cmdline_ip(self):
- content = {'net-eth0.conf': DHCP_CONTENT_1}
- files = sorted(populate_dir(self.tmp_dir(), content))
+ def test_ip_cmdline_without_ip(self):
+ content = {'/run/net-eth0.conf': DHCP_CONTENT_1,
+ cmdline._OPEN_ISCSI_INTERFACE_FILE: "eth0\n"}
+ exp1 = copy.deepcopy(DHCP_EXPECTED_1)
+ exp1['mac_address'] = self.macs['eth0']
+
+ root = self.tmp_dir()
+ populate_dir(root, content)
+ self.reRoot(root)
+
found = cmdline.read_kernel_cmdline_config(
- files=files, cmdline='foo ip=dhcp', mac_addrs=self.macs)
+ cmdline='foo root=/root/bar', mac_addrs=self.macs)
+ self.assertEqual(found['version'], 1)
+ self.assertEqual(found['config'], [exp1])
+
+ def test_ip_cmdline_read_kernel_cmdline_ip(self):
+ content = {'/run/net-eth0.conf': DHCP_CONTENT_1}
exp1 = copy.deepcopy(DHCP_EXPECTED_1)
exp1['mac_address'] = self.macs['eth0']
+
+ root = self.tmp_dir()
+ populate_dir(root, content)
+ self.reRoot(root)
+
+ found = cmdline.read_kernel_cmdline_config(
+ cmdline='foo ip=dhcp', mac_addrs=self.macs)
self.assertEqual(found['version'], 1)
self.assertEqual(found['config'], [exp1])
def test_ip_cmdline_read_kernel_cmdline_ip6(self):
- content = {'net6-eno1.conf': DHCP6_CONTENT_1}
- files = sorted(populate_dir(self.tmp_dir(), content))
+ content = {'/run/net6-eno1.conf': DHCP6_CONTENT_1}
+ root = self.tmp_dir()
+ populate_dir(root, content)
+ self.reRoot(root)
+
found = cmdline.read_kernel_cmdline_config(
- files=files, cmdline='foo ip6=dhcp root=/dev/sda',
+ cmdline='foo ip6=dhcp root=/dev/sda',
mac_addrs=self.macs)
self.assertEqual(
found,
@@ -2223,18 +2192,23 @@ class TestCmdlineReadKernelConfig(CiTestCase):
self.assertIsNone(found)
def test_ip_cmdline_both_ip_ip6(self):
- content = {'net-eth0.conf': DHCP_CONTENT_1,
- 'net6-eth0.conf': DHCP6_CONTENT_1.replace('eno1', 'eth0')}
- files = sorted(populate_dir(self.tmp_dir(), content))
- found = cmdline.read_kernel_cmdline_config(
- files=files, cmdline='foo ip=dhcp ip6=dhcp', mac_addrs=self.macs)
-
+ content = {
+ '/run/net-eth0.conf': DHCP_CONTENT_1,
+ '/run/net6-eth0.conf': DHCP6_CONTENT_1.replace('eno1', 'eth0')}
eth0 = copy.deepcopy(DHCP_EXPECTED_1)
eth0['mac_address'] = self.macs['eth0']
eth0['subnets'].append(
{'control': 'manual', 'type': 'dhcp6',
'netmask': '64', 'dns_nameservers': ['2001:67c:1562:8010::2:1']})
expected = [eth0]
+
+ root = self.tmp_dir()
+ populate_dir(root, content)
+ self.reRoot(root)
+
+ found = cmdline.read_kernel_cmdline_config(
+ cmdline='foo ip=dhcp ip6=dhcp', mac_addrs=self.macs)
+
self.assertEqual(found['version'], 1)
self.assertEqual(found['config'], expected)
@@ -2306,6 +2280,9 @@ class TestNetplanRoundTrip(CiTestCase):
def testsimple_render_all(self):
entry = NETWORK_CONFIGS['all']
files = self._render_and_read(network_config=yaml.load(entry['yaml']))
+ print(entry['expected_netplan'])
+ print('-- expected ^ | v rendered --')
+ print(files['/etc/netplan/50-cloud-init.yaml'])
self.assertEqual(
entry['expected_netplan'].splitlines(),
files['/etc/netplan/50-cloud-init.yaml'].splitlines())
diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py
index 4a92e741..8685b8e2 100644
--- a/tests/unittests/test_util.py
+++ b/tests/unittests/test_util.py
@@ -8,7 +8,9 @@ import shutil
import stat
import tempfile
+import json
import six
+import sys
import yaml
from cloudinit import importer, util
@@ -364,6 +366,56 @@ class TestMountinfoParsing(helpers.ResourceUsingTestCase):
expected = ('none', 'tmpfs', '/run/lock')
self.assertEqual(expected, util.parse_mount_info('/run/lock', lines))
+ @mock.patch('cloudinit.util.subp')
+ def test_get_device_info_from_zpool(self, zpool_output):
+ # mock subp command from util.get_mount_info_fs_on_zpool
+ zpool_output.return_value = (
+ self.readResource('zpool_status_simple.txt'), ''
+ )
+ # save function return values and do asserts
+ ret = util.get_device_info_from_zpool('vmzroot')
+ self.assertEqual('gpt/system', ret)
+ self.assertIsNotNone(ret)
+
+ @mock.patch('cloudinit.util.subp')
+ def test_get_device_info_from_zpool_on_error(self, zpool_output):
+ # mock subp command from util.get_mount_info_fs_on_zpool
+ zpool_output.return_value = (
+ self.readResource('zpool_status_simple.txt'), 'error'
+ )
+ # save function return values and do asserts
+ ret = util.get_device_info_from_zpool('vmzroot')
+ self.assertIsNone(ret)
+
+ @mock.patch('cloudinit.util.subp')
+ def test_parse_mount_with_ext(self, mount_out):
+ mount_out.return_value = (self.readResource('mount_parse_ext.txt'), '')
+ # this one is valid and exists in mount_parse_ext.txt
+ ret = util.parse_mount('/var')
+ self.assertEqual(('/dev/mapper/vg00-lv_var', 'ext4', '/var'), ret)
+ # another one that is valid and exists
+ ret = util.parse_mount('/')
+ self.assertEqual(('/dev/mapper/vg00-lv_root', 'ext4', '/'), ret)
+ # this one exists in mount_parse_ext.txt
+ ret = util.parse_mount('/sys/kernel/debug')
+ self.assertIsNone(ret)
+ # this one does not even exist in mount_parse_ext.txt
+ ret = util.parse_mount('/not/existing/mount')
+ self.assertIsNone(ret)
+
+ @mock.patch('cloudinit.util.subp')
+ def test_parse_mount_with_zfs(self, mount_out):
+ mount_out.return_value = (self.readResource('mount_parse_zfs.txt'), '')
+ # this one is valid and exists in mount_parse_zfs.txt
+ ret = util.parse_mount('/var')
+ self.assertEqual(('vmzroot/ROOT/freebsd/var', 'zfs', '/var'), ret)
+ # this one is the root, valid and also exists in mount_parse_zfs.txt
+ ret = util.parse_mount('/')
+ self.assertEqual(('vmzroot/ROOT/freebsd', 'zfs', '/'), ret)
+ # this one does not even exist in mount_parse_ext.txt
+ ret = util.parse_mount('/not/existing/mount')
+ self.assertIsNone(ret)
+
class TestReadDMIData(helpers.FilesystemMockingTestCase):
@@ -630,6 +682,24 @@ class TestSubp(helpers.CiTestCase):
# but by using bash, we remove dependency on another program.
return([BASH, '-c', 'printf "$@"', 'printf'] + list(args))
+ def test_subp_handles_bytestrings(self):
+ """subp can run a bytestring command if shell is True."""
+ tmp_file = self.tmp_path('test.out')
+ cmd = 'echo HI MOM >> {tmp_file}'.format(tmp_file=tmp_file)
+ (out, _err) = util.subp(cmd.encode('utf-8'), shell=True)
+ self.assertEqual(u'', out)
+ self.assertEqual(u'', _err)
+ self.assertEqual('HI MOM\n', util.load_file(tmp_file))
+
+ def test_subp_handles_strings(self):
+ """subp can run a string command if shell is True."""
+ tmp_file = self.tmp_path('test.out')
+ cmd = 'echo HI MOM >> {tmp_file}'.format(tmp_file=tmp_file)
+ (out, _err) = util.subp(cmd, shell=True)
+ self.assertEqual(u'', out)
+ self.assertEqual(u'', _err)
+ self.assertEqual('HI MOM\n', util.load_file(tmp_file))
+
def test_subp_handles_utf8(self):
# The given bytes contain utf-8 accented characters as seen in e.g.
# the "deja dup" package in Ubuntu.
@@ -733,6 +803,71 @@ class TestSubp(helpers.CiTestCase):
self.assertEqual("/target/my/path/",
util.target_path("/target/", "///my/path/"))
+ def test_c_lang_can_take_utf8_args(self):
+ """Independent of system LC_CTYPE, args can contain utf-8 strings.
+
+ When python starts up, its default encoding gets set based on
+ the value of LC_CTYPE. If no system locale is set, the default
+ encoding for both python2 and python3 in some paths will end up
+ being ascii.
+
+ Attempts to use setlocale or patching (or changing) os.environ
+ in the current environment seem to not be effective.
+
+ This test starts up a python with LC_CTYPE set to C so that
+ the default encoding will be set to ascii. In such an environment
+ Popen(['command', 'non-ascii-arg']) would cause a UnicodeDecodeError.
+ """
+ python_prog = '\n'.join([
+ 'import json, sys',
+ 'from cloudinit.util import subp',
+ 'data = sys.stdin.read()',
+ 'cmd = json.loads(data)',
+ 'subp(cmd, capture=False)',
+ ''])
+ cmd = [BASH, '-c', 'echo -n "$@"', '--',
+ self.utf8_valid.decode("utf-8")]
+ python_subp = [sys.executable, '-c', python_prog]
+
+ out, _err = util.subp(
+ python_subp, update_env={'LC_CTYPE': 'C'},
+ data=json.dumps(cmd).encode("utf-8"),
+ decode=False)
+ self.assertEqual(self.utf8_valid, out)
+
+ def test_bogus_command_logs_status_messages(self):
+ """status_cb gets status messages logs on bogus commands provided."""
+ logs = []
+
+ def status_cb(log):
+ logs.append(log)
+
+ with self.assertRaises(util.ProcessExecutionError):
+ util.subp([self.bogus_command], status_cb=status_cb)
+
+ expected = [
+ 'Begin run command: {cmd}\n'.format(cmd=self.bogus_command),
+ 'ERROR: End run command: invalid command provided\n']
+ self.assertEqual(expected, logs)
+
+ def test_command_logs_exit_codes_to_status_cb(self):
+ """status_cb gets status messages containing command exit code."""
+ logs = []
+
+ def status_cb(log):
+ logs.append(log)
+
+ with self.assertRaises(util.ProcessExecutionError):
+ util.subp(['ls', '/I/dont/exist'], status_cb=status_cb)
+ util.subp(['ls'], status_cb=status_cb)
+
+ expected = [
+ 'Begin run command: ls /I/dont/exist\n',
+ 'ERROR: End run command: exit(2)\n',
+ 'Begin run command: ls\n',
+ 'End run command: exit(0)\n']
+ self.assertEqual(expected, logs)
+
class TestEncode(helpers.TestCase):
"""Test the encoding functions"""
diff --git a/tools/ds-identify b/tools/ds-identify
index cd268242..9a2db5c4 100755
--- a/tools/ds-identify
+++ b/tools/ds-identify
@@ -92,6 +92,7 @@ DI_DMI_SYS_VENDOR=""
DI_DMI_PRODUCT_SERIAL=""
DI_DMI_PRODUCT_UUID=""
DI_FS_LABELS=""
+DI_FS_UUIDS=""
DI_ISO9660_DEVS=""
DI_KERNEL_CMDLINE=""
DI_VIRT=""
@@ -114,7 +115,7 @@ DI_DSNAME=""
# be searched if there is no setting found in config.
DI_DSLIST_DEFAULT="MAAS ConfigDrive NoCloud AltCloud Azure Bigstep \
CloudSigma CloudStack DigitalOcean AliYun Ec2 GCE OpenNebula OpenStack \
-OVF SmartOS Scaleway"
+OVF SmartOS Scaleway Hetzner IBMCloud"
DI_DSLIST=""
DI_MODE=""
DI_ON_FOUND=""
@@ -123,6 +124,8 @@ DI_ON_NOTFOUND=""
DI_EC2_STRICT_ID_DEFAULT="true"
+_IS_IBM_CLOUD=""
+
error() {
set -- "ERROR:" "$@";
debug 0 "$@"
@@ -186,7 +189,8 @@ block_dev_with_label() {
read_fs_info() {
cached "${DI_BLKID_OUTPUT}" && return 0
# do not rely on links in /dev/disk which might not be present yet.
- # note that older blkid versions do not report DEVNAME in 'export' output.
+ # Note that blkid < 2.22 (centos6, trusty) do not output DEVNAME.
+ # that means that DI_ISO9660_DEVS will not be set.
if is_container; then
# blkid will in a container, or at least currently in lxd
# not provide useful information.
@@ -195,7 +199,7 @@ read_fs_info() {
return
fi
local oifs="$IFS" line="" delim=","
- local ret=0 out="" labels="" dev="" label="" ftype="" isodevs=""
+ local ret=0 out="" labels="" dev="" label="" ftype="" isodevs="" uuids=""
out=$(blkid -c /dev/null -o export) || {
ret=$?
error "failed running [$ret]: blkid -c /dev/null -o export"
@@ -203,22 +207,29 @@ read_fs_info() {
DI_ISO9660_DEVS="$UNAVAILABLE:error"
return $ret
}
- IFS="$CR"
- set -- $out
- IFS="$oifs"
- for line in "$@" ""; do
+ # 'set --' will collapse multiple consecutive entries in IFS for
+ # whitespace characters (\n, tab, " ") so we cannot rely on getting
+ # empty lines in "$@" below.
+ IFS="$CR"; set -- $out; IFS="$oifs"
+
+ for line in "$@"; do
case "${line}" in
- DEVNAME=*) dev=${line#DEVNAME=};;
+ DEVNAME=*)
+ [ -n "$dev" -a "$ftype" = "iso9660" ] &&
+ isodevs="${isodevs} ${dev}=$label"
+ ftype=""; dev=""; label="";
+ dev=${line#DEVNAME=};;
LABEL=*) label="${line#LABEL=}";
labels="${labels}${line#LABEL=}${delim}";;
TYPE=*) ftype=${line#TYPE=};;
- "") if [ "$ftype" = "iso9660" ]; then
- isodevs="${isodevs} ${dev}=$label"
- fi
- ftype=""; devname=""; label="";
+ UUID=*) uuids="${uuids}${line#UUID=}$delim";;
esac
done
+ [ -n "$dev" -a "$ftype" = "iso9660" ] &&
+ isodevs="${isodevs} ${dev}=$label"
+
DI_FS_LABELS="${labels%${delim}}"
+ DI_FS_UUIDS="${uuids%${delim}}"
DI_ISO9660_DEVS="${isodevs# }"
}
@@ -431,14 +442,25 @@ dmi_sys_vendor_is() {
[ "${DI_DMI_SYS_VENDOR}" = "$1" ]
}
-has_fs_with_label() {
- local label="$1"
- case ",${DI_FS_LABELS}," in
- *,$label,*) return 0;;
+has_fs_with_uuid() {
+ case ",${DI_FS_UUIDS}," in
+ *,$1,*) return 0;;
esac
return 1
}
+has_fs_with_label() {
+ # has_fs_with_label(label1[ ,label2 ..])
+ # return 0 if a there is a filesystem that matches any of the labels.
+ local label=""
+ for label in "$@"; do
+ case ",${DI_FS_LABELS}," in
+ *,$label,*) return 0;;
+ esac
+ done
+ return 1
+}
+
nocase_equal() {
# nocase_equal(a, b)
# return 0 if case insenstive comparision a.lower() == b.lower()
@@ -470,6 +492,16 @@ check_seed_dir() {
return 0
}
+check_writable_seed_dir() {
+ # ubuntu core bind-mounts /writable/system-data/var/lib/cloud
+ # over the top of /var/lib/cloud, but the mount might not be done yet.
+ local wdir="/writable/system-data"
+ [ -d "${PATH_ROOT}$wdir" ] || return 1
+ local sdir="${PATH_ROOT}$wdir${PATH_VAR_LIB_CLOUD#${PATH_ROOT}}"
+ local PATH_VAR_LIB_CLOUD="$sdir"
+ check_seed_dir "$@"
+}
+
probe_floppy() {
cached "${STATE_FLOPPY_PROBED}" && return "${STATE_FLOPPY_PROBED}"
local fpath=/dev/floppy
@@ -567,8 +599,11 @@ dscheck_NoCloud() {
case " ${DI_DMI_PRODUCT_SERIAL} " in
*\ ds=nocloud*) return ${DS_FOUND};;
esac
+
+ is_ibm_cloud && return ${DS_NOT_FOUND}
for d in nocloud nocloud-net; do
check_seed_dir "$d" meta-data user-data && return ${DS_FOUND}
+ check_writable_seed_dir "$d" meta-data user-data && return ${DS_FOUND}
done
if has_fs_with_label "${fslabel}"; then
return ${DS_FOUND}
@@ -577,9 +612,8 @@ dscheck_NoCloud() {
}
check_configdrive_v2() {
- if has_fs_with_label "config-2"; then
- return ${DS_FOUND}
- elif has_fs_with_label "CONFIG-2"; then
+ is_ibm_cloud && return ${DS_NOT_FOUND}
+ if has_fs_with_label CONFIG-2 config-2; then
return ${DS_FOUND}
fi
# look in /config-drive <vlc>/seed/config_drive for a directory
@@ -633,8 +667,9 @@ ovf_vmware_guest_customization() {
# we have to have the plugin to do vmware customization
local found="" pkg="" pre="${PATH_ROOT}/usr/lib"
+ local ppath="plugins/vmsvc/libdeployPkgPlugin.so"
for pkg in vmware-tools open-vm-tools; do
- if [ -f "$pre/$pkg/plugins/vmsvc/libdeployPkgPlugin.so" ]; then
+ if [ -f "$pre/$pkg/$ppath" -o -f "${pre}64/$pkg/$ppath" ]; then
found="$pkg"; break;
fi
done
@@ -685,15 +720,12 @@ dscheck_OVF() {
# Azure provides ovf. Skip false positive by dis-allowing.
is_azure_chassis && return $DS_NOT_FOUND
- local isodevs="${DI_ISO9660_DEVS}"
- case "$isodevs" in
- ""|$UNAVAILABLE:*) return ${DS_NOT_FOUND};;
- esac
-
# DI_ISO9660_DEVS is <device>=label, like /dev/sr0=OVF-TRANSPORT
- for tok in $isodevs; do
- is_cdrom_ovf "${tok%%=*}" "${tok#*=}" && return $DS_FOUND
- done
+ if [ "${DI_ISO9660_DEVS#${UNAVAILABLE}:}" = "${DI_ISO9660_DEVS}" ]; then
+ for tok in ${DI_ISO9660_DEVS}; do
+ is_cdrom_ovf "${tok%%=*}" "${tok#*=}" && return $DS_FOUND
+ done
+ fi
if ovf_vmware_guest_customization; then
return ${DS_FOUND}
@@ -879,6 +911,10 @@ dscheck_OpenStack() {
return ${DS_FOUND}
fi
+ if dmi_chassis_asset_tag_matches "OpenTelekomCloud"; then
+ return ${DS_FOUND}
+ fi
+
# LP: #1715241 : arch other than intel are not identified properly.
case "$DI_UNAME_MACHINE" in
i?86|x86_64) :;;
@@ -964,6 +1000,41 @@ dscheck_Scaleway() {
return ${DS_NOT_FOUND}
}
+dscheck_Hetzner() {
+ dmi_sys_vendor_is Hetzner && return ${DS_FOUND}
+ return ${DS_NOT_FOUND}
+}
+
+is_ibm_provisioning() {
+ [ -f "${PATH_ROOT}/root/provisioningConfiguration.cfg" ]
+}
+
+is_ibm_cloud() {
+ cached "${_IS_IBM_CLOUD}" && return ${_IS_IBM_CLOUD}
+ local ret=1
+ if [ "$DI_VIRT" = "xen" ]; then
+ if is_ibm_provisioning; then
+ ret=0
+ elif has_fs_with_label METADATA metadata; then
+ ret=0
+ elif has_fs_with_uuid 9796-932E &&
+ has_fs_with_label CONFIG-2 config-2; then
+ ret=0
+ fi
+ fi
+ _IS_IBM_CLOUD=$ret
+ return $ret
+}
+
+dscheck_IBMCloud() {
+ if is_ibm_provisioning; then
+ debug 1 "cloud-init disabled during provisioning on IBMCloud"
+ return ${DS_NOT_FOUND}
+ fi
+ is_ibm_cloud && return ${DS_FOUND}
+ return ${DS_NOT_FOUND}
+}
+
collect_info() {
read_virt
read_pid1_product_name
diff --git a/tools/pipremove b/tools/pipremove
new file mode 100755
index 00000000..f8f4ff11
--- /dev/null
+++ b/tools/pipremove
@@ -0,0 +1,14 @@
+#!/usr/bin/python3
+import subprocess
+import sys
+
+for pkg in sys.argv[1:]:
+ try:
+ exec('import %s' % pkg) # pylint: disable=W0122
+ except ImportError:
+ continue
+ sys.stderr.write("%s removing package %s\n" % (sys.argv[0], pkg))
+ ret = subprocess.Popen(['pip', 'uninstall', '--yes', pkg]).wait()
+ if ret != 0:
+ sys.stderr.write("Failed to uninstall %s (%d)\n" % (pkg, ret))
+ sys.exit(ret)
diff --git a/tools/run-centos b/tools/run-centos
index d58ef3e8..cb241ee5 100755
--- a/tools/run-centos
+++ b/tools/run-centos
@@ -23,6 +23,9 @@ Usage: ${0##*/} [ options ] version
options:
-a | --artifact keep .rpm artifacts
+ --dirty apply local changes before running tests.
+ If not provided, a clean checkout of branch is tested.
+ Inside container, changes are in local-changes.diff.
-k | --keep keep container after tests
-r | --rpm build .rpm
-s | --srpm build .src.rpm
@@ -80,25 +83,84 @@ inside() {
inject_cloud_init(){
# take current cloud-init git dir and put it inside $name at
# ~$user/cloud-init.
- local name="$1" user="$2" top_d="" dname="" pstat=""
- top_d=$(git rev-parse --show-toplevel) || {
- errorrc "Failed to get git top level in $PWD";
+ local name="$1" user="$2" dirty="$3"
+ local changes="" top_d="" dname="cloud-init" pstat=""
+ local gitdir="" commitish=""
+ gitdir=$(git rev-parse --git-dir) || {
+ errorrc "Failed to get git dir in $PWD";
return
}
- dname=$(basename "${top_d}") || return
- debug 1 "collecting ${top_d} ($dname) into user $user in $name."
- tar -C "${top_d}/.." -cpf - "$dname" |
+ local t=${gitdir%/*}
+ case "$t" in
+ */worktrees)
+ if [ -f "${t%worktrees}/config" ]; then
+ gitdir="${t%worktrees}"
+ fi
+ esac
+
+ # attempt to get branch name.
+ commitish=$(git rev-parse --abbrev-ref HEAD) || {
+ errorrc "Failed git rev-parse --abbrev-ref HEAD"
+ return
+ }
+ if [ "$commitish" = "HEAD" ]; then
+ # detached head
+ commitish=$(git rev-parse HEAD) || {
+ errorrc "failed git rev-parse HEAD"
+ return
+ }
+ fi
+
+ local local_changes=false
+ if ! git diff --quiet "$commitish"; then
+ # there are local changes not committed.
+ local_changes=true
+ if [ "$dirty" = "false" ]; then
+ error "WARNING: You had uncommitted changes. Those changes will "
+ error "be put into 'local-changes.diff' inside the container. "
+ error "To test these changes you must pass --dirty."
+ fi
+ fi
+
+ debug 1 "collecting ${gitdir} ($dname) into user $user in $name."
+ tar -C "${gitdir}" -cpf - . |
inside_as "$name" "$user" sh -ec '
dname=$1
+ commitish=$2
rm -Rf "$dname"
+ mkdir -p $dname/.git
+ cd $dname/.git
tar -xpf -
- [ "$dname" = "cloud-init" ] || mv "$dname" cloud-init' \
- extract "$dname"
+ cd ..
+ git config core.bare false
+ out=$(git checkout $commitish 2>&1) ||
+ { echo "failed git checkout $commitish: $out" 1>&2; exit 1; }
+ out=$(git checkout . 2>&1) ||
+ { echo "failed git checkout .: $out" 1>&2; exit 1; }
+ ' extract "$dname" "$commitish"
[ "${PIPESTATUS[*]}" = "0 0" ] || {
- error "Failed to push tarball of '$top_d' into $name" \
+ error "Failed to push tarball of '$gitdir' into $name" \
" for user $user (dname=$dname)"
return 1
}
+
+ echo "local_changes=$local_changes dirty=$dirty"
+ if [ "$local_changes" = "true" ]; then
+ git diff "$commitish" |
+ inside_as "$name" "$user" sh -exc '
+ cd "$1"
+ if [ "$2" = "true" ]; then
+ git apply
+ else
+ cat > local-changes.diff
+ fi
+ ' insert_changes "$dname" "$dirty"
+ [ "${PIPESTATUS[*]}" = "0 0" ] || {
+ error "Failed to apply local changes."
+ return 1
+ }
+ fi
+
return 0
}
@@ -179,7 +241,7 @@ delete_container() {
main() {
local short_opts="ahkrsuv"
- local long_opts="artifact,help,keep,rpm,srpm,unittest,verbose"
+ local long_opts="artifact,dirty,help,keep,rpm,srpm,unittest,verbose"
local getopt_out=""
getopt_out=$(getopt --name "${0##*/}" \
--options "${short_opts}" --long "${long_opts}" -- "$@") &&
@@ -188,11 +250,13 @@ main() {
local cur="" next=""
local artifact="" keep="" rpm="" srpm="" unittest="" version=""
+ local dirty=false
while [ $# -ne 0 ]; do
cur="${1:-}"; next="${2:-}";
case "$cur" in
-a|--artifact) artifact=1;;
+ --dirty) dirty=true;;
-h|--help) Usage ; exit 0;;
-k|--keep) KEEP=true;;
-r|--rpm) rpm=1;;
@@ -231,7 +295,7 @@ main() {
inside "$name" useradd "$user"
debug 1 "inserting cloud-init"
- inject_cloud_init "$name" "$user" || {
+ inject_cloud_init "$name" "$user" "$dirty" || {
errorrc "FAIL: injecting cloud-init into $name failed."
return
}
@@ -244,12 +308,13 @@ main() {
local errors=0
inside_as_cd "$name" "$user" "$cdir" \
- sh -ec "git checkout .; git status" ||
+ sh -ec "git status" ||
{ errorrc "git checkout failed."; errors=$(($errors+1)); }
if [ -n "$unittest" ]; then
debug 1 "running unit tests."
- inside_as_cd "$name" "$user" "$cdir" nosetests tests/unittests ||
+ inside_as_cd "$name" "$user" "$cdir" \
+ nosetests tests/unittests cloudinit ||
{ errorrc "nosetests failed."; errors=$(($errors+1)); }
fi
diff --git a/tox.ini b/tox.ini
index bb74853f..818ade3d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -45,7 +45,7 @@ deps = -r{toxinidir}/test-requirements.txt
[testenv:py26]
deps = -r{toxinidir}/test-requirements.txt
-commands = nosetests {posargs:tests/unittests}
+commands = nosetests {posargs:tests/unittests cloudinit}
setenv =
LC_ALL = C
@@ -60,6 +60,9 @@ deps = sphinx
commands = {envpython} -m sphinx {posargs:doc/rtd doc/rtd_html}
[testenv:xenial]
+commands =
+ python ./tools/pipremove jsonschema
+ python -m nose {posargs:tests/unittests cloudinit}
basepython = python3
deps =
# requirements
@@ -83,7 +86,7 @@ deps =
[testenv:centos6]
basepython = python2.6
-commands = nosetests {posargs:tests/unittests}
+commands = nosetests {posargs:tests/unittests cloudinit}
deps =
# requirements
argparse==1.2.1
@@ -98,7 +101,7 @@ deps =
[testenv:opensusel42]
basepython = python2.7
-commands = nosetests {posargs:tests/unittests}
+commands = nosetests {posargs:tests/unittests cloudinit}
deps =
# requirements
argparse==1.3.0