diff --git a/ironic_python_agent/ironic_api_client.py b/ironic_python_agent/ironic_api_client.py index 35d2deebf..207798712 100644 --- a/ironic_python_agent/ironic_api_client.py +++ b/ironic_python_agent/ironic_api_client.py @@ -23,17 +23,21 @@ from ironic_python_agent import encoding from ironic_python_agent import errors from ironic_python_agent import netutils from ironic_python_agent import utils +from ironic_python_agent import version CONF = cfg.CONF LOG = log.getLogger(__name__) +MIN_IRONIC_VERSION = (1, 22) +AGENT_VERSION_IRONIC_VERSION = (1, 36) + class APIClient(object): api_version = 'v1' lookup_api = '/%s/lookup' % api_version heartbeat_api = '/%s/heartbeat/{uuid}' % api_version - ramdisk_api_headers = {'X-OpenStack-Ironic-API-Version': '1.22'} + _ironic_api_version = None def __init__(self, api_url): self.api_url = api_url.rstrip('/') @@ -69,12 +73,39 @@ class APIClient(object): cert=cert, **kwargs) + def _get_ironic_api_version_header(self, version=MIN_IRONIC_VERSION): + version_str = "%d.%d" % version + return {'X-OpenStack-Ironic-API-Version': version_str} + + def _get_ironic_api_version(self): + if not self._ironic_api_version: + try: + response = self._request('GET', '/') + data = jsonutils.loads(response.content) + version = data['default_version']['version'].split('.') + self._ironic_api_version = (int(version[0]), int(version[1])) + except Exception: + LOG.exception("An error occurred while attempting to discover " + "the available Ironic API versions, falling " + "back to using version %s", + ".".join(map(str, MIN_IRONIC_VERSION))) + return MIN_IRONIC_VERSION + return self._ironic_api_version + def heartbeat(self, uuid, advertise_address): path = self.heartbeat_api.format(uuid=uuid) + data = {'callback_url': self._get_agent_url(advertise_address)} + + if self._get_ironic_api_version() >= AGENT_VERSION_IRONIC_VERSION: + data['agent_version'] = version.version_info.release_string() + headers = self._get_ironic_api_version_header( + AGENT_VERSION_IRONIC_VERSION) + else: + headers = self._get_ironic_api_version_header() + try: - response = self._request('POST', path, data=data, - headers=self.ramdisk_api_headers) + response = self._request('POST', path, data=data, headers=headers) except Exception as e: raise errors.HeartbeatError(str(e)) @@ -113,9 +144,10 @@ class APIClient(object): params['node_uuid'] = node_uuid try: - response = self._request('GET', self.lookup_api, - headers=self.ramdisk_api_headers, - params=params) + response = self._request( + 'GET', self.lookup_api, + headers=self._get_ironic_api_version_header(), + params=params) except Exception: LOG.exception('Lookup failed') return False diff --git a/ironic_python_agent/tests/unit/test_ironic_api_client.py b/ironic_python_agent/tests/unit/test_ironic_api_client.py index 6d7c2a47d..bcf36b337 100644 --- a/ironic_python_agent/tests/unit/test_ironic_api_client.py +++ b/ironic_python_agent/tests/unit/test_ironic_api_client.py @@ -20,6 +20,7 @@ from ironic_python_agent import errors from ironic_python_agent import hardware from ironic_python_agent import ironic_api_client from ironic_python_agent.tests.unit import base +from ironic_python_agent import version API_URL = 'http://agent-api.ironic.example.org/' @@ -28,14 +29,20 @@ class FakeResponse(object): def __init__(self, content=None, status_code=200, headers=None): content = content or {} self.content = jsonutils.dumps(content) + self._json = content self.status_code = status_code self.headers = headers or {} + def json(self): + return self._json + class TestBaseIronicPythonAgent(base.IronicAgentTest): def setUp(self): super(TestBaseIronicPythonAgent, self).setUp() self.api_client = ironic_api_client.APIClient(API_URL) + self.api_client._ironic_api_version = ( + ironic_api_client.MIN_IRONIC_VERSION) self.hardware_info = { 'interfaces': [ hardware.NetworkInterface( @@ -57,11 +64,54 @@ class TestBaseIronicPythonAgent(base.IronicAgentTest): physical_mb='8675'), } + def test__get_ironic_api_version_already_set(self): + self.api_client.session.request = mock.create_autospec( + self.api_client.session.request, + return_value=None) + + self.assertFalse(self.api_client.session.request.called) + self.assertEqual(ironic_api_client.MIN_IRONIC_VERSION, + self.api_client._get_ironic_api_version()) + + def test__get_ironic_api_version_error(self): + self.api_client._ironic_api_version = None + self.api_client.session.request = mock.create_autospec( + self.api_client.session.request, + return_value=None) + self.api_client.session.request.side_effect = Exception("Boom") + + self.assertEqual(ironic_api_client.MIN_IRONIC_VERSION, + self.api_client._get_ironic_api_version()) + + def test__get_ironic_api_version_fresh(self): + self.api_client._ironic_api_version = None + response = FakeResponse(status_code=200, content={ + "default_version": { + "id": "v1", + "links": [ + { + "href": "http://127.0.0.1:6385/v1/", + "rel": "self" + } + ], + "min_version": "1.1", + "status": "CURRENT", + "version": "1.31" + } + }) + self.api_client.session.request = mock.Mock() + self.api_client.session.request.return_value = response + + self.assertEqual((1, 31), self.api_client._get_ironic_api_version()) + self.assertEqual((1, 31), self.api_client._ironic_api_version) + def test_successful_heartbeat(self): response = FakeResponse(status_code=202) self.api_client.session.request = mock.Mock() self.api_client.session.request.return_value = response + self.api_client._ironic_api_version = ( + ironic_api_client.AGENT_VERSION_IRONIC_VERSION) self.api_client.heartbeat( uuid='deadbeef-dabb-ad00-b105-f00d00bab10c', @@ -73,13 +123,18 @@ class TestBaseIronicPythonAgent(base.IronicAgentTest): data = self.api_client.session.request.call_args[1]['data'] self.assertEqual('POST', request_args[0]) self.assertEqual(API_URL + heartbeat_path, request_args[1]) - self.assertEqual('{"callback_url": "http://192.0.2.1:9999"}', data) + expected_data = { + 'callback_url': 'http://192.0.2.1:9999', + 'agent_version': version.version_info.release_string()} + self.assertEqual(jsonutils.dumps(expected_data), data) def test_successful_heartbeat_ip6(self): response = FakeResponse(status_code=202) self.api_client.session.request = mock.Mock() self.api_client.session.request.return_value = response + self.api_client._ironic_api_version = ( + ironic_api_client.AGENT_VERSION_IRONIC_VERSION) self.api_client.heartbeat( uuid='deadbeef-dabb-ad00-b105-f00d00bab10c', @@ -91,8 +146,31 @@ class TestBaseIronicPythonAgent(base.IronicAgentTest): data = self.api_client.session.request.call_args[1]['data'] self.assertEqual('POST', request_args[0]) self.assertEqual(API_URL + heartbeat_path, request_args[1]) - self.assertEqual('{"callback_url": "http://[fc00:1111::4]:9999"}', - data) + expected_data = { + 'callback_url': 'http://[fc00:1111::4]:9999', + 'agent_version': version.version_info.release_string()} + self.assertEqual(jsonutils.dumps(expected_data), data) + + def test_heartbeat_agent_version_unsupported(self): + response = FakeResponse(status_code=202) + + self.api_client.session.request = mock.Mock() + self.api_client.session.request.return_value = response + self.api_client._ironic_api_version = (1, 31) + + self.api_client.heartbeat( + uuid='deadbeef-dabb-ad00-b105-f00d00bab10c', + advertise_address=('fc00:1111::4', '9999') + ) + + heartbeat_path = 'v1/heartbeat/deadbeef-dabb-ad00-b105-f00d00bab10c' + request_args = self.api_client.session.request.call_args[0] + data = self.api_client.session.request.call_args[1]['data'] + self.assertEqual('POST', request_args[0]) + self.assertEqual(API_URL + heartbeat_path, request_args[1]) + expected_data = { + 'callback_url': 'http://[fc00:1111::4]:9999'} + self.assertEqual(jsonutils.dumps(expected_data), data) def test_heartbeat_requests_exception(self): self.api_client.session.request = mock.Mock() diff --git a/releasenotes/notes/start_passing_agent_version_to_ironic-6fa8670ae0e7eb38.yaml b/releasenotes/notes/start_passing_agent_version_to_ironic-6fa8670ae0e7eb38.yaml new file mode 100644 index 000000000..1965127a0 --- /dev/null +++ b/releasenotes/notes/start_passing_agent_version_to_ironic-6fa8670ae0e7eb38.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + Now passes an ``agent_version`` field to the Bare Metal service as part of + the heartbeat request if the Bare Metal API version is 1.36 or higher. + This provides the Bare Metal service with the information required to + determine what agent features are available, so that the Bare Metal service + can be upgraded to a new version before the agent is upgraded, whilst + ensuring the Bare Metal service only requests the agent features that are + available to it.