diff --git a/teeth_agent/agent.py b/teeth_agent/agent.py index 965d567bd..261a48771 100644 --- a/teeth_agent/agent.py +++ b/teeth_agent/agent.py @@ -91,9 +91,9 @@ class TeethAgentHeartbeater(threading.Thread): def do_heartbeat(self): try: deadline = self.api.heartbeat( - hardware_info=self.hardware.list_hardware_info(), - version=self.agent.version, - mode=self.agent.get_mode_name()) + uuid=self.agent.get_node_uuid(), + advertise_address=self.agent.advertise_address + ) self.error_delay = self.initial_delay self.log.info('heartbeat successful') except Exception: @@ -112,10 +112,11 @@ class TeethAgentHeartbeater(threading.Thread): class TeethAgent(object): - def __init__(self, api_url, listen_address, ipaddr): + def __init__(self, api_url, advertise_address, listen_address): self.api_url = api_url + self.api_client = overlord_agent_api.APIClient(self.api_url) self.listen_address = listen_address - self.ipaddr = ipaddr + self.advertise_address = advertise_address self.mode_implementation = None self.version = pkg_resources.get_distribution('teeth-agent').version self.api = app.VersionSelectorApplication(self) @@ -125,6 +126,7 @@ class TeethAgent(object): self.command_lock = threading.Lock() self.log = log.getLogger(__name__) self.started_at = None + self.node = None def get_mode_name(self): if self.mode_implementation: @@ -143,6 +145,11 @@ class TeethAgent(object): def get_agent_mac_addr(self): return self.hardware.get_primary_mac_address() + def get_node_uuid(self): + if 'uuid' not in self.node: + errors.HeartbeatError('Tried to heartbeat without node UUID.') + return self.node['uuid'] + def list_command_results(self): return self.command_results.values() @@ -204,6 +211,10 @@ class TeethAgent(object): def run(self): """Run the Teeth Agent.""" self.started_at = _time() + # Get the UUID so we can heartbeat to Ironic + self.node = self.api_client.lookup_node( + hardware_info=self.hardware.list_hardware_info(), + ) self.heartbeater.start() wsgi = simple_server.make_server( self.listen_address[0], @@ -229,5 +240,12 @@ def _load_mode_implementation(mode_name): return mgr.driver -def build_agent(api_url, listen_host, listen_port, ipaddr): - return TeethAgent(api_url, (listen_host, listen_port), ipaddr) +def build_agent(api_url, + advertise_host, + advertise_port, + listen_host, + listen_port): + + return TeethAgent(api_url, + (advertise_host, advertise_port), + (listen_host, listen_port)) diff --git a/teeth_agent/cmd/agent.py b/teeth_agent/cmd/agent.py index 7075693a5..a30b8a9c6 100644 --- a/teeth_agent/cmd/agent.py +++ b/teeth_agent/cmd/agent.py @@ -31,19 +31,25 @@ def run(): parser.add_argument('--listen-host', default='0.0.0.0', type=str, - help=('The IP address to listen on.')) + help='The IP address to listen on.') parser.add_argument('--listen-port', default=9999, type=int, help='The port to listen on') - - parser.add_argument('--ipaddr', - required=True, - help='The external IP address to advertise to ironic') - + parser.add_argument('--advertise-host', + default='0.0.0.0', + type=str, + help='The host to tell Ironic to reply and send ' + 'commands to.') + parser.add_argument('--advertise-port', + default=9999, + type=int, + help='The port to tell Ironic to reply and send ' + 'commands to.') args = parser.parse_args() agent.build_agent(args.api_url, + args.advertise_host, + args.advertise_port, args.listen_host, - args.listen_port, - args.ipaddr).run() + args.listen_port).run() diff --git a/teeth_agent/errors.py b/teeth_agent/errors.py index 367508eea..6ba45316c 100644 --- a/teeth_agent/errors.py +++ b/teeth_agent/errors.py @@ -110,6 +110,17 @@ class HeartbeatError(OverlordAPIError): super(HeartbeatError, self).__init__(details) +class LookupNodeError(OverlordAPIError): + """Error raised when the node configuration lookup to the Ironic API + fails. + """ + + message = 'Error getting configuration from Ironic.' + + def __init__(self, details): + super(LookupNodeError, self).__init__(details) + + class ImageDownloadError(RESTError): """Error raised when an image cannot be downloaded.""" diff --git a/teeth_agent/overlord_agent_api.py b/teeth_agent/overlord_agent_api.py index e124769c3..fda86b22b 100644 --- a/teeth_agent/overlord_agent_api.py +++ b/teeth_agent/overlord_agent_api.py @@ -46,17 +46,16 @@ class APIClient(object): headers=request_headers, data=data) - def heartbeat(self, hardware_info, mode, version): - path = '/{api_version}/agents'.format(api_version=self.api_version) - + def heartbeat(self, uuid, advertise_address): + path = '/{api_version}/nodes/{uuid}/vendor_passthru/heartbeat'.format( + api_version=self.api_version, + uuid=uuid + ) data = { - 'hardware': hardware_info, - 'mode': mode, - 'version': version, + 'agent_url': self._get_agent_url(advertise_address) } - try: - response = self._request('PUT', path, data=data) + response = self._request('POST', path, data=data) except Exception as e: raise errors.HeartbeatError(str(e)) @@ -71,18 +70,36 @@ class APIClient(object): except Exception: raise errors.HeartbeatError('Invalid Heartbeat-Before header') - def get_configuration(self, mac_addr): - path = '/{api_version}/agents/{mac_addr}/configuration'.format( - api_version=self.api_version, - mac_addr=mac_addr) + def lookup_node(self, hardware_info): + path = '/{api_version}/drivers/teeth/lookup'.format( + api_version=self.api_version + ) + # This hardware won't be saved on the node currently, because of how + # driver_vendor_passthru is implemented (no node saving). + data = { + 'hardware': hardware_info, + } - response = self._request('GET', path) + try: + response = self._request('POST', path, data=data) + except Exception as e: + raise errors.LookupNodeError(str(e)) if response.status_code != requests.codes.OK: msg = 'Invalid status code: {0}'.format(response.status_code) - raise errors.OverlordAPIError(msg) + raise errors.LookupNodeError(msg) try: - return json.loads(response.content) + content = json.loads(response.content) except Exception as e: - raise errors.OverlordAPIError('Error decoding response: ' + str(e)) + raise errors.LookupNodeError('Error decoding response: ' + + str(e)) + + if 'node' not in content or 'uuid' not in content['node']: + raise errors.LookupNodeError('Got invalid data from the API: ' + '{0}'.format(content)) + return content['node'] + + def _get_agent_url(self, advertise_address): + return 'http://{0}:{1}'.format(advertise_address[0], + advertise_address[1]) diff --git a/teeth_agent/tests/agent.py b/teeth_agent/tests/agent.py index 64e8fb892..8f90e7a6a 100644 --- a/teeth_agent/tests/agent.py +++ b/teeth_agent/tests/agent.py @@ -120,8 +120,8 @@ class TestBaseAgent(unittest.TestCase): def setUp(self): self.encoder = encoding.RESTJSONEncoder(indent=4) self.agent = agent.TeethAgent('https://fake_api.example.org:8081/', - ('localhost', 9999), - '192.168.1.1') + ('203.0.113.1', 9990), + ('192.0.2.1', 9999)) def assertEqualEncoded(self, a, b): # Evidently JSONEncoder.default() can't handle None (??) so we have to @@ -162,9 +162,10 @@ class TestBaseAgent(unittest.TestCase): wsgi_server.start.side_effect = KeyboardInterrupt() self.agent.heartbeater = mock.Mock() + self.agent.api_client.lookup_node = mock.Mock() self.agent.run() - listen_addr = ('localhost', 9999) + listen_addr = ('192.0.2.1', 9999) wsgi_server_cls.assert_called_once_with( listen_addr[0], listen_addr[1], diff --git a/teeth_agent/tests/overlord_agent_api.py b/teeth_agent/tests/overlord_agent_api.py index 3abbc9675..20f92ad78 100644 --- a/teeth_agent/tests/overlord_agent_api.py +++ b/teeth_agent/tests/overlord_agent_api.py @@ -32,9 +32,9 @@ class TestBaseTeethAgent(unittest.TestCase): self.api_client = overlord_agent_api.APIClient(API_URL) self.hardware_info = [ hardware.HardwareInfo(hardware.HardwareType.MAC_ADDRESS, - 'a:b:c:d'), + 'aa:bb:cc:dd:ee:ff'), hardware.HardwareInfo(hardware.HardwareType.MAC_ADDRESS, - '0:1:2:3'), + 'ff:ee:dd:cc:bb:aa'), ] def test_successful_heartbeat(self): @@ -47,30 +47,17 @@ class TestBaseTeethAgent(unittest.TestCase): self.api_client.session.request.return_value = response heartbeat_before = self.api_client.heartbeat( - hardware_info=self.hardware_info, - version='15', - mode='STANDBY') + uuid='deadbeef-dabb-ad00-b105-f00d00bab10c', + advertise_address=('192.0.2.1', '9999') + ) self.assertEqual(heartbeat_before, expected_heartbeat_before) + heartbeat_path = 'v1/nodes/deadbeef-dabb-ad00-b105-f00d00bab10c/' \ + 'vendor_passthru/heartbeat' request_args = self.api_client.session.request.call_args[0] - self.assertEqual(request_args[0], 'PUT') - self.assertEqual(request_args[1], API_URL + 'v1/agents') - - data = self.api_client.session.request.call_args[1]['data'] - content = json.loads(data) - self.assertEqual(content['mode'], 'STANDBY') - self.assertEqual(content['version'], '15') - self.assertEqual(content['hardware'], [ - { - 'type': 'mac_address', - 'id': 'a:b:c:d', - }, - { - 'type': 'mac_address', - 'id': '0:1:2:3', - }, - ]) + self.assertEqual(request_args[0], 'POST') + self.assertEqual(request_args[1], API_URL + heartbeat_path) def test_heartbeat_requests_exception(self): self.api_client.session.request = mock.Mock() @@ -78,9 +65,8 @@ class TestBaseTeethAgent(unittest.TestCase): self.assertRaises(errors.HeartbeatError, self.api_client.heartbeat, - hardware_info=self.hardware_info, - version='15', - mode='STANDBY') + uuid='deadbeef-dabb-ad00-b105-f00d00bab10c', + advertise_address=('192.0.2.1', '9999')) def test_heartbeat_invalid_status_code(self): response = httmock.response(status_code=404) @@ -89,9 +75,8 @@ class TestBaseTeethAgent(unittest.TestCase): self.assertRaises(errors.HeartbeatError, self.api_client.heartbeat, - hardware_info=self.hardware_info, - version='15', - mode='STANDBY') + uuid='deadbeef-dabb-ad00-b105-f00d00bab10c', + advertise_address=('192.0.2.1', '9999')) def test_heartbeat_missing_heartbeat_before_header(self): response = httmock.response(status_code=204) @@ -100,9 +85,8 @@ class TestBaseTeethAgent(unittest.TestCase): self.assertRaises(errors.HeartbeatError, self.api_client.heartbeat, - hardware_info=self.hardware_info, - version='15', - mode='STANDBY') + uuid='deadbeef-dabb-ad00-b105-f00d00bab10c', + advertise_address=('192.0.2.1', '9999')) def test_heartbeat_invalid_heartbeat_before_header(self): response = httmock.response(status_code=204, headers={ @@ -113,6 +97,75 @@ class TestBaseTeethAgent(unittest.TestCase): self.assertRaises(errors.HeartbeatError, self.api_client.heartbeat, + uuid='deadbeef-dabb-ad00-b105-f00d00bab10c', + advertise_address=('192.0.2.1', '9999')) + + def test_lookup_node(self): + response = httmock.response(status_code=200, content={ + 'node': { + 'uuid': 'deadbeef-dabb-ad00-b105-f00d00bab10c' + } + }) + + self.api_client.session.request = mock.Mock() + self.api_client.session.request.return_value = response + + self.api_client.lookup_node( + hardware_info=self.hardware_info, + ) + + request_args = self.api_client.session.request.call_args[0] + self.assertEqual(request_args[0], 'POST') + self.assertEqual(request_args[1], API_URL + 'v1/drivers/teeth/lookup') + + data = self.api_client.session.request.call_args[1]['data'] + content = json.loads(data) + self.assertEqual(content['hardware'], [ + { + 'type': 'mac_address', + 'id': 'aa:bb:cc:dd:ee:ff', + }, + { + 'type': 'mac_address', + 'id': 'ff:ee:dd:cc:bb:aa', + }, + ]) + + def test_lookup_node_bad_response_code(self): + response = httmock.response(status_code=400, content={ + 'node': { + 'uuid': 'deadbeef-dabb-ad00-b105-f00d00bab10c' + } + }) + + self.api_client.session.request = mock.Mock() + self.api_client.session.request.return_value = response + + self.assertRaises(errors.LookupNodeError, + self.api_client.lookup_node, hardware_info=self.hardware_info, - version='15', - mode='STANDBY') + ) + + def test_lookup_node_bad_response_data(self): + response = httmock.response(status_code=200, content='a') + + self.api_client.session.request = mock.Mock() + self.api_client.session.request.return_value = response + + self.assertRaises(errors.LookupNodeError, + self.api_client.lookup_node, + hardware_info=self.hardware_info + ) + + def test_lookup_node_bad_response_body(self): + response = httmock.response(status_code=200, content={ + 'node_node': 'also_not_node' + }) + + self.api_client.session.request = mock.Mock() + self.api_client.session.request.return_value = response + + self.assertRaises(errors.LookupNodeError, + self.api_client.lookup_node, + hardware_info=self.hardware_info, + )