diff --git a/cloudinit/osys/windows/general.py b/cloudinit/osys/windows/general.py index 578b9963..db92049c 100644 --- a/cloudinit/osys/windows/general.py +++ b/cloudinit/osys/windows/general.py @@ -52,8 +52,8 @@ class General(general.General): def reboot(self): raise NotImplementedError - def set_locale(self): + def set_locale(self, locale): raise NotImplementedError - def set_timezone(self): + def set_timezone(self, timezone): raise NotImplementedError diff --git a/cloudinit/osys/windows/network.py b/cloudinit/osys/windows/network.py index 9e980a3d..43ac1f25 100644 --- a/cloudinit/osys/windows/network.py +++ b/cloudinit/osys/windows/network.py @@ -11,11 +11,15 @@ from ctypes import wintypes import logging import subprocess +from six.moves import urllib_parse + from cloudinit import exceptions +from cloudinit.osys import base from cloudinit.osys import network from cloudinit.osys.windows.util import iphlpapi from cloudinit.osys.windows.util import kernel32 from cloudinit.osys.windows.util import ws2_32 +from cloudinit import url_helper MIB_IPPROTO_NETMGMT = 3 @@ -26,7 +30,8 @@ _PROTOCOL_TCP = "TCP" _PROTOCOL_UDP = "UDP" _ERROR_FILE_NOT_FOUND = 2 _ComputerNamePhysicalDnsHostname = 5 -LOG = logging.getLogger(__file__) +_MAX_URL_CHECK_RETRIES = 3 +LOG = logging.getLogger(__name__) def _heap_alloc(heap, size): @@ -37,6 +42,15 @@ def _heap_alloc(heap, size): return table_mem +def _check_url(url, retries_count=_MAX_URL_CHECK_RETRIES): + LOG.debug("Testing url: %s", url) + try: + url_helper.read_url(url, retries=retries_count) + return True + except url_helper.UrlError: + return False + + class Network(network.Network): """Network namespace object tailored for the Windows platform.""" @@ -110,6 +124,42 @@ class Network(network.Network): return next((r for r in self.routes() if r.destination == '0.0.0.0'), None) + def set_metadata_ip_route(self, metadata_url): + """Set a network route if the given metadata url can't be accessed. + + This is a workaround for + https://bugs.launchpad.net/quantum/+bug/1174657. + """ + osutils = base.get_osutils() + + if osutils.general.check_os_version(6, 0): + # 169.254.x.x addresses are not getting routed starting from + # Windows Vista / 2008 + metadata_netloc = urllib_parse.urlparse(metadata_url).netloc + metadata_host = metadata_netloc.split(':')[0] + + if not metadata_host.startswith("169.254."): + return + + routes = self.routes() + exists_route = any(route.destination == metadata_host + for route in routes) + if not exists_route and not _check_url(metadata_url): + default_gateway = self.default_gateway() + if default_gateway: + try: + LOG.debug('Setting gateway for host: %s', + metadata_host) + route = Route( + destination=metadata_host, + netmask="255.255.255.255", + gateway=default_gateway.gateway, + interface=None, metric=None) + Route.add(route) + except Exception as ex: + # Ignore it + LOG.exception(ex) + # These are not required by the Windows version for now, # but we provide them as noop version. def hosts(self): diff --git a/cloudinit/sources/base.py b/cloudinit/sources/base.py new file mode 100644 index 00000000..0872ee13 --- /dev/null +++ b/cloudinit/sources/base.py @@ -0,0 +1,97 @@ +# Copyright 2015 Canonical Ltd. +# This file is part of cloud-init. See LICENCE file for license information. +# +# vi: ts=4 expandtab + +import abc + +import six + + +class APIResponse(object): + """Holds API response content + + To access the content in the binary format, use the + `buffer` attribute, while the unicode content can be + accessed by calling `str` over this. + """ + + def __init__(self, buffer, encoding="utf-8"): + self.buffer = buffer + self._encoding = encoding + + def __str__(self): + return self.buffer.decode(self._encoding) + + +@six.add_metaclass(abc.ABCMeta) +class BaseDataSource(object): + """Base class for the data sources.""" + + datasource_config = {} + + def __init__(self, config=None): + self._cache = {} + # TODO(cpopa): merge them instead. + self._config = config or self.datasource_config + + def _get_cache_data(self, path): + """Do a metadata lookup for the given *path* + + This will return the available metadata under *path*, + while caching the result, so that a next call will not do + an additional API call. + """ + if path not in self._cache: + self._cache[path] = self._get_data(path) + + return self._cache[path] + + @abc.abstractmethod + def load(self): + """Try to load this metadata service. + + This should return ``True`` if the service was loaded properly, + ``False`` otherwise. + """ + + @abc.abstractmethod + def _get_data(self, path): + """Retrieve the metadata exported under the `path` key. + + This should return an instance of :class:`APIResponse`. + """ + + def instance_id(self): + """Get this instance's id.""" + + def user_data(self): + """Get the user data available for this instance.""" + + def vendor_data(self): + """Get the vendor data available for this instance.""" + + def host_name(self): + """Get the hostname available for this instance.""" + + def public_keys(self): + """Get the public keys available for this instance.""" + + def network_config(self): + """Get the specified network config, if any.""" + + def admin_password(self): + """Get the admin password.""" + + def post_password(self, password): + """Post the password to the metadata service.""" + + def can_update_password(self): + """Check if this data source can update the admin password.""" + + def is_password_changed(self): + """Check if the data source has a new password for this instance.""" + return False + + def is_password_set(self): + """Check if the password was already posted to the metadata service.""" diff --git a/cloudinit/sources/openstack/__init__.py b/cloudinit/sources/openstack/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cloudinit/sources/openstack/base.py b/cloudinit/sources/openstack/base.py new file mode 100644 index 00000000..6b44f5b7 --- /dev/null +++ b/cloudinit/sources/openstack/base.py @@ -0,0 +1,112 @@ +# Copyright 2015 Canonical Ltd. +# This file is part of cloud-init. See LICENCE file for license information. +# +# vi: ts=4 expandtab + +"""Base classes for interacting with OpenStack data sources.""" + +import abc +import json +import logging +import os + +import six + +from cloudinit.sources import base + +__all__ = ('BaseOpenStackSource', ) + +_PAYLOAD_KEY = "content_path" +_ADMIN_PASSWORD = "admin_pass" +LOG = logging.getLogger(__name__) +_OS_LATEST = 'latest' +_OS_FOLSOM = '2012-08-10' +_OS_GRIZZLY = '2013-04-04' +_OS_HAVANA = '2013-10-17' +# Keep this in chronological order. New supported versions go at the end. +_OS_VERSIONS = ( + _OS_FOLSOM, + _OS_GRIZZLY, + _OS_HAVANA, +) + + +@six.add_metaclass(abc.ABCMeta) +class BaseOpenStackSource(base.BaseDataSource): + """Base classes for interacting with an OpenStack data source. + + This is useful for both the HTTP data source, as well for + ConfigDrive. + """ + def __init__(self): + super(BaseOpenStackSource, self).__init__() + self._version = None + + @abc.abstractmethod + def _available_versions(self): + """Get the available metadata versions.""" + + @abc.abstractmethod + def _path_join(self, path, *addons): + """Join one or more components together.""" + + def _working_version(self): + versions = self._available_versions() + # OS_VERSIONS is stored in chronological order, so + # reverse it to check newest first. + supported = reversed(_OS_VERSIONS) + selected_version = next((version for version in supported + if version in versions), _OS_LATEST) + + LOG.debug("Selected version %r from %s", selected_version, versions) + return selected_version + + def _get_content(self, name): + path = self._path_join('openstack', 'content', name) + return self._get_cache_data(path) + + def _get_meta_data(self): + path = self._path_join('openstack', self._version, 'meta_data.json') + data = self._get_cache_data(path) + if data: + return json.loads(str(data)) + + def load(self): + self._version = self._working_version() + super(BaseOpenStackSource, self).load() + + def user_data(self): + path = self._path_join('openstack', self._version, 'user_data') + return self._get_cache_data(path).buffer + + def vendor_data(self): + path = self._path_join('openstack', self._version, 'vendor_data.json') + return self._get_cache_data(path).buffer + + def instance_id(self): + return self._get_meta_data().get('uuid') + + def host_name(self): + return self._get_meta_data().get('hostname') + + def public_keys(self): + public_keys = self._get_meta_data().get('public_keys') + if public_keys: + return list(public_keys.values()) + return [] + + def network_config(self): + network_config = self._get_meta_data().get('network_config') + if not network_config: + return None + if _PAYLOAD_KEY not in network_config: + return None + + content_path = network_config[_PAYLOAD_KEY] + content_name = os.path.basename(content_path) + return str(self._get_content(content_name)) + + def admin_password(self): + meta_data = self._get_meta_data() + meta = meta_data.get('meta', {}) + return meta.get(_ADMIN_PASSWORD) or meta_data.get(_ADMIN_PASSWORD) diff --git a/cloudinit/sources/openstack/httpopenstack.py b/cloudinit/sources/openstack/httpopenstack.py new file mode 100644 index 00000000..71dcee3f --- /dev/null +++ b/cloudinit/sources/openstack/httpopenstack.py @@ -0,0 +1,127 @@ +# Copyright 2015 Canonical Ltd. +# This file is part of cloud-init. See LICENCE file for license information. +# +# vi: ts=4 expandtab + +import logging +import os +import posixpath +import re + +from cloudinit import exceptions +from cloudinit.osys import base +from cloudinit.sources import base as base_source +from cloudinit.sources.openstack import base as baseopenstack +from cloudinit import url_helper + + +LOG = logging.getLogger(__name__) +IS_WINDOWS = os.name == 'nt' +# Not necessarily the same as using datetime.strftime, +# but should be enough for our use case. +VERSION_REGEX = re.compile('^\d{4}-\d{2}-\d{2}$') + + +class HttpOpenStackSource(baseopenstack.BaseOpenStackSource): + """Class for exporting the HTTP OpenStack data source.""" + + datasource_config = { + 'max_wait': 120, + 'timeout': 10, + 'metadata_url': 'http://169.254.169.254/', + 'post_password_version': '2013-04-04', + 'retries': 3, + } + + @staticmethod + def _enable_metadata_access(metadata_url): + if IS_WINDOWS: + osutils = base.get_osutils() + osutils.network.set_metadata_ip_route(metadata_url) + + @staticmethod + def _path_join(path, *addons): + return posixpath.join(path, *addons) + + @staticmethod + def _valid_api_version(version): + if version == 'latest': + return version + return VERSION_REGEX.match(version) + + def _available_versions(self): + content = str(self._get_cache_data("openstack")) + versions = list(filter(None, content.splitlines())) + if not versions: + msg = 'No metadata versions were found.' + raise exceptions.CloudInitError(msg) + + for version in versions: + if not self._valid_api_version(version): + msg = 'Invalid API version {!r}'.format(version) + raise exceptions.CloudInitError(msg) + + return versions + + def _get_data(self, path): + norm_path = self._path_join(self._config['metadata_url'], path) + LOG.debug('Getting metadata from: %s', norm_path) + response = url_helper.wait_any_url([norm_path], + timeout=self._config['timeout'], + max_wait=self._config['max_wait']) + if response: + _, request = response + return base_source.APIResponse(request.contents, + encoding=request.encoding) + + msg = "Metadata for url {0} was not accessible in due time" + raise exceptions.CloudInitError(msg.format(norm_path)) + + def _post_data(self, path, data): + norm_path = self._path_join(self._config['metadata_url'], path) + LOG.debug('Posting metadata to: %s', norm_path) + url_helper.read_url(norm_path, data=data, + retries=self._config['retries'], + timeout=self._config['timeout']) + + @property + def _password_path(self): + return 'openstack/%s/password' % self._version + + def load(self): + metadata_url = self._config['metadata_url'] + self._enable_metadata_access(metadata_url) + super(HttpOpenStackSource, self).load() + + try: + self._get_meta_data() + return True + except Exception: + LOG.warning('Metadata not found at URL %r', metadata_url) + return False + + def can_update_password(self): + """Check if the password can be posted for the current data source.""" + password = map(int, self._config['post_password_version'].split("-")) + if self._version == 'latest': + current = (0, ) + else: + current = map(int, self._version.split("-")) + return tuple(current) >= tuple(password) + + @property + def is_password_set(self): + path = self._password_path + content = self._get_cache_data(path).buffer + return len(content) > 0 + + def post_password(self, password): + try: + self._post_data(self._password_path, password) + return True + except url_helper.UrlError as ex: + if ex.status_code == url_helper.CONFLICT: + # Password already set + return False + else: + raise diff --git a/cloudinit/tests/osys/windows/test_network.py b/cloudinit/tests/osys/windows/test_network.py index 512785b9..26bb5a3d 100644 --- a/cloudinit/tests/osys/windows/test_network.py +++ b/cloudinit/tests/osys/windows/test_network.py @@ -8,6 +8,7 @@ import subprocess import unittest from cloudinit import exceptions +from cloudinit.tests.util import LogSnatcher from cloudinit.tests.util import mock @@ -15,7 +16,7 @@ class TestNetworkWindows(unittest.TestCase): def setUp(self): self._ctypes_mock = mock.MagicMock() - self._moves_mock = mock.Mock() + self._winreg_mock = mock.Mock() self._win32com_mock = mock.Mock() self._wmi_mock = mock.Mock() @@ -24,7 +25,7 @@ class TestNetworkWindows(unittest.TestCase): {'ctypes': self._ctypes_mock, 'win32com': self._win32com_mock, 'wmi': self._wmi_mock, - 'six.moves': self._moves_mock}) + 'six.moves.winreg': self._winreg_mock}) self._module_patcher.start() self._iphlpapi = mock.Mock() @@ -251,3 +252,106 @@ class TestNetworkWindows(unittest.TestCase): self.assertEqual('dwForwardIfIndex', given_route.interface) self.assertEqual('dwForwardMetric1', given_route.metric) self.assertEqual('dwForwardProto', given_route.flags) + + @mock.patch('cloudinit.osys.base.get_osutils') + @mock.patch('cloudinit.osys.windows.network.Network.routes') + def test_set_metadata_ip_route_not_called(self, mock_routes, + mock_osutils): + general = mock_osutils.return_value.general + general.check_os_version.return_value = False + + self._network.set_metadata_ip_route(mock.sentinel.url) + + self.assertFalse(mock_routes.called) + general.check_os_version.assert_called_once_with(6, 0) + + @mock.patch('cloudinit.osys.base.get_osutils') + @mock.patch('cloudinit.osys.windows.network.Network.routes') + def test_set_metadata_ip_route_not_invalid_url(self, mock_routes, + mock_osutils): + general = mock_osutils.return_value.general + general.check_os_version.return_value = True + + self._network.set_metadata_ip_route("http://169.253.169.253") + + self.assertFalse(mock_routes.called) + general.check_os_version.assert_called_once_with(6, 0) + + @mock.patch('cloudinit.osys.base.get_osutils') + @mock.patch('cloudinit.osys.windows.network.Network.routes') + @mock.patch('cloudinit.osys.windows.network.Network.default_gateway') + def test_set_metadata_ip_route_route_already_exists( + self, mock_default_gateway, mock_routes, mock_osutils): + + mock_route = mock.Mock() + mock_route.destination = "169.254.169.254" + mock_routes.return_value = (mock_route, ) + + self._network.set_metadata_ip_route("http://169.254.169.254") + + self.assertTrue(mock_routes.called) + self.assertFalse(mock_default_gateway.called) + + @mock.patch('cloudinit.osys.base.get_osutils') + @mock.patch('cloudinit.osys.windows.network._check_url') + @mock.patch('cloudinit.osys.windows.network.Network.routes') + @mock.patch('cloudinit.osys.windows.network.Network.default_gateway') + def test_set_metadata_ip_route_route_missing_url_accessible( + self, mock_default_gateway, mock_routes, + mock_check_url, mock_osutils): + + mock_routes.return_value = () + mock_check_url.return_value = True + + self._network.set_metadata_ip_route("http://169.254.169.254") + + self.assertTrue(mock_routes.called) + self.assertFalse(mock_default_gateway.called) + self.assertTrue(mock_osutils.called) + + @mock.patch('cloudinit.osys.base.get_osutils') + @mock.patch('cloudinit.osys.windows.network._check_url') + @mock.patch('cloudinit.osys.windows.network.Network.routes') + @mock.patch('cloudinit.osys.windows.network.Network.default_gateway') + @mock.patch('cloudinit.osys.windows.network.Route') + def test_set_metadata_ip_route_no_default_gateway( + self, mock_Route, mock_default_gateway, + mock_routes, mock_check_url, mock_osutils): + + mock_routes.return_value = () + mock_check_url.return_value = False + mock_default_gateway.return_value = None + + self._network.set_metadata_ip_route("http://169.254.169.254") + + self.assertTrue(mock_osutils.called) + self.assertTrue(mock_routes.called) + self.assertTrue(mock_default_gateway.called) + self.assertFalse(mock_Route.called) + + @mock.patch('cloudinit.osys.base.get_osutils') + @mock.patch('cloudinit.osys.windows.network._check_url') + @mock.patch('cloudinit.osys.windows.network.Network.routes') + @mock.patch('cloudinit.osys.windows.network.Network.default_gateway') + @mock.patch('cloudinit.osys.windows.network.Route') + def test_set_metadata_ip_route( + self, mock_Route, mock_default_gateway, + mock_routes, mock_check_url, mock_osutils): + + mock_routes.return_value = () + mock_check_url.return_value = False + + with LogSnatcher('cloudinit.osys.windows.network') as snatcher: + self._network.set_metadata_ip_route("http://169.254.169.254") + + expected = ['Setting gateway for host: 169.254.169.254'] + self.assertEqual(expected, snatcher.output) + self.assertTrue(mock_routes.called) + self.assertTrue(mock_default_gateway.called) + mock_Route.assert_called_once_with( + destination="169.254.169.254", + netmask="255.255.255.255", + gateway=mock_default_gateway.return_value.gateway, + interface=None, metric=None) + mock_Route.add.assert_called_once_with(mock_Route.return_value) + self.assertTrue(mock_osutils.called) diff --git a/cloudinit/tests/sources/__init__.py b/cloudinit/tests/sources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cloudinit/tests/sources/openstack/__init__.py b/cloudinit/tests/sources/openstack/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cloudinit/tests/sources/openstack/test_base.py b/cloudinit/tests/sources/openstack/test_base.py new file mode 100644 index 00000000..b288f914 --- /dev/null +++ b/cloudinit/tests/sources/openstack/test_base.py @@ -0,0 +1,176 @@ +# Copyright 2015 Canonical Ltd. +# This file is part of cloud-init. See LICENCE file for license information. +# +# vi: ts=4 expandtab + +from cloudinit.sources import base as base_source +from cloudinit.sources.openstack import base +from cloudinit import test +from cloudinit.tests.util import LogSnatcher +from cloudinit.tests.util import mock + + +class TestBaseOpenStackSource(test.TestCase): + + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '__abstractmethods__', new=()) + def setUp(self): + self._source = base.BaseOpenStackSource() + super(TestBaseOpenStackSource, self).setUp() + + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_available_versions') + def _test_working_version(self, mock_available_versions, + versions, expected_version): + + mock_available_versions.return_value = versions + + with LogSnatcher('cloudinit.sources.openstack.base') as snatcher: + version = self._source._working_version() + + msg = "Selected version '{0}' from {1}" + expected_logging = [msg.format(expected_version, versions)] + self.assertEqual(expected_logging, snatcher.output) + self.assertEqual(expected_version, version) + + def test_working_version_latest(self): + self._test_working_version(versions=(), expected_version='latest') + + def test_working_version_other_version(self): + versions = ( + base._OS_FOLSOM, + base._OS_GRIZZLY, + base._OS_HAVANA, + ) + self._test_working_version(versions=versions, + expected_version=base._OS_HAVANA) + + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_get_meta_data') + def test_metadata_capabilities(self, mock_get_meta_data): + mock_get_meta_data.return_value = { + 'uuid': mock.sentinel.id, + 'hostname': mock.sentinel.hostname, + 'public_keys': {'key-one': 'key-one', 'key-two': 'key-two'}, + } + + instance_id = self._source.instance_id() + hostname = self._source.host_name() + public_keys = self._source.public_keys() + + self.assertEqual(mock.sentinel.id, instance_id) + self.assertEqual(mock.sentinel.hostname, hostname) + self.assertEqual(["key-one", "key-two"], sorted(public_keys)) + + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_get_meta_data') + def test_no_public_keys(self, mock_get_meta_data): + mock_get_meta_data.return_value = {'public_keys': []} + public_keys = self._source.public_keys() + self.assertEqual([], public_keys) + + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_get_meta_data') + def test_admin_password(self, mock_get_meta_data): + mock_get_meta_data.return_value = { + 'meta': {base._ADMIN_PASSWORD: mock.sentinel.password} + } + password = self._source.admin_password() + self.assertEqual(mock.sentinel.password, password) + + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_path_join') + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_get_cache_data') + def test_get_content(self, mock_get_cache_data, mock_path_join): + result = self._source._get_content(mock.sentinel.name) + + mock_path_join.assert_called_once_with( + 'openstack', 'content', mock.sentinel.name) + mock_get_cache_data.assert_called_once_with( + mock_path_join.return_value) + self.assertEqual(mock_get_cache_data.return_value, result) + + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_path_join') + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_get_cache_data') + def test_user_data(self, mock_get_cache_data, mock_path_join): + result = self._source.user_data() + + mock_path_join.assert_called_once_with( + 'openstack', self._source._version, 'user_data') + mock_get_cache_data.assert_called_once_with( + mock_path_join.return_value) + self.assertEqual(mock_get_cache_data.return_value.buffer, result) + + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_path_join') + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_get_cache_data') + def test_get_metadata(self, mock_get_cache_data, mock_path_join): + mock_get_cache_data.return_value = base_source.APIResponse(b"{}") + + result = self._source._get_meta_data() + + mock_path_join.assert_called_once_with( + 'openstack', self._source._version, 'meta_data.json') + mock_get_cache_data.assert_called_once_with( + mock_path_join.return_value) + self.assertEqual({}, result) + + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_path_join') + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_get_cache_data') + def test_vendor_data(self, mock_get_cache_data, mock_path_join): + result = self._source.vendor_data() + + mock_path_join.assert_called_once_with( + 'openstack', self._source._version, 'vendor_data.json') + mock_get_cache_data.assert_called_once_with( + mock_path_join.return_value) + self.assertEqual(mock_get_cache_data.return_value.buffer, result) + + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_working_version') + def test_load(self, mock_working_version): + self._source.load() + + self.assertTrue(mock_working_version.called) + self.assertEqual(mock_working_version.return_value, + self._source._version) + + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_get_meta_data') + def test_network_config_no_config(self, mock_get_metadata): + mock_get_metadata.return_value = {} + + self.assertIsNone(self._source.network_config()) + + mock_get_metadata.return_value = {1: 2} + + self.assertIsNone(self._source.network_config()) + + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_get_meta_data') + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_get_content') + def test_network_config(self, mock_get_content, mock_get_metadata): + mock_get_metadata.return_value = { + "network_config": {base._PAYLOAD_KEY: "content_path"} + } + + result = self._source.network_config() + + mock_get_content.assert_called_once_with("content_path") + self.assertEqual(str(mock_get_content.return_value), result) + + @mock.patch('cloudinit.sources.openstack.base.BaseOpenStackSource.' + '_get_data') + def test_get_cache_data(self, mock_get_data): + mock_get_data.return_value = b'test' + result = self._source._get_cache_data(mock.sentinel.path) + + mock_get_data.assert_called_once_with(mock.sentinel.path) + self.assertEqual(b'test', result) diff --git a/cloudinit/tests/sources/openstack/test_httpopenstack.py b/cloudinit/tests/sources/openstack/test_httpopenstack.py new file mode 100644 index 00000000..f830a3b2 --- /dev/null +++ b/cloudinit/tests/sources/openstack/test_httpopenstack.py @@ -0,0 +1,251 @@ +# Copyright 2015 Canonical Ltd. +# This file is part of cloud-init. See LICENCE file for license information. +# +# vi: ts=4 expandtab + +import textwrap + +from six.moves import http_client + +from cloudinit import exceptions +from cloudinit.sources import base +from cloudinit.sources.openstack import httpopenstack +from cloudinit import test +from cloudinit.tests.util import LogSnatcher +from cloudinit.tests.util import mock +from cloudinit import url_helper + + +class TestHttpOpenStackSource(test.TestCase): + + def setUp(self): + self._source = httpopenstack.HttpOpenStackSource() + super(TestHttpOpenStackSource, self).setUp() + + @mock.patch.object(httpopenstack, 'IS_WINDOWS', new=False) + @mock.patch('cloudinit.osys.windows.network.Network.' + 'set_metadata_ip_route') + def test__enable_metadata_access_not_nt(self, mock_set_metadata_ip_route): + self._source._enable_metadata_access(mock.sentinel.metadata_url) + + self.assertFalse(mock_set_metadata_ip_route.called) + + @mock.patch.object(httpopenstack, 'IS_WINDOWS', new=True) + @mock.patch('cloudinit.osys.base.get_osutils') + def test__enable_metadata_access_nt(self, mock_get_osutils): + + self._source._enable_metadata_access(mock.sentinel.metadata_url) + + mock_get_osutils.assert_called_once_with() + osutils = mock_get_osutils.return_value + osutils.network.set_metadata_ip_route.assert_called_once_with( + mock.sentinel.metadata_url) + + def test__path_join(self): + calls = [ + (('path', 'a', 'b'), 'path/a/b'), + (('path', ), 'path'), + (('path/', 'b/'), 'path/b/'), + ] + for arguments, expected in calls: + path = self._source._path_join(*arguments) + self.assertEqual(expected, path) + + @mock.patch('cloudinit.sources.openstack.httpopenstack.' + 'HttpOpenStackSource._get_cache_data') + def test__available_versions(self, mock_get_cache_data): + mock_get_cache_data.return_value = textwrap.dedent(""" + 2013-02-02 + 2014-04-04 + + 2015-05-05 + + latest""") + versions = self._source._available_versions() + expected = ['2013-02-02', '2014-04-04', '2015-05-05', 'latest'] + mock_get_cache_data.assert_called_once_with("openstack") + self.assertEqual(expected, versions) + + @mock.patch('cloudinit.sources.openstack.httpopenstack.' + 'HttpOpenStackSource._get_cache_data') + def _test__available_versions_invalid_versions( + self, version, mock_get_cache_data): + + mock_get_cache_data.return_value = version + + exc = self.assertRaises(exceptions.CloudInitError, + self._source._available_versions) + expected = 'Invalid API version {!r}'.format(version) + self.assertEqual(expected, str(exc)) + + def test__available_versions_invalid_versions(self): + versions = ['2013-no-worky', '2012', '2012-02', + 'lates', '20004-111-222', '2004-11-11111', + ' 2004-11-20'] + for version in versions: + self._test__available_versions_invalid_versions(version) + + @mock.patch('cloudinit.sources.openstack.httpopenstack.' + 'HttpOpenStackSource._get_cache_data') + def test__available_versions_no_version_found(self, mock_get_cache_data): + mock_get_cache_data.return_value = '' + + exc = self.assertRaises(exceptions.CloudInitError, + self._source._available_versions) + self.assertEqual('No metadata versions were found.', str(exc)) + + @mock.patch('cloudinit.sources.openstack.httpopenstack.' + 'HttpOpenStackSource._get_cache_data') + def _test_is_password_set(self, mock_get_cache_data, data, expected): + mock_get_cache_data.return_value = data + + result = self._source.is_password_set + self.assertEqual(expected, result) + mock_get_cache_data.assert_called_once_with( + self._source._password_path) + + def test_is_password_set(self): + empty_data = base.APIResponse(b"") + non_empty_data = base.APIResponse(b"password") + self._test_is_password_set(data=empty_data, expected=False) + self._test_is_password_set(data=non_empty_data, expected=True) + + def _test_can_update_password(self, version, expected): + with mock.patch.object(self._source, '_version', new=version): + self.assertEqual(self._source.can_update_password(), expected) + + def test_can_update_password(self): + self._test_can_update_password('2012-08-10', expected=False) + self._test_can_update_password('2012-11-10', expected=False) + self._test_can_update_password('2013-04-04', expected=True) + self._test_can_update_password('2014-04-04', expected=True) + self._test_can_update_password('latest', expected=False) + + @mock.patch('cloudinit.sources.openstack.httpopenstack.' + 'HttpOpenStackSource._path_join') + @mock.patch('cloudinit.url_helper.read_url') + def test__post_data(self, mock_read_url, mock_path_join): + with LogSnatcher('cloudinit.sources.openstack.' + 'httpopenstack') as snatcher: + self._source._post_data(mock.sentinel.path, + mock.sentinel.data) + + expected_logging = [ + 'Posting metadata to: %s' % mock_path_join.return_value + ] + self.assertEqual(expected_logging, snatcher.output) + mock_path_join.assert_called_once_with( + self._source._config['metadata_url'], mock.sentinel.path) + mock_read_url.assert_called_once_with( + mock_path_join.return_value, data=mock.sentinel.data, + retries=self._source._config['retries'], + timeout=self._source._config['timeout']) + + @mock.patch('cloudinit.sources.openstack.httpopenstack.' + 'HttpOpenStackSource._post_data') + def test_post_password(self, mock_post_data): + self.assertTrue(self._source.post_password(mock.sentinel.password)) + mock_post_data.assert_called_once_with( + self._source._password_path, mock.sentinel.password) + + @mock.patch('cloudinit.sources.openstack.httpopenstack.' + 'HttpOpenStackSource._post_data') + def test_post_password_already_posted(self, mock_post_data): + exc = url_helper.UrlError(None) + exc.status_code = http_client.CONFLICT + mock_post_data.side_effect = exc + + self.assertFalse(self._source.post_password(mock.sentinel.password)) + mock_post_data.assert_called_once_with( + self._source._password_path, mock.sentinel.password) + + @mock.patch('cloudinit.sources.openstack.httpopenstack.' + 'HttpOpenStackSource._post_data') + def test_post_password_other_error(self, mock_post_data): + exc = url_helper.UrlError(None) + exc.status_code = http_client.NOT_FOUND + mock_post_data.side_effect = exc + + self.assertRaises(url_helper.UrlError, + self._source.post_password, + mock.sentinel.password) + mock_post_data.assert_called_once_with( + self._source._password_path, mock.sentinel.password) + + @mock.patch('cloudinit.sources.openstack.base.' + 'BaseOpenStackSource.load') + @mock.patch('cloudinit.sources.openstack.httpopenstack.' + 'HttpOpenStackSource._get_meta_data') + @mock.patch('cloudinit.sources.openstack.httpopenstack.' + 'HttpOpenStackSource._enable_metadata_access') + def _test_load(self, mock_enable_metadata_access, + mock_get_metadata, mock_load, expected, + expected_logging, metadata_side_effect=None): + + mock_get_metadata.side_effect = metadata_side_effect + with LogSnatcher('cloudinit.sources.openstack.' + 'httpopenstack') as snatcher: + response = self._source.load() + + self.assertEqual(expected, response) + mock_enable_metadata_access.assert_called_once_with( + self._source._config['metadata_url']) + mock_load.assert_called_once_with() + mock_get_metadata.assert_called_once_with() + self.assertEqual(expected_logging, snatcher.output) + + def test_load_works(self): + self._test_load(expected=True, expected_logging=[]) + + def test_load_fails(self): + expected_logging = [ + 'Metadata not found at URL %r' + % self._source._config['metadata_url'] + ] + self._test_load(expected=False, + expected_logging=expected_logging, + metadata_side_effect=ValueError) + + @mock.patch('cloudinit.sources.openstack.httpopenstack.' + 'HttpOpenStackSource._path_join') + @mock.patch('cloudinit.url_helper.wait_any_url') + def test__get_data_inaccessible_metadata(self, mock_wait_any_url, + mock_path_join): + + mock_wait_any_url.return_value = None + mock_path_join.return_value = mock.sentinel.path_join + msg = "Metadata for url {0} was not accessible in due time" + expected = msg.format(mock.sentinel.path_join) + expected_logging = [ + 'Getting metadata from: %s' % mock.sentinel.path_join + ] + with LogSnatcher('cloudinit.sources.openstack.' + 'httpopenstack') as snatcher: + exc = self.assertRaises(exceptions.CloudInitError, + self._source._get_data, 'test') + + self.assertEqual(expected, str(exc)) + self.assertEqual(expected_logging, snatcher.output) + + @mock.patch('cloudinit.sources.openstack.httpopenstack.' + 'HttpOpenStackSource._path_join') + @mock.patch('cloudinit.url_helper.wait_any_url') + def test__get_data(self, mock_wait_any_url, mock_path_join): + mock_response = mock.Mock() + response = b"test" + mock_response.contents = response + mock_response.encoding = 'utf-8' + + mock_wait_any_url.return_value = (None, mock_response) + mock_path_join.return_value = mock.sentinel.path_join + expected_logging = [ + 'Getting metadata from: %s' % mock.sentinel.path_join + ] + with LogSnatcher('cloudinit.sources.openstack.' + 'httpopenstack') as snatcher: + result = self._source._get_data('test') + + self.assertEqual(expected_logging, snatcher.output) + self.assertIsInstance(result, base.APIResponse) + self.assertEqual('test', str(result)) + self.assertEqual(b'test', result.buffer) diff --git a/cloudinit/tests/util.py b/cloudinit/tests/util.py index 3b73f972..a34c6478 100644 --- a/cloudinit/tests/util.py +++ b/cloudinit/tests/util.py @@ -1,4 +1,62 @@ +# Copyright 2015 Canonical Ltd. +# This file is part of cloud-init. See LICENCE file for license information. +# +# vi: ts=4 expandtab + +import logging + try: from unittest import mock except ImportError: import mock # noqa + + +# This is similar with unittest.TestCase.assertLogs from Python 3.4. +class SnatchHandler(logging.Handler): + def __init__(self, *args, **kwargs): + super(SnatchHandler, self).__init__(*args, **kwargs) + self.output = [] + + def emit(self, record): + msg = self.format(record) + self.output.append(msg) + + +class LogSnatcher(object): + """A context manager to capture emitted logged messages. + + The class can be used as following:: + + with LogSnatcher('plugins.windows.createuser') as snatcher: + LOG.info("doing stuff") + LOG.info("doing stuff %s", 1) + LOG.warn("doing other stuff") + ... + self.assertEqual(snatcher.output, + ['INFO:unknown:doing stuff', + 'INFO:unknown:doing stuff 1', + 'WARN:unknown:doing other stuff']) + """ + + @property + def output(self): + """Get the output of this Snatcher. + + The output is a list of log messages, already formatted. + """ + return self._snatch_handler.output + + def __init__(self, logger_name): + self._logger_name = logger_name + self._snatch_handler = SnatchHandler() + self._logger = logging.getLogger(self._logger_name) + self._previous_level = self._logger.getEffectiveLevel() + + def __enter__(self): + self._logger.setLevel(logging.DEBUG) + self._logger.handlers.append(self._snatch_handler) + return self + + def __exit__(self, *args): + self._logger.handlers.remove(self._snatch_handler) + self._logger.setLevel(self._previous_level) diff --git a/cloudinit/url_helper.py b/cloudinit/url_helper.py index 011312af..7f204bb5 100644 --- a/cloudinit/url_helper.py +++ b/cloudinit/url_helper.py @@ -24,6 +24,7 @@ from six.moves.urllib.parse import urlparse # noqa from six.moves.urllib.parse import urlunparse # noqa from six.moves.http_client import BAD_REQUEST as _BAD_REQUEST +from six.moves.http_client import CONFLICT # noqa from six.moves.http_client import MULTIPLE_CHOICES as _MULTIPLE_CHOICES from six.moves.http_client import OK