diff --git a/cloudbaseinit/conf/cloudstack.py b/cloudbaseinit/conf/cloudstack.py index fc20e94f..2c14ba70 100644 --- a/cloudbaseinit/conf/cloudstack.py +++ b/cloudbaseinit/conf/cloudstack.py @@ -27,7 +27,7 @@ def __init__(self, config): super(CloudStackOptions, self).__init__(config, group="cloudstack") self._options = [ cfg.StrOpt( - "metadata_base_url", default="http://10.1.1.1/", + "metadata_base_url", default="http://data-server/", help="The base URL where the service looks for metadata", deprecated_name="cloudstack_metadata_ip", deprecated_group="DEFAULT"), @@ -46,6 +46,10 @@ def __init__(self, config): cfg.BoolOpt( "add_metadata_private_ip_route", default=False, help="Add a route for the metadata ip address to the gateway"), + cfg.StrOpt( + "disk_label", default="config-2", + help="Disk label of ConfigDrive" + ) ] def register(self): diff --git a/cloudbaseinit/conf/default.py b/cloudbaseinit/conf/default.py index 61909601..5977e537 100644 --- a/cloudbaseinit/conf/default.py +++ b/cloudbaseinit/conf/default.py @@ -161,6 +161,7 @@ def __init__(self, config): 'metadata_services', default=[ 'cloudbaseinit.metadata.services.httpservice.HttpService', + 'cloudbaseinit.metadata.services.cloudstack.ConfigDrive', 'cloudbaseinit.metadata.services' '.configdrive.ConfigDriveService', 'cloudbaseinit.metadata.services.ec2service.EC2Service', diff --git a/cloudbaseinit/metadata/services/base.py b/cloudbaseinit/metadata/services/base.py index 4e8b64cc..ca70eca8 100644 --- a/cloudbaseinit/metadata/services/base.py +++ b/cloudbaseinit/metadata/services/base.py @@ -142,6 +142,9 @@ def get_admin_username(self): def get_admin_password(self): pass + def confirm_admin_password(self, password): + """Mark the admin password as applied (no-op by default).""" + @property def can_post_password(self): return False diff --git a/cloudbaseinit/metadata/services/cloudstack.py b/cloudbaseinit/metadata/services/cloudstack.py index 7418e369..278ac87e 100644 --- a/cloudbaseinit/metadata/services/cloudstack.py +++ b/cloudbaseinit/metadata/services/cloudstack.py @@ -13,6 +13,7 @@ # under the License. import contextlib +import hashlib import http.client import posixpath import urllib @@ -21,6 +22,8 @@ from cloudbaseinit import conf as cloudbaseinit_conf from cloudbaseinit.metadata.services import base +from cloudbaseinit.metadata.services import baseconfigdrive +from cloudbaseinit.metadata.services import baseopenstackservice from cloudbaseinit.osutils import factory as osutils_factory from cloudbaseinit.utils import encoding from cloudbaseinit.utils import network @@ -33,9 +36,9 @@ TIMEOUT = 10 -class CloudStack(base.BaseHTTPMetadataService): +class DataServer(base.BaseHTTPMetadataService): - """Metadata service for Apache CloudStack. + """Metadata service based on DataServer for Apache CloudStack. Apache CloudStack is an open source software designed to deploy and manage large networks of virtual machines, as a highly available, @@ -44,7 +47,7 @@ class CloudStack(base.BaseHTTPMetadataService): """ def __init__(self): - super(CloudStack, self).__init__( + super(DataServer, self).__init__( # Note(alexcoman): The base url used by the current metadata # service will be updated later by the `_test_api` method. base_url=None, @@ -83,7 +86,7 @@ def _test_api(self, metadata_url): def load(self): """Obtain all the required information.""" - super(CloudStack, self).load() + super(DataServer, self).load() if CONF.cloudstack.add_metadata_private_ip_route: network.check_metadata_ip_route(CONF.cloudstack.metadata_base_url) @@ -240,16 +243,14 @@ def _delete_password(self): def get_admin_password(self): """Get the admin password from the Password Server. - .. note: - The password is deleted from the Password Server after the first - call of this method. - Another request for password will work only if the password was - changed and sent to the Password Server. + The password is deleted only after confirm_admin_password(). """ - password = self._get_password() + return self._get_password() + + def confirm_admin_password(self, password): + """Delete the password from the Password Server.""" if password: self._delete_password() - return password @property def can_update_password(self): @@ -259,3 +260,74 @@ def can_update_password(self): def is_password_changed(self): """Check if a new password exists in the Password Server.""" return bool(self._get_password()) + + +# For backward compatibility, CloudStack Class is an alias to DataServer Class +CloudStack = DataServer + + +class ConfigDrive(baseconfigdrive.BaseConfigDriveService, + baseopenstackservice.BaseOpenStackService): + + def __init__(self): + # CloudStack marker rejects plain OpenStack config drives early. + super(ConfigDrive, self).__init__( + CONF.cloudstack.disk_label, + 'cloudstack\\metadata\\instance-id.txt') + + def _preprocess_options(self): + self._searched_types = set(['iso']) + self._searched_locations = set(['cdrom']) + + def get_instance_id(self): + return self._get_cache_data( + posixpath.join('cloudstack', 'metadata', 'instance-id.txt'), + decode=True).strip() + + @staticmethod + def _password_hash(password): + return hashlib.sha256(password.encode('utf-8')).hexdigest() + + def _persist_password_hash(self, password): + osutils = osutils_factory.get_os_utils() + osutils.set_config_value( + 'PasswordHash', self._password_hash(password), + self.get_instance_id()) + + def _get_password(self): + path = posixpath.normpath( + posixpath.join('cloudstack', 'password', 'vm_password.txt')) + try: + password = self._get_cache_data(path, decode=True).strip() + except base.NotExistingMetadataException: + LOG.info('No password file was found in ConfigDrive') + return None + + if not password or password == SAVED_PASSWORD: + LOG.info('No password available in ConfigDrive') + return None + + LOG.info('Password file was found in ConfigDrive') + return password + + def get_admin_password(self): + return self._get_password() + + def confirm_admin_password(self, password): + self._persist_password_hash(password) + + @property + def can_update_password(self): + return True + + def is_password_changed(self): + password = self._get_password() + if not password: + return False + osutils = osutils_factory.get_os_utils() + old_password_hash = osutils.get_config_value( + 'PasswordHash', self.get_instance_id()) + if old_password_hash != self._password_hash(password): + LOG.debug('New password is detected') + return True + return False diff --git a/cloudbaseinit/metadata/services/osconfigdrive/windows.py b/cloudbaseinit/metadata/services/osconfigdrive/windows.py index 3fa212dd..83b56140 100644 --- a/cloudbaseinit/metadata/services/osconfigdrive/windows.py +++ b/cloudbaseinit/metadata/services/osconfigdrive/windows.py @@ -61,7 +61,7 @@ def _meta_data_file_exists(self, drive, metadata_file): def _check_for_config_drive(self, drive, required_drive_label, metadata_file): label = self._osutils.get_volume_label(drive) - if label and label.lower() == required_drive_label and \ + if label and label.lower() == required_drive_label.lower() and \ self._meta_data_file_exists(drive, metadata_file): LOG.info('Config Drive found on %s', drive) return True diff --git a/cloudbaseinit/plugins/common/setuserpassword.py b/cloudbaseinit/plugins/common/setuserpassword.py index 7032d931..16cbfc4e 100644 --- a/cloudbaseinit/plugins/common/setuserpassword.py +++ b/cloudbaseinit/plugins/common/setuserpassword.py @@ -84,6 +84,14 @@ def _set_password(self, service, osutils, user_name, shared_data): CONF.user_password_length) osutils.set_user_password(user_name, password) + if injected: + service.confirm_admin_password(password) + elif service.can_update_password: + # Consume metadata password even when not injected, to avoid + # resetting the guest password on every boot. + metadata_password = service.get_admin_password() + if metadata_password: + service.confirm_admin_password(metadata_password) self._change_logon_behaviour(user_name, password_injected=injected) return password diff --git a/cloudbaseinit/tests/metadata/services/osconfigdrive/test_windows.py b/cloudbaseinit/tests/metadata/services/osconfigdrive/test_windows.py index f81e1804..cd767de6 100644 --- a/cloudbaseinit/tests/metadata/services/osconfigdrive/test_windows.py +++ b/cloudbaseinit/tests/metadata/services/osconfigdrive/test_windows.py @@ -88,6 +88,17 @@ def test_check_for_config_drive_exists(self): def test_check_for_config_drive_exists_upper_label(self): self._test_check_for_config_drive(label="CONFIG-2") + @mock.patch('os.path.exists') + def test_check_for_config_drive_upper_required_label(self, mock_exists): + drive = "C:\\" + self.osutils.get_volume_label.return_value = "config-2" + mock_exists.return_value = True + + response = self._config_manager._check_for_config_drive( + drive, "CONFIG-2", self._fake_metadata_file) + + self.assertTrue(response) + def test_check_for_config_drive_missing(self): self._test_check_for_config_drive(exists=False, fail=True) diff --git a/cloudbaseinit/tests/metadata/services/test_base.py b/cloudbaseinit/tests/metadata/services/test_base.py index 17a9457e..8b87b4ca 100644 --- a/cloudbaseinit/tests/metadata/services/test_base.py +++ b/cloudbaseinit/tests/metadata/services/test_base.py @@ -54,6 +54,9 @@ def test_can_update_password(self): def test_is_password_changed(self): self.assertFalse(self._service.is_password_changed()) + def test_confirm_admin_password(self): + self.assertIsNone(self._service.confirm_admin_password('s3cret')) + @mock.patch('cloudbaseinit.metadata.services.base.' 'BaseMetadataService.get_public_keys') def test_get_user_pwd_encryption_key(self, mock_get_public_keys): diff --git a/cloudbaseinit/tests/metadata/services/test_cloudstack.py b/cloudbaseinit/tests/metadata/services/test_cloudstack.py index 738ff0b6..c703e761 100644 --- a/cloudbaseinit/tests/metadata/services/test_cloudstack.py +++ b/cloudbaseinit/tests/metadata/services/test_cloudstack.py @@ -290,7 +290,7 @@ def test_get_admin_password(self, mock_get_password, mock_delete_password): self.assertEqual(mock.sentinel.password, password) self.assertEqual(1, mock_get_password.call_count) - self.assertEqual(1, mock_delete_password.call_count) + self.assertFalse(mock_delete_password.called) @mock.patch('cloudbaseinit.metadata.services.cloudstack.CloudStack.' '_delete_password') @@ -302,7 +302,19 @@ def test_get_admin_password_fail(self, mock_get_password, self.assertIsNone(self._service.get_admin_password()) self.assertEqual(1, mock_get_password.call_count) - self.assertEqual(0, mock_delete_password.call_count) + self.assertFalse(mock_delete_password.called) + + @mock.patch('cloudbaseinit.metadata.services.cloudstack.CloudStack.' + '_delete_password') + def test_confirm_admin_password(self, mock_delete_password): + self._service.confirm_admin_password(mock.sentinel.password) + mock_delete_password.assert_called_once_with() + + @mock.patch('cloudbaseinit.metadata.services.cloudstack.CloudStack.' + '_delete_password') + def test_confirm_admin_password_none(self, mock_delete_password): + self._service.confirm_admin_password(None) + self.assertFalse(mock_delete_password.called) def test_can_update_password(self): self.assertTrue(self._service.can_update_password) @@ -312,3 +324,132 @@ def test_can_update_password(self): def test_is_password_changed(self, mock_get_password): mock_get_password.return_value = True self.assertTrue(self._service.is_password_changed()) + + +class CloudStackConfigDriveTest(unittest.TestCase): + + def setUp(self): + self._service = cloudstack.ConfigDrive() + + def test_init_uses_cloudstack_marker(self): + self.assertEqual('config-2', self._service._drive_label) + self.assertEqual('cloudstack\\metadata\\instance-id.txt', + self._service._metadata_file) + + def test_preprocess_options(self): + self._service._preprocess_options() + self.assertEqual(set(['iso']), self._service._searched_types) + self.assertEqual(set(['cdrom']), self._service._searched_locations) + + @mock.patch('cloudbaseinit.metadata.services.cloudstack.ConfigDrive.' + '_get_cache_data') + def test_get_instance_id(self, mock_get_cache_data): + mock_get_cache_data.return_value = 'i-123\r\n' + + self.assertEqual('i-123', self._service.get_instance_id()) + mock_get_cache_data.assert_called_once_with( + 'cloudstack/metadata/instance-id.txt', decode=True) + + @mock.patch('cloudbaseinit.metadata.services.cloudstack.ConfigDrive.' + '_get_cache_data') + def test_get_password(self, mock_get_cache_data): + mock_get_cache_data.return_value = 's3cret\r\n' + + self.assertEqual('s3cret', self._service._get_password()) + mock_get_cache_data.assert_called_once_with( + 'cloudstack/password/vm_password.txt', decode=True) + + @mock.patch('cloudbaseinit.metadata.services.cloudstack.ConfigDrive.' + '_get_cache_data') + def test_get_password_sentinels(self, mock_get_cache_data): + for value in ('', ' ', cloudstack.SAVED_PASSWORD, + cloudstack.SAVED_PASSWORD + '\r\n'): + mock_get_cache_data.return_value = value + self.assertIsNone(self._service._get_password()) + + @mock.patch('cloudbaseinit.metadata.services.cloudstack.ConfigDrive.' + '_get_cache_data') + def test_get_password_missing(self, mock_get_cache_data): + mock_get_cache_data.side_effect = base.NotExistingMetadataException() + self.assertIsNone(self._service._get_password()) + + @mock.patch('cloudbaseinit.metadata.services.cloudstack.ConfigDrive.' + '_persist_password_hash') + @mock.patch('cloudbaseinit.metadata.services.cloudstack.ConfigDrive.' + '_get_password') + def test_get_admin_password(self, mock_get_password, mock_persist): + mock_get_password.return_value = 's3cret' + + self.assertEqual('s3cret', self._service.get_admin_password()) + self.assertFalse(mock_persist.called) + + @mock.patch('cloudbaseinit.metadata.services.cloudstack.ConfigDrive.' + '_persist_password_hash') + @mock.patch('cloudbaseinit.metadata.services.cloudstack.ConfigDrive.' + '_get_password') + def test_get_admin_password_none(self, mock_get_password, mock_persist): + mock_get_password.return_value = None + + self.assertIsNone(self._service.get_admin_password()) + self.assertFalse(mock_persist.called) + + @mock.patch('cloudbaseinit.metadata.services.cloudstack.ConfigDrive.' + '_persist_password_hash') + def test_confirm_admin_password(self, mock_persist): + self._service.confirm_admin_password('s3cret') + mock_persist.assert_called_once_with('s3cret') + + def test_can_update_password(self): + self.assertTrue(self._service.can_update_password) + + @mock.patch('cloudbaseinit.osutils.factory.get_os_utils') + @mock.patch('cloudbaseinit.metadata.services.cloudstack.ConfigDrive.' + 'get_instance_id') + @mock.patch('cloudbaseinit.metadata.services.cloudstack.ConfigDrive.' + '_get_password') + def test_is_password_changed(self, mock_get_password, mock_instance_id, + mock_get_os_utils): + mock_get_password.return_value = 's3cret' + mock_instance_id.return_value = 'i-1' + mock_osutils = mock_get_os_utils.return_value + mock_osutils.get_config_value.return_value = None + + self.assertTrue(self._service.is_password_changed()) + mock_osutils.get_config_value.assert_called_once_with( + 'PasswordHash', 'i-1') + self.assertFalse(mock_osutils.set_config_value.called) + + @mock.patch('cloudbaseinit.osutils.factory.get_os_utils') + @mock.patch('cloudbaseinit.metadata.services.cloudstack.ConfigDrive.' + 'get_instance_id') + @mock.patch('cloudbaseinit.metadata.services.cloudstack.ConfigDrive.' + '_get_password') + def test_is_password_changed_same_hash(self, mock_get_password, + mock_instance_id, + mock_get_os_utils): + password = 's3cret' + mock_get_password.return_value = password + mock_instance_id.return_value = 'i-1' + mock_osutils = mock_get_os_utils.return_value + mock_osutils.get_config_value.return_value = ( + self._service._password_hash(password)) + + self.assertFalse(self._service.is_password_changed()) + self.assertFalse(mock_osutils.set_config_value.called) + + @mock.patch('cloudbaseinit.metadata.services.cloudstack.ConfigDrive.' + '_get_password') + def test_is_password_changed_no_password(self, mock_get_password): + mock_get_password.return_value = None + self.assertFalse(self._service.is_password_changed()) + + @mock.patch('cloudbaseinit.osutils.factory.get_os_utils') + @mock.patch('cloudbaseinit.metadata.services.cloudstack.ConfigDrive.' + 'get_instance_id') + def test_persist_password_hash(self, mock_instance_id, mock_get_os_utils): + mock_instance_id.return_value = 'i-1' + mock_osutils = mock_get_os_utils.return_value + + self._service._persist_password_hash('s3cret') + mock_osutils.set_config_value.assert_called_once_with( + 'PasswordHash', self._service._password_hash('s3cret'), 'i-1') diff --git a/cloudbaseinit/tests/plugins/common/test_setuserpassword.py b/cloudbaseinit/tests/plugins/common/test_setuserpassword.py index 0b21abd1..86fcc8f5 100644 --- a/cloudbaseinit/tests/plugins/common/test_setuserpassword.py +++ b/cloudbaseinit/tests/plugins/common/test_setuserpassword.py @@ -142,7 +142,7 @@ def _test_set_password(self, mock_get_password, mock_change_logon_behaviour, password, can_update_password, is_password_changed, max_password_length=20, - injected=False): + injected=False, metadata_password=None): expected_password = password expected_logging = [] user = 'fake_user' @@ -157,6 +157,7 @@ def _test_set_password(self, mock_get_password, mock_osutils.generate_random_password.return_value = expected_password mock_service.can_update_password = can_update_password mock_service.is_password_changed.return_value = is_password_changed + mock_service.get_admin_password.return_value = metadata_password with testutils.ConfPatcher('user_password_length', max_password_length): @@ -179,6 +180,25 @@ def _test_set_password(self, mock_get_password, self.assertEqual(expected_password, response) self.assertEqual(expected_logging, snatcher.output) + if expected_password: + mock_osutils.set_user_password.assert_called_once_with( + user, expected_password) + if injected: + mock_service.confirm_admin_password.assert_called_once_with( + expected_password) + elif can_update_password: + mock_service.get_admin_password.assert_called_once_with() + if metadata_password: + mock_service.confirm_admin_password.assert_called_once_with( + metadata_password) + else: + self.assertFalse(mock_service.confirm_admin_password.called) + else: + self.assertFalse(mock_service.confirm_admin_password.called) + self.assertFalse(mock_service.get_admin_password.called) + else: + self.assertFalse(mock_osutils.set_user_password.called) + self.assertFalse(mock_service.confirm_admin_password.called) if password and can_update_password and is_password_changed: mock_change_logon_behaviour.assert_called_once_with( user, password_injected=injected) @@ -201,6 +221,15 @@ def test_set_password(self): self._test_set_password(password='Password', can_update_password=True, is_password_changed=False) + self._test_set_password(password='Password', + can_update_password=True, + is_password_changed=True, + injected=True) + self._test_set_password(password=None, + can_update_password=True, + is_password_changed=True, + injected=False, + metadata_password='s3cret') @mock.patch('cloudbaseinit.plugins.common.setuserpassword.' 'SetUserPasswordPlugin._set_password') diff --git a/doc/source/services.rst b/doc/source/services.rst index c76f1ad9..54c9990a 100644 --- a/doc/source/services.rst +++ b/doc/source/services.rst @@ -274,14 +274,15 @@ Config options for `default` section: .. note:: http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html -Apache CloudStack ------------------ +Apache CloudStack (DataServer) +------------------------------ -.. class:: cloudbaseinit.metadata.services.cloudstack.CloudStack +.. class:: cloudbaseinit.metadata.services.cloudstack.DataServer -Another web-based service which usually uses "10.1.1.1" or DHCP addresses for +Another web-based service which usually uses "data-server" DNS record or DHCP addresses for retrieving content. If no metadata can be found at the `metadata_base_url`, the service will look for the metadata at the DHCP server URL. +The legacy class name `CloudStack` remains available as an alias. Capabilities: @@ -289,14 +290,14 @@ Capabilities: * hostname * public keys * admin user password - * poll for, post, delete admin user password (each reboot) + * poll for admin user password updates (each reboot) * user data Config options for `cloudstack` section: - * metadata_base_url (string: "http://10.1.1.1/") + * metadata_base_url (string: "http://data-server/") * password_server_port (int: 8080) - * add_metadata_private_ip_route (bool: True) + * add_metadata_private_ip_route (bool: False) * https_allow_insecure (bool: False) * https_ca_bundle (string: None) @@ -306,11 +307,48 @@ Config options for `default` section: * retry_count_interval (integer: 4) .. note:: By design, this service can update the password anytime, so it will - cause the `setuserpassword` plugin to run at every boot and - by security concerns, the password is deleted right after retrieval - and no updating will occur until a new password is available on the - server. + cause the `setuserpassword` plugin to run at every boot. The password + is deleted from the Password Server only after it has been + successfully applied on the guest. + +.. _cloudstackconfigdrive: + +Apache CloudStack (ConfigDrive) +------------------------------- + +.. class:: cloudbaseinit.metadata.services.cloudstack.ConfigDrive + +CloudStack also provides meta-data and user-data via CD-ROM without requiring +network access. Only ISO/CD-ROM is supported. The volume label defaults to +``config-2``; discovery uses the marker +``cloudstack/metadata/instance-id.txt`` so OpenStack config drives are not +claimed. It is listed before OpenStack ConfigDrive in the default +``metadata_services`` order. + +This service is usually faster than the HTTP twin, as there is no timeout +waiting for the network to be up. + +Metadata version used: `latest`. +Capabilities: + + * instance id + * hostname + * public keys + * admin user password + * poll for admin user password updates (each reboot) + * user data + * static network configuration (via ``network_data.json``) + +Config options for `cloudstack` section: + + * disk_label (string: "config-2") + +.. note:: By design, this service can update the password anytime, so it will + cause the `setuserpassword` plugin to run at every boot. The password + is read from ``cloudstack/password/vm_password.txt``. A hash is stored + after it is applied, and no updating will occur until a new password + is available on the ConfigDrive. OpenNebula Service ------------------