diff --git a/cloudbaseinit/exception.py b/cloudbaseinit/exception.py index 64b14283..f954b451 100644 --- a/cloudbaseinit/exception.py +++ b/cloudbaseinit/exception.py @@ -41,6 +41,13 @@ class MetadataNotFoundException(CloudbaseInitException): pass +class MetadataEndpointException(CloudbaseInitException): + + """Exception thrown in case the metadata is unresponsive or errors out.""" + + pass + + class CertificateVerifyFailed(ServiceException): """The received certificate is not valid. diff --git a/cloudbaseinit/metadata/services/packet.py b/cloudbaseinit/metadata/services/packet.py index 1e3c3cbd..9b515daf 100644 --- a/cloudbaseinit/metadata/services/packet.py +++ b/cloudbaseinit/metadata/services/packet.py @@ -14,10 +14,13 @@ """Metadata Service for Packet.""" import json +import requests from cloudbaseinit import conf as cloudbaseinit_conf +from cloudbaseinit import exception from cloudbaseinit.metadata.services import base from oslo_log import log as oslo_logging +from six.moves.urllib import error CONF = cloudbaseinit_conf.CONF LOG = oslo_logging.getLogger(__name__) @@ -83,3 +86,49 @@ class PacketService(base.BaseHTTPMetadataService): def get_user_data(self): """Get the available user data for the current instance.""" return self._get_cache_data("userdata") + + def _get_phone_home_url(self): + return self._get_meta_data().get("phone_home_url") + + def get_user_pwd_encryption_key(self): + phone_home_url = self._get_phone_home_url() + key_url = requests.compat.urljoin('%s/' % phone_home_url, "key") + return self._get_cache_data(key_url, decode=True) + + @property + def can_post_password(self): + """The Packet metadata service supports posting the password.""" + return True + + def post_password(self, enc_password_b64): + phone_home_url = self._get_phone_home_url() + LOG.info("Posting password to: %s", phone_home_url) + try: + action = lambda: self._http_request( + url=phone_home_url, + data=json.dumps({'password': enc_password_b64.decode()})) + return self._exec_with_retry(action) + except error.HTTPError as exc: + LOG.exception(exc) + raise exception.MetadataEndpointException( + "Failed to post password to the metadata service") + + def provisioning_completed(self): + """Signal to Packet that the instance is ready. + + To complete the provisioning, on the first boot after installation + make a GET request to CONF.packet.metadata_url, which will return a + JSON object which contains phone_home_url entry. + Make a POST request to phone_home_url with no body (important!) + and this will complete the installation process. + """ + phone_home_url = self._get_phone_home_url() + LOG.info("Calling home to: %s", phone_home_url) + try: + action = lambda: self._http_request(url=phone_home_url, + method="post") + return self._exec_with_retry(action) + except error.HTTPError as exc: + LOG.exception(exc) + raise exception.MetadataEndpointException( + "Failed to call home to the metadata service") diff --git a/cloudbaseinit/tests/metadata/services/test_packet.py b/cloudbaseinit/tests/metadata/services/test_packet.py index c93c4451..da135401 100644 --- a/cloudbaseinit/tests/metadata/services/test_packet.py +++ b/cloudbaseinit/tests/metadata/services/test_packet.py @@ -20,7 +20,10 @@ try: except ImportError: import mock +from six.moves.urllib import error + from cloudbaseinit import conf as cloudbaseinit_conf +from cloudbaseinit import exception from cloudbaseinit.tests import testutils @@ -121,3 +124,106 @@ class PacketServiceTest(unittest.TestCase): response = self._packet_service.get_user_data() mock_get_cache_data.assert_called_once_with("userdata") self.assertEqual(mock_get_cache_data.return_value, response) + + @mock.patch(MODULE_PATH + + ".PacketService._get_meta_data") + def test_get_phone_home_url(self, mock_get_meta_data): + fake_phone_url = 'fake_phone_url' + mock_get_meta_data.return_value = { + "phone_home_url": fake_phone_url + } + response = self._packet_service._get_phone_home_url() + + self.assertEqual(response, fake_phone_url) + + def test_can_post_password(self): + self.assertEqual(self._packet_service.can_post_password, + True) + + @mock.patch(MODULE_PATH + + ".PacketService._get_phone_home_url") + @mock.patch(MODULE_PATH + + ".PacketService._get_cache_data") + def test_get_user_pwd_encryption_key(self, mock_get_cache_data, + mock_get_phone_url): + fake_phone_url = 'fake_phone_url' + user_pwd_encryption_key = 'fake_key' + + mock_get_cache_data.return_value = user_pwd_encryption_key + mock_get_phone_url.return_value = fake_phone_url + + response = self._packet_service.get_user_pwd_encryption_key() + mock_get_phone_url.assert_called_once() + mock_get_cache_data.assert_called_once_with( + "%s/%s" % (fake_phone_url, 'key'), decode=True) + + self.assertEqual(response, user_pwd_encryption_key) + + @mock.patch('time.sleep') + @mock.patch(MODULE_PATH + + ".PacketService._get_phone_home_url") + @mock.patch(MODULE_PATH + + ".PacketService._http_request") + def _test_post_password(self, mock_http_request, + mock_get_phone_url, mock_sleep, fail=False): + fake_phone_url = 'fake_phone_url' + fake_response = 'fake_response' + fake_encoded_password = b'fake_password' + + if fail: + mock_http_request.side_effect = ( + error.HTTPError(401, "invalid", {}, 0, 0)) + with self.assertRaises(exception.MetadataEndpointException): + self._packet_service.post_password(fake_encoded_password) + else: + mock_http_request.return_value = fake_response + mock_get_phone_url.return_value = fake_phone_url + + response = self._packet_service.post_password( + fake_encoded_password) + mock_get_phone_url.assert_called_once() + mock_http_request.assert_called_once_with( + data='{"password": "fake_password"}', + url=fake_phone_url) + + self.assertEqual(response, fake_response) + + def test_post_password(self): + self._test_post_password() + + def test_post_password_with_failure(self): + self._test_post_password(fail=True) + + @mock.patch('time.sleep') + @mock.patch(MODULE_PATH + + ".PacketService._get_phone_home_url") + @mock.patch(MODULE_PATH + + ".PacketService._http_request") + def _test_provisioning_completed(self, mock_http_request, + mock_get_phone_url, mock_sleep, + fail=False): + fake_phone_url = 'fake_phone_url' + fake_response = 'fake_response' + + if fail: + mock_http_request.side_effect = ( + error.HTTPError(401, "invalid", {}, 0, 0)) + with self.assertRaises(exception.MetadataEndpointException): + self._packet_service.provisioning_completed() + else: + mock_http_request.return_value = fake_response + mock_get_phone_url.return_value = fake_phone_url + + response = self._packet_service.provisioning_completed() + mock_get_phone_url.assert_called_once() + mock_http_request.assert_called_once_with( + url=fake_phone_url, + method="post") + + self.assertEqual(response, fake_response) + + def test_provisioning_completed(self): + self._test_provisioning_completed() + + def test_provisioning_completed_with_failure(self): + self._test_provisioning_completed(fail=True) diff --git a/doc/source/services.rst b/doc/source/services.rst index d5935110..0a727203 100644 --- a/doc/source/services.rst +++ b/doc/source/services.rst @@ -338,7 +338,9 @@ Capabilities: * instance id * hostname * public keys + * post admin user password (only once) * user data + * call home on successful provision Config options for `packet` section: