packet: add phone_home and post_password support

Add support for the Packet features:

  * phone home - after provisioning is ready, an empty HTTP POST request
  is performed to the phone_home_url
  * post user password - a random password is generated for the default
  user, the password gets encrypted using the encryption public SSH key
  and posted to the phone_home_url

Change-Id: Ib3e405b6b5bb4ca65d047c756513b9d6f87cf6de
This commit is contained in:
Adrian Vladu 2020-04-22 01:03:07 +03:00
parent 39a73ea9e6
commit 107bdfaceb
4 changed files with 164 additions and 0 deletions

View File

@ -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.

View File

@ -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")

View File

@ -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)

View File

@ -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: