Add support for custom metadata agent for HNV

Co-Authored-By: Claudiu Belu <cbelu@cloudbasesolutions.com>

Partially implements: blueprint hyperv-network-virtualization-support

Change-Id: I257a59d68b217ef773bcba14cfc78b07ff9ecd6c
This commit is contained in:
Alexandru Coman 2017-01-17 20:10:39 +02:00 committed by Claudiu Belu
parent 755211699a
commit d1da5a57af
3 changed files with 521 additions and 0 deletions

View File

@ -0,0 +1,234 @@
# Copyright 2017 Cloudbase Solutions SRL
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import hashlib
import hmac
import sys
import httplib2
from neutron.agent import rpc as agent_rpc
from neutron.common import config as common_config
from neutron.common import topics
from neutron.conf.agent import common as neutron_config
from neutron.conf.agent.metadata import config as meta_config
from neutron import wsgi
from neutron_lib import constants
from neutron_lib import context
from oslo_log import log as logging
from oslo_service import loopingcall
from oslo_utils import encodeutils
from oslo_utils import uuidutils
import six
import six.moves.urllib.parse as urlparse
import webob
from hyperv.common.i18n import _, _LW, _LE # noqa
from hyperv.neutron.agent import base as base_agent
from hyperv.neutron import config
from hyperv.neutron import neutron_client
CONF = config.CONF
LOG = logging.getLogger(__name__)
class _MetadataProxyHandler(object):
def __init__(self):
self._context = context.get_admin_context_without_session()
self._neutron_client = neutron_client.NeutronAPIClient()
@webob.dec.wsgify(RequestClass=webob.Request)
def __call__(self, req):
try:
return self._proxy_request(req)
except Exception:
LOG.exception(_LE("Unexpected error."))
msg = _('An unknown error has occurred. '
'Please try your request again.')
explanation = six.text_type(msg)
return webob.exc.HTTPInternalServerError(explanation=explanation)
def _get_port_profile_id(self, request):
"""Get the port profile ID from the request path."""
# Note(alexcoman): The port profile ID can be found as suffix
# in request path.
port_profile_id = request.path.split("/")[-1].strip()
if uuidutils.is_uuid_like(port_profile_id):
LOG.debug("The instance id was found in request path.")
return port_profile_id
LOG.debug("Failed to get the instance id from the request.")
return None
def _get_instance_id(self, port_profile_id):
tenant_id = None
instance_id = None
ports = self._neutron_client.get_network_ports()
for port in ports:
vif_details = port.get("binding:vif_details", {})
profile_id = vif_details.get("port_profile_id")
if profile_id and profile_id == port_profile_id:
tenant_id = port["tenant_id"]
# Note(alexcoman): The port["device_id"] is actually the
# Nova instance_id.
instance_id = port["device_id"]
break
else:
LOG.debug("Failed to get the port information.")
return tenant_id, instance_id
def _sign_instance_id(self, instance_id):
secret = CONF.metadata_proxy_shared_secret
secret = encodeutils.to_utf8(secret)
instance_id = encodeutils.to_utf8(instance_id)
return hmac.new(secret, instance_id, hashlib.sha256).hexdigest()
def _get_headers(self, port_profile_id):
tenant_id, instance_id = self._get_instance_id(port_profile_id)
if not (tenant_id and instance_id):
return None
headers = {
'X-Instance-ID': instance_id,
'X-Tenant-ID': tenant_id,
'X-Instance-ID-Signature': self._sign_instance_id(instance_id),
}
return headers
def _proxy_request(self, request):
LOG.debug("Request: %s", request)
port_profile_id = self._get_port_profile_id(request)
if not port_profile_id:
return webob.exc.HTTPNotFound()
headers = self._get_headers(port_profile_id)
if not headers:
return webob.exc.HTTPNotFound()
LOG.debug("Trying to proxy the request.")
nova_url = '%s:%s' % (CONF.nova_metadata_host,
CONF.nova_metadata_port)
allow_insecure = CONF.nova_metadata_insecure
http_request = httplib2.Http(
ca_certs=CONF.auth_ca_cert,
disable_ssl_certificate_validation=allow_insecure
)
if CONF.nova_client_cert and CONF.nova_client_priv_key:
http_request.add_certificate(
key=CONF.nova_client_priv_key,
cert=CONF.nova_client_cert,
domain=nova_url)
url = urlparse.urlunsplit((
CONF.nova_metadata_protocol, nova_url,
request.path_info, request.query_string, ''))
response, content = http_request.request(
url.replace(port_profile_id, ""),
method=request.method, headers=headers,
body=request.body)
LOG.debug("Response [%s]: %s", response.status, content)
if response.status == 200:
request.response.content_type = response['content-type']
request.response.body = content
return request.response
elif response.status == 403:
LOG.warning(_LW(
'The remote metadata server responded with Forbidden. This '
'response usually occurs when shared secrets do not match.'
))
return webob.exc.HTTPForbidden()
elif response.status == 400:
return webob.exc.HTTPBadRequest()
elif response.status == 404:
return webob.exc.HTTPNotFound()
elif response.status == 409:
return webob.exc.HTTPConflict()
elif response.status == 500:
message = _(
"Remote metadata server experienced an internal server error."
)
LOG.warning(message)
return webob.exc.HTTPInternalServerError(explanation=message)
else:
message = _("The HNV Metadata proxy experienced an internal"
" server error.")
LOG.warning(_('Unexpected response code: %s') % response.status)
return webob.exc.HTTPInternalServerError(explanation=message)
class MetadataProxy(base_agent.BaseAgent):
_AGENT_BINARY = 'neutron-hnv-metadata-proxy'
_AGENT_TYPE = constants.AGENT_TYPE_METADATA
_AGENT_TOPIC = 'N/A'
def __init__(self):
super(MetadataProxy, self).__init__()
self._set_agent_state()
self._setup_rpc()
def _setup_rpc(self):
"""Setup the RPC client for the current agent."""
self._state_rpc = agent_rpc.PluginReportStateAPI(topics.REPORTS)
report_interval = CONF.AGENT.report_interval
if report_interval:
heartbeat = loopingcall.FixedIntervalLoopingCall(
self._report_state)
heartbeat.start(interval=report_interval)
def _get_agent_configurations(self):
return {
'nova_metadata_ip': CONF.nova_metadata_host,
'nova_metadata_port': CONF.nova_metadata_port,
'log_agent_heartbeats': CONF.AGENT.log_agent_heartbeats,
}
def _work(self):
"""Start the neutron-hnv-metadata-proxy agent."""
server = wsgi.Server(
name=self._AGENT_BINARY,
num_threads=CONF.AGENT.worker_count)
server.start(
application=_MetadataProxyHandler(),
port=CONF.bind_port,
host=CONF.bind_host)
server.wait()
def run(self):
self._prologue()
try:
self._work()
except Exception:
LOG.exception(_LE("Error in agent."))
def register_config_opts():
neutron_config.register_agent_state_opts_helper(CONF)
meta_config.register_meta_conf_opts(
meta_config.METADATA_PROXY_HANDLER_OPTS)
def main():
"""The entry point for neutron-hnv-metadata-proxy."""
register_config_opts()
common_config.init(sys.argv[1:])
neutron_config.setup_logging()
proxy = MetadataProxy()
proxy.run()

View File

@ -0,0 +1,286 @@
# Copyright 2017 Cloudbase Solutions Srl
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import sys
import mock
from neutron.agent import rpc as agent_rpc
from neutron.common import topics
from neutron import wsgi
from oslo_config import cfg
import webob
from hyperv.neutron.agent import base as base_agent
from hyperv.neutron.agent import hnv_metadata_agent
from hyperv.tests import base as test_base
CONF = cfg.CONF
class TestMetadataProxyHandler(test_base.BaseTestCase):
@mock.patch("hyperv.neutron.neutron_client.NeutronAPIClient")
@mock.patch("neutron_lib.context.get_admin_context_without_session")
def _get_proxy(self, mock_get_context, mock_neutron_client):
return hnv_metadata_agent._MetadataProxyHandler()
def setUp(self):
super(TestMetadataProxyHandler, self).setUp()
hnv_metadata_agent.register_config_opts()
self._proxy = self._get_proxy()
self._neutron_client = self._proxy._neutron_client
@mock.patch.object(hnv_metadata_agent._MetadataProxyHandler,
"_proxy_request")
def test_call(self, mock_proxy_request):
mock_proxy_request.side_effect = [mock.sentinel.response,
ValueError("_proxy_request_error")]
self.assertEqual(mock.sentinel.response,
self._proxy(mock.sentinel.request))
mock_proxy_request.assert_called_once_with(mock.sentinel.request)
self.assertIsInstance(self._proxy(mock.sentinel.request),
webob.exc.HTTPInternalServerError)
def test_get_port_profile_id(self):
url = "http://169.254.169.254/"
port_profile_id = "9d0bab3e-1abf-11e7-a7ef-5cc5d4a321db"
request = mock.Mock(path=url + port_profile_id)
request_invalid = mock.Mock(path=url)
self.assertEqual(port_profile_id,
self._proxy._get_port_profile_id(request))
self.assertIsNone(self._proxy._get_port_profile_id(request_invalid))
def test_get_instance_id(self):
self._neutron_client.get_network_ports.return_value = [
{},
{"binding:vif_details": {"port_profile_id": None}},
{"binding:vif_details": {
"port_profile_id": mock.sentinel.port_profile_id},
"tenant_id": mock.sentinel.tenant_id,
"device_id": mock.sentinel.instance_id},
]
self.assertEqual(
(mock.sentinel.tenant_id, mock.sentinel.instance_id),
self._proxy._get_instance_id(mock.sentinel.port_profile_id))
self._neutron_client.get_network_ports.return_value = []
self.assertEqual(
(None, None),
self._proxy._get_instance_id(mock.sentinel.port_profile_id))
def test_sign_instance_id(self):
self.config(metadata_proxy_shared_secret="secret")
self.assertEqual(
"0329a06b62cd16b33eb6792be8c60b158d89a2ee3a876fce9a881ebb488c0914",
self._proxy._sign_instance_id("test")
)
@mock.patch.object(hnv_metadata_agent._MetadataProxyHandler,
"_sign_instance_id")
@mock.patch.object(hnv_metadata_agent._MetadataProxyHandler,
"_get_instance_id")
def test_get_headers(self, mock_get_instance_id, mock_sign_instance_id):
mock_get_instance_id.side_effect = [
(mock.sentinel.tenant_id, mock.sentinel.instance_id),
(None, None),
]
expected_headers = {
'X-Instance-ID': mock.sentinel.instance_id,
'X-Tenant-ID': mock.sentinel.tenant_id,
'X-Instance-ID-Signature': mock_sign_instance_id.return_value,
}
self.assertEqual(
expected_headers,
self._proxy._get_headers(mock.sentinel.port))
mock_get_instance_id.assert_called_once_with(mock.sentinel.port)
self.assertIsNone(self._proxy._get_headers(mock.sentinel.port))
@mock.patch("httplib2.Http")
@mock.patch.object(hnv_metadata_agent._MetadataProxyHandler,
"_get_headers")
def _test_proxy_request(self, mock_get_headers, mock_http,
valid_path=True, valid_profile_id=True,
response_code=200, method='GET'):
nova_url = '%s:%s' % (CONF.nova_metadata_ip,
CONF.nova_metadata_port)
path = "/9d0bab3e-1abf-11e7-a7ef-5cc5d4a321db" if valid_path else "/"
headers = {"X-Not-Empty": True} if valid_profile_id else {}
mock_get_headers.return_value = headers
http_response = mock.MagicMock(status=response_code)
http_response.__getitem__.return_value = "text/plain"
http_request = mock_http.return_value
http_request.request.return_value = (http_response,
mock.sentinel.content)
mock_resonse = mock.Mock(content_type=None, body=None)
mock_request = mock.Mock(path=path, path_info=path, query_string='',
headers={}, method=method,
body=mock.sentinel.body)
mock_request.response = mock_resonse
response = self._proxy._proxy_request(mock_request)
if not (valid_path and valid_profile_id):
http_request.add_certificate.assert_not_called()
http_request.request.assert_not_called()
return response
if CONF.nova_client_cert and CONF.nova_client_priv_key:
http_request.add_certificate.assert_called_once_with(
key=CONF.nova_client_priv_key,
cert=CONF.nova_client_cert,
domain=nova_url)
http_request.request.assert_called_once_with(
"http://127.0.0.1:8775/", method=method, headers=headers,
body=mock.sentinel.body)
return response
def test_proxy_request_200(self):
self.config(nova_client_cert=mock.sentinel.nova_client_cert,
nova_client_priv_key=mock.sentinel.priv_key)
response = self._test_proxy_request()
self.assertEqual("text/plain", response.content_type)
self.assertEqual(mock.sentinel.content, response.body)
def test_proxy_request_400(self):
self.assertIsInstance(
self._test_proxy_request(response_code=400),
webob.exc.HTTPBadRequest)
def test_proxy_request_403(self):
self.assertIsInstance(
self._test_proxy_request(response_code=403),
webob.exc.HTTPForbidden)
def test_proxy_request_409(self):
self.assertIsInstance(
self._test_proxy_request(response_code=409),
webob.exc.HTTPConflict)
def test_proxy_request_404(self):
self.assertIsInstance(
self._test_proxy_request(valid_path=False),
webob.exc.HTTPNotFound)
self.assertIsInstance(
self._test_proxy_request(valid_profile_id=False),
webob.exc.HTTPNotFound)
self.assertIsInstance(
self._test_proxy_request(response_code=404),
webob.exc.HTTPNotFound)
def test_proxy_request_500(self):
self.assertIsInstance(
self._test_proxy_request(response_code=500),
webob.exc.HTTPInternalServerError)
def test_proxy_request_other_code(self):
self.assertIsInstance(
self._test_proxy_request(response_code=527),
webob.exc.HTTPInternalServerError)
def test_proxy_request_post(self):
response = self._test_proxy_request(method='POST')
self.assertEqual("text/plain", response.content_type)
self.assertEqual(mock.sentinel.content, response.body)
class TestMetadataProxy(test_base.HyperVBaseTestCase):
@mock.patch.object(hnv_metadata_agent.MetadataProxy, "_setup_rpc")
@mock.patch.object(base_agent.BaseAgent, "_set_agent_state")
def _get_agent(self, mock_set_agent_state, mock_setup_rpc):
return hnv_metadata_agent.MetadataProxy()
def setUp(self):
super(TestMetadataProxy, self).setUp()
hnv_metadata_agent.register_config_opts()
self._agent = self._get_agent()
@mock.patch('oslo_service.loopingcall.FixedIntervalLoopingCall')
@mock.patch.object(agent_rpc, 'PluginReportStateAPI')
def test_setup_rpc(self, mock_plugin_report_state_api,
mock_looping_call):
report_interval = 10
self.config(report_interval=report_interval, group="AGENT")
self._agent._setup_rpc()
mock_plugin_report_state_api.assert_called_once_with(topics.REPORTS)
mock_looping_call.assert_called_once_with(self._agent._report_state)
mock_heartbeat = mock_looping_call.return_value
mock_heartbeat.start.assert_called_once_with(interval=report_interval)
def test_get_agent_configurations(self):
fake_ip = '10.10.10.10'
fake_port = 9999
self.config(nova_metadata_ip=fake_ip,
nova_metadata_port=fake_port)
configuration = self._agent._get_agent_configurations()
self.assertEqual(fake_ip, configuration["nova_metadata_ip"])
self.assertEqual(fake_port, configuration["nova_metadata_port"])
self.assertEqual(CONF.AGENT.log_agent_heartbeats,
configuration["log_agent_heartbeats"])
@mock.patch.object(hnv_metadata_agent, "_MetadataProxyHandler")
@mock.patch.object(wsgi, "Server")
def test_work(self, mock_server, mock_proxy_handler):
self._agent._work()
mock_server.assert_called_once_with(
name=self._agent._AGENT_BINARY,
num_threads=CONF.AGENT.worker_count)
server = mock_server.return_value
server.start.assert_called_once_with(
application=mock_proxy_handler.return_value,
port=CONF.bind_port,
host=CONF.bind_host)
server.wait.assert_called_once_with()
@mock.patch.object(hnv_metadata_agent.MetadataProxy, "_work")
@mock.patch.object(hnv_metadata_agent.MetadataProxy, "_prologue")
def test_run(self, mock_prologue, mock_work):
mock_work.side_effect = ValueError
self._agent.run()
mock_prologue.assert_called_once_with()
mock_work.assert_called_once_with()
class TestMain(test_base.BaseTestCase):
@mock.patch.object(hnv_metadata_agent, 'MetadataProxy')
@mock.patch.object(hnv_metadata_agent, 'common_config')
@mock.patch.object(hnv_metadata_agent, 'meta_config')
@mock.patch.object(hnv_metadata_agent, 'neutron_config')
def test_main(self, mock_config, mock_meta_config, mock_common_config,
mock_proxy):
hnv_metadata_agent.main()
mock_config.register_agent_state_opts_helper.assert_called_once_with(
CONF)
mock_meta_config.register_meta_conf_opts.assert_called_once_with(
hnv_metadata_agent.meta_config.METADATA_PROXY_HANDLER_OPTS)
mock_common_config.init.assert_called_once_with(sys.argv[1:])
mock_config.setup_logging.assert_called_once_with()
mock_proxy.assert_called_once_with()
mock_proxy.return_value.run.assert_called_once_with()

View File

@ -29,6 +29,7 @@ packages =
console_scripts =
neutron-hyperv-agent = hyperv.neutron.agent.hyperv_neutron_agent:main
neutron-hnv-agent = hyperv.neutron.agent.hnv_neutron_agent:main
neutron-hnv-metadata-proxy = hyperv.neutron.agent.hnv_metadata_agent:main
neutron.qos.agent_drivers =
hyperv = hyperv.neutron.qos.qos_driver:QosHyperVAgentDriver