diff --git a/imagebuild/coreos/oem/cloud-config.yml b/imagebuild/coreos/oem/cloud-config.yml index 03f71ced6..2ce383e2d 100644 --- a/imagebuild/coreos/oem/cloud-config.yml +++ b/imagebuild/coreos/oem/cloud-config.yml @@ -189,6 +189,41 @@ coreos: Type=none Options=bind + - name: setup-rescue-directories.service + command: start + content: | + [Unit] + Description=Create directories for rescue mode configuration + After=ironic-python-agent-container-creation.service + Requires=ironic-python-agent-container-creation.service + + [Service] + Type=oneshot + RemainAfterExit=yes + ExecStart=/usr/bin/mkdir /etc/ipa-rescue-config + ExecStart=/usr/bin/mkdir /opt/ironic-python-agent/etc/ipa-rescue-config + + - name: opt-ironic\x2dpython\x2dagent-etc-ipa\x2drescue\x2dconfig.mount + command: start + content: | + [Unit] + DefaultDependencies=no + + Conflicts=umount.target + Before=umount.target + + After=ironic-python-agent-container-creation.service + After=setup-rescue-directories.service + + Requires=ironic-python-agent-container-creation.service + Requires=setup-rescue-directories.service + + [Mount] + What=/etc/ipa-rescue-config + Where=/opt/ironic-python-agent/etc/ipa-rescue-config + Type=none + Options=bind + - name: ironic-python-agent.service command: start content: | @@ -203,6 +238,8 @@ coreos: After=opt-ironic\x2dpython\x2dagent-mnt.mount After=opt-ironic\x2dpython\x2dagent-etc-resolvconf.service After=opt-ironic\x2dpython\x2dagent-run-log.mount + After=setup-rescue-directories.service + After=opt-ironic\x2dpython\x2dagent-etc-ipa\x2drescue\x2dconfig.mount Requires=ironic-python-agent-container-creation.service Requires=opt-ironic\x2dpython\x2dagent-proc.mount @@ -213,6 +250,8 @@ coreos: Requires=opt-ironic\x2dpython\x2dagent-mnt.mount Requires=opt-ironic\x2dpython\x2dagent-etc-resolvconf.service Requires=opt-ironic\x2dpython\x2dagent-run-log.mount + Requires=setup-rescue-directories.service + Requires=opt-ironic\x2dpython\x2dagent-etc-ipa\x2drescue\x2dconfig.mount [Service] ExecStartPre=-/usr/sbin/modprobe ipmi_msghandler @@ -220,6 +259,6 @@ coreos: ExecStartPre=-/usr/sbin/modprobe ipmi_si ExecStart=/usr/bin/chroot /opt/ironic-python-agent \ /usr/local/bin/ironic-python-agent - Restart=always + ExecStopPost=/usr/share/oem/finalize_rescue.sh + Restart=on-failure RestartSec=30s - diff --git a/imagebuild/coreos/oem/finalize_rescue.sh b/imagebuild/coreos/oem/finalize_rescue.sh new file mode 100755 index 000000000..e8e5b744a --- /dev/null +++ b/imagebuild/coreos/oem/finalize_rescue.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +create_rescue_user() { + echo "Adding rescue user with root privileges..." + crypted_pass=$( /etc/sudoers.d/rescue +} + +setup_dhcp_network() { + DHCP_CONFIG_TEMPLATE=/usr/share/oem/rescue-dhcp-config.network + + echo "Configuring DHCP networks on all interfaces..." + echo "Removing all existing network configuration..." + sudo rm /etc/systemd/network/* + + echo "Configuring all interfaces except loopback to DHCP..." + for interface in $(ls /sys/class/net) ; do + if [ $interface != "lo" ]; then + sudo sed "s/RESCUE_NETWORK_INTERFACE/$interface/" $DHCP_CONFIG_TEMPLATE > /etc/systemd/network/50-$interface.network || true + fi + done + + sudo systemctl restart systemd-networkd +} + +echo "Attempting to start rescue mode configuration..." +if [ -f /etc/ipa-rescue-config/ipa-rescue-password ]; then + # NOTE(mariojv) An exit code of 0 is always forced here to avoid making IPA + # restart after something fails. IPA should not restart when this script + # executes to avoid exposing its API to a tenant network. + create_rescue_user || exit 0 + setup_dhcp_network || exit 0 + # TODO(mariojv) Add support for configdrive and static networks +else + echo "One or more of the files needed for rescue mode does not exist, not rescuing." +fi diff --git a/imagebuild/coreos/oem/rescue-dhcp-config.network b/imagebuild/coreos/oem/rescue-dhcp-config.network new file mode 100644 index 000000000..0f7ded347 --- /dev/null +++ b/imagebuild/coreos/oem/rescue-dhcp-config.network @@ -0,0 +1,5 @@ +[Match] +Name=RESCUE_NETWORK_INTERFACE + +[Network] +DHCP=yes diff --git a/ironic_python_agent/agent.py b/ironic_python_agent/agent.py index c3d81e1bf..cf1d84f23 100644 --- a/ironic_python_agent/agent.py +++ b/ironic_python_agent/agent.py @@ -192,6 +192,8 @@ class IronicPythonAgent(base.ExecuteCommandMixin): self.network_interface = network_interface self.standalone = standalone self.hardware_initialization_delay = hardware_initialization_delay + # IPA will stop serving requests and exit after this is set to False + self.serve_api = True def get_status(self): """Retrieve a serializable status. @@ -316,6 +318,30 @@ class IronicPythonAgent(base.ExecuteCommandMixin): LOG.warning("No valid network interfaces found. " "Node lookup will probably fail.") + def serve_ipa_api(self): + """Serve the API until an extension terminates it.""" + if netutils.is_ipv6_enabled(): + # Listens to both IP versions, assuming IPV6_V6ONLY isn't enabled, + # (the default behaviour in linux) + simple_server.WSGIServer.address_family = socket.AF_INET6 + server = simple_server.WSGIServer((self.listen_address.hostname, + self.listen_address.port), + simple_server.WSGIRequestHandler) + server.set_app(self.api) + + if not self.standalone and self.api_url: + # Don't start heartbeating until the server is listening + self.heartbeater.start() + + while self.serve_api: + try: + server.handle_request() + except BaseException as e: + msg = "Failed due to unknow exception. Error %s" % e + LOG.exception(msg) + raise errors.IronicAPIError(msg) + LOG.info('shutting down') + def run(self): """Run the Ironic Python Agent.""" # Get the UUID so we can heartbeat to Ironic. Raises LookupNodeError @@ -369,24 +395,7 @@ class IronicPythonAgent(base.ExecuteCommandMixin): LOG.error('Neither ipa-api-url nor inspection_callback_url' 'found, please check your pxe append parameters.') - if netutils.is_ipv6_enabled(): - # Listens to both IP versions, assuming IPV6_V6ONLY isn't enabled, - # (the default behaviour in linux) - simple_server.WSGIServer.address_family = socket.AF_INET6 - wsgi = simple_server.make_server( - self.listen_address.hostname, - self.listen_address.port, - self.api, - server_class=simple_server.WSGIServer) - - if not self.standalone and self.api_url: - # Don't start heartbeating until the server is listening - self.heartbeater.start() - - try: - wsgi.serve_forever() - except BaseException: - LOG.exception('shutting down') + self.serve_ipa_api() if not self.standalone and self.api_url: self.heartbeater.stop() diff --git a/ironic_python_agent/extensions/rescue.py b/ironic_python_agent/extensions/rescue.py new file mode 100644 index 000000000..0769bd8fd --- /dev/null +++ b/ironic_python_agent/extensions/rescue.py @@ -0,0 +1,62 @@ +# 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 crypt +import random +import string + +from oslo_log import log + +from ironic_python_agent.extensions import base + +LOG = log.getLogger() + +PASSWORD_FILE = '/etc/ipa-rescue-config/ipa-rescue-password' + + +class RescueExtension(base.BaseAgentExtension): + + def make_salt(self): + """Generate a random salt for hashing the rescue password. + + Salt should be a two-character string from the set [a-zA-Z0-9]. + + :returns: a valid salt for use with crypt.crypt + """ + allowed_chars = string.ascii_letters + string.digits + return random.choice(allowed_chars) + random.choice(allowed_chars) + + def write_rescue_password(self, rescue_password=""): + """Write rescue password to a file for use after IPA exits. + + :param rescue_password: Rescue password. + """ + LOG.debug('Writing hashed rescue password to %s', PASSWORD_FILE) + salt = self.make_salt() + hashed_password = crypt.crypt(rescue_password, salt) + try: + with open(PASSWORD_FILE, 'w') as f: + f.write(hashed_password) + except IOError as e: + msg = ("Rescue Operation failed when writing the hashed rescue " + "password to the password file. Error %s") % e + LOG.exception(msg) + raise IOError(msg) + + @base.sync_command('finalize_rescue') + def finalize_rescue(self, rescue_password=""): + """Sets the rescue password for the rescue user.""" + self.write_rescue_password(rescue_password) + # IPA will terminate after the result of finalize_rescue is returned to + # ironic to avoid exposing the IPA API to a tenant or public network + self.agent.serve_api = False + return diff --git a/ironic_python_agent/tests/unit/extensions/test_rescue.py b/ironic_python_agent/tests/unit/extensions/test_rescue.py new file mode 100644 index 000000000..a416f2272 --- /dev/null +++ b/ironic_python_agent/tests/unit/extensions/test_rescue.py @@ -0,0 +1,77 @@ +# 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 string + +import mock +from oslotest import base as test_base + +from ironic_python_agent.extensions import rescue +from ironic_python_agent.tests.unit.extensions.test_base import FakeAgent + + +class TestRescueExtension(test_base.BaseTestCase): + + def setUp(self): + super(TestRescueExtension, self).setUp() + self.agent_extension = rescue.RescueExtension() + self.agent_extension.agent = FakeAgent() + + def test_make_salt(self): + salt = self.agent_extension.make_salt() + self.assertEqual(2, len(salt)) + for char in salt: + self.assertIn(char, string.ascii_letters + string.digits) + + @mock.patch('ironic_python_agent.extensions.rescue.crypt.crypt', + autospec=True) + @mock.patch('ironic_python_agent.extensions.rescue.RescueExtension.' + 'make_salt', autospec=True) + def test_write_rescue_password(self, mock_salt, mock_crypt): + mock_salt.return_value = '12' + mock_crypt.return_value = '12deadbeef' + mock_open = mock.mock_open() + with mock.patch('ironic_python_agent.extensions.rescue.open', + mock_open): + self.agent_extension.write_rescue_password('password') + + mock_crypt.assert_called_once_with('password', '12') + mock_open.assert_called_once_with( + '/etc/ipa-rescue-config/ipa-rescue-password', 'w') + file_handle = mock_open() + file_handle.write.assert_called_once_with('12deadbeef') + + @mock.patch('ironic_python_agent.extensions.rescue.crypt.crypt', + autospec=True) + @mock.patch('ironic_python_agent.extensions.rescue.RescueExtension.' + 'make_salt', autospec=True) + def test_write_rescue_password_ioerror(self, mock_salt, mock_crypt): + mock_salt.return_value = '12' + mock_crypt.return_value = '12deadbeef' + mock_open = mock.mock_open() + with mock.patch('ironic_python_agent.extensions.rescue.open', + mock_open): + mock_open.side_effect = IOError + # Make sure IOError gets reraised for caller to handle + self.assertRaises( + IOError, self.agent_extension.write_rescue_password, + 'password') + + @mock.patch('ironic_python_agent.extensions.rescue.RescueExtension.' + 'write_rescue_password', autospec=True) + def test_finalize_rescue(self, mock_write_rescue_password): + self.agent_extension.agent.serve_api = True + self.agent_extension.finalize_rescue(rescue_password='password') + mock_write_rescue_password.assert_called_once_with( + mock.ANY, + rescue_password='password') + self.assertFalse(self.agent_extension.agent.serve_api) diff --git a/ironic_python_agent/tests/unit/test_agent.py b/ironic_python_agent/tests/unit/test_agent.py index 4de211e95..10eaa0295 100644 --- a/ironic_python_agent/tests/unit/test_agent.py +++ b/ironic_python_agent/tests/unit/test_agent.py @@ -176,15 +176,21 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): @mock.patch( 'ironic_python_agent.hardware_managers.cna._detect_cna_card', mock.Mock()) + @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) @mock.patch.object(agent.IronicPythonAgent, '_wait_for_interface', autospec=True) - @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) - @mock.patch('wsgiref.simple_server.make_server', autospec=True) - def test_run(self, mock_make_server, mock_dispatch, mock_wait): + @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) + @mock.patch.object(hardware, 'load_managers', autospec=True) + def test_run(self, mock_load_managers, mock_wsgi, + mock_wait, mock_dispatch): CONF.set_override('inspection_callback_url', '') - wsgi_server = mock_make_server.return_value - wsgi_server.start.side_effect = KeyboardInterrupt() + wsgi_server = mock_wsgi.return_value + + def set_serve_api(): + self.agent.serve_api = False + + wsgi_server.handle_request.side_effect = set_serve_api self.agent.heartbeater = mock.Mock() self.agent.api_client.lookup_node = mock.Mock() self.agent.api_client.lookup_node.return_value = { @@ -195,21 +201,66 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): 'heartbeat_timeout': 300 } } + self.agent.run() listen_addr = agent.Host('192.0.2.1', 9999) - mock_make_server.assert_called_once_with( - listen_addr.hostname, - listen_addr.port, - self.agent.api, - server_class=simple_server.WSGIServer) - wsgi_server.serve_forever.assert_called_once_with() + mock_wsgi.assert_called_once_with( + (listen_addr.hostname, + listen_addr.port), + simple_server.WSGIRequestHandler) + wsgi_server.set_app.assert_called_once_with(self.agent.api) + self.assertTrue(wsgi_server.handle_request.called) mock_wait.assert_called_once_with(mock.ANY) self.assertEqual([mock.call('list_hardware_info'), mock.call('wait_for_disks')], mock_dispatch.call_args_list) self.agent.heartbeater.start.assert_called_once_with() + @mock.patch.object(hardware, '_check_for_iscsi', mock.Mock()) + @mock.patch( + 'ironic_python_agent.hardware_managers.cna._detect_cna_card', + mock.Mock()) + @mock.patch.object(agent.IronicPythonAgent, + '_wait_for_interface', autospec=True) + @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) + @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) + @mock.patch.object(hardware, 'load_managers', autospec=True) + def test_run_raise_exception(self, mock_load_managers, mock_wsgi, + mock_dispatch, mock_wait): + CONF.set_override('inspection_callback_url', '') + + wsgi_server = mock_wsgi.return_value + wsgi_server.handle_request.side_effect = KeyboardInterrupt() + self.agent.heartbeater = mock.Mock() + self.agent.api_client.lookup_node = mock.Mock() + self.agent.api_client.lookup_node.return_value = { + 'node': { + 'uuid': 'deadbeef-dabb-ad00-b105-f00d00bab10c' + }, + 'config': { + 'heartbeat_timeout': 300 + } + } + + self.assertRaisesRegex(errors.IronicAPIError, + 'Failed due to unknow exception.', + self.agent.run) + + self.assertTrue(mock_wait.called) + self.assertEqual([mock.call('list_hardware_info'), + mock.call('wait_for_disks')], + mock_dispatch.call_args_list) + listen_addr = agent.Host('192.0.2.1', 9999) + mock_wsgi.assert_called_once_with( + (listen_addr.hostname, + listen_addr.port), + simple_server.WSGIRequestHandler) + wsgi_server.set_app.assert_called_once_with(self.agent.api) + self.assertTrue(wsgi_server.handle_request.called) + self.agent.heartbeater.start.assert_called_once_with() + self.assertTrue(wsgi_server.handle_request.called) + @mock.patch.object(hardware, '_check_for_iscsi', mock.Mock()) @mock.patch('ironic_python_agent.hardware_managers.cna._detect_cna_card', mock.Mock()) @@ -217,15 +268,18 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): '_wait_for_interface', autospec=True) @mock.patch.object(inspector, 'inspect', autospec=True) @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) - @mock.patch('wsgiref.simple_server.make_server', autospec=True) + @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) @mock.patch.object(hardware.HardwareManager, 'list_hardware_info', autospec=True) - def test_run_with_inspection(self, mock_list_hardware, mock_make_server, + def test_run_with_inspection(self, mock_list_hardware, mock_wsgi, mock_dispatch, mock_inspector, mock_wait): CONF.set_override('inspection_callback_url', 'http://foo/bar') - wsgi_server = mock_make_server.return_value - wsgi_server.start.side_effect = KeyboardInterrupt() + def set_serve_api(): + self.agent.serve_api = False + + wsgi_server = mock_wsgi.return_value + wsgi_server.handle_request.side_effect = set_serve_api mock_inspector.return_value = 'uuid' @@ -242,12 +296,12 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): self.agent.run() listen_addr = agent.Host('192.0.2.1', 9999) - mock_make_server.assert_called_once_with( - listen_addr.hostname, - listen_addr.port, - self.agent.api, - server_class=simple_server.WSGIServer) - wsgi_server.serve_forever.assert_called_once_with() + mock_wsgi.assert_called_once_with( + (listen_addr.hostname, + listen_addr.port), + simple_server.WSGIRequestHandler) + self.assertTrue(mock_wsgi.called) + mock_inspector.assert_called_once_with() self.assertEqual(1, self.agent.api_client.lookup_node.call_count) self.assertEqual( @@ -268,12 +322,12 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): '_wait_for_interface', autospec=True) @mock.patch.object(inspector, 'inspect', autospec=True) @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) - @mock.patch('wsgiref.simple_server.make_server', autospec=True) + @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) @mock.patch.object(hardware.HardwareManager, 'list_hardware_info', autospec=True) def test_run_with_inspection_without_apiurl(self, mock_list_hardware, - mock_make_server, + mock_wsgi, mock_dispatch, mock_inspector, mock_wait): @@ -295,18 +349,21 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): self.assertFalse(hasattr(self.agent, 'api_client')) self.assertFalse(hasattr(self.agent, 'heartbeater')) - wsgi_server = mock_make_server.return_value - wsgi_server.start.side_effect = KeyboardInterrupt() + def set_serve_api(): + self.agent.serve_api = False + + wsgi_server = mock_wsgi.return_value + wsgi_server.handle_request.side_effect = set_serve_api self.agent.run() listen_addr = agent.Host('192.0.2.1', 9999) - mock_make_server.assert_called_once_with( - listen_addr.hostname, - listen_addr.port, - self.agent.api, - server_class=simple_server.WSGIServer) - wsgi_server.serve_forever.assert_called_once_with() + mock_wsgi.assert_called_once_with( + (listen_addr.hostname, + listen_addr.port), + simple_server.WSGIRequestHandler) + self.assertTrue(wsgi_server.handle_request.called) + mock_inspector.assert_called_once_with() self.assertFalse(mock_wait.called) @@ -320,12 +377,12 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): '_wait_for_interface', autospec=True) @mock.patch.object(inspector, 'inspect', autospec=True) @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) - @mock.patch('wsgiref.simple_server.make_server', autospec=True) + @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) @mock.patch.object(hardware.HardwareManager, 'list_hardware_info', autospec=True) def test_run_without_inspection_and_apiurl(self, mock_list_hardware, - mock_make_server, + mock_wsgi, mock_dispatch, mock_inspector, mock_wait): @@ -347,18 +404,20 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): self.assertFalse(hasattr(self.agent, 'api_client')) self.assertFalse(hasattr(self.agent, 'heartbeater')) - wsgi_server = mock_make_server.return_value - wsgi_server.start.side_effect = KeyboardInterrupt() + def set_serve_api(): + self.agent.serve_api = False + + wsgi_server = mock_wsgi.return_value + wsgi_server.handle_request.side_effect = set_serve_api self.agent.run() listen_addr = agent.Host('192.0.2.1', 9999) - mock_make_server.assert_called_once_with( - listen_addr.hostname, - listen_addr.port, - self.agent.api, - server_class=simple_server.WSGIServer) - wsgi_server.serve_forever.assert_called_once_with() + mock_wsgi.assert_called_once_with( + (listen_addr.hostname, + listen_addr.port), + simple_server.WSGIRequestHandler) + self.assertTrue(wsgi_server.handle_request.called) self.assertFalse(mock_inspector.called) self.assertFalse(mock_wait.called) @@ -392,12 +451,16 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): @mock.patch.object(agent.IronicPythonAgent, '_wait_for_interface', autospec=True) @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) - @mock.patch('wsgiref.simple_server.make_server', autospec=True) + @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) def test_run_with_sleep(self, mock_make_server, mock_dispatch, mock_wait, mock_sleep, mock_load_managers): CONF.set_override('inspection_callback_url', '') + + def set_serve_api(): + self.agent.serve_api = False + wsgi_server = mock_make_server.return_value - wsgi_server.start.side_effect = KeyboardInterrupt() + wsgi_server.handle_request.side_effect = set_serve_api self.agent.hardware_initialization_delay = 10 self.agent.heartbeater = mock.Mock() @@ -414,11 +477,9 @@ class TestBaseAgent(ironic_agent_base.IronicAgentTest): listen_addr = agent.Host('192.0.2.1', 9999) mock_make_server.assert_called_once_with( - listen_addr.hostname, - listen_addr.port, - self.agent.api, - server_class=simple_server.WSGIServer) - wsgi_server.serve_forever.assert_called_once_with() + (listen_addr.hostname, + listen_addr.port), + simple_server.WSGIRequestHandler) self.agent.heartbeater.start.assert_called_once_with() mock_sleep.assert_called_once_with(10) @@ -539,32 +600,35 @@ class TestAgentStandalone(ironic_agent_base.IronicAgentTest): 'ironic_python_agent.hardware_managers.cna._detect_cna_card', mock.Mock()) @mock.patch('wsgiref.simple_server.make_server', autospec=True) + @mock.patch('wsgiref.simple_server.WSGIServer', autospec=True) @mock.patch.object(hardware.HardwareManager, 'list_hardware_info', autospec=True) - def test_run(self, mock_list_hardware, mock_make_server): + @mock.patch.object(hardware, 'load_managers', autospec=True) + def test_run(self, mock_load_managers, mock_list_hardware, + mock_wsgi, mock_make_server): wsgi_server = mock_make_server.return_value wsgi_server.start.side_effect = KeyboardInterrupt() + wsgi_server_request = mock_wsgi.return_value + + def set_serve_api(): + self.agent.serve_api = False + + wsgi_server_request.handle_request.side_effect = set_serve_api self.agent.heartbeater = mock.Mock() self.agent.api_client.lookup_node = mock.Mock() - self.agent.api_client.lookup_node.return_value = { - 'node': { - 'uuid': 'deadbeef-dabb-ad00-b105-f00d00bab10c' - }, - 'config': { - 'heartbeat_timeout': 300 - } - } + self.agent.run() + self.assertTrue(mock_load_managers.called) listen_addr = agent.Host('192.0.2.1', 9999) - mock_make_server.assert_called_once_with( - listen_addr.hostname, - listen_addr.port, - self.agent.api, - server_class=simple_server.WSGIServer) - wsgi_server.serve_forever.assert_called_once_with() + mock_wsgi.assert_called_once_with( + (listen_addr.hostname, + listen_addr.port), + simple_server.WSGIRequestHandler) + wsgi_server_request.set_app.assert_called_once_with(self.agent.api) + self.assertTrue(wsgi_server_request.handle_request.called) self.assertFalse(self.agent.heartbeater.called) self.assertFalse(self.agent.api_client.lookup_node.called) diff --git a/releasenotes/notes/add-coreos-dhcp-rescue-support-1dd8e9d5ac9c7594.yaml b/releasenotes/notes/add-coreos-dhcp-rescue-support-1dd8e9d5ac9c7594.yaml new file mode 100644 index 000000000..59dbb1ea0 --- /dev/null +++ b/releasenotes/notes/add-coreos-dhcp-rescue-support-1dd8e9d5ac9c7594.yaml @@ -0,0 +1,4 @@ +--- +features: + - Adds an extension to support rescue mode for CoreOS agents using DHCP + for the tenant network. diff --git a/setup.cfg b/setup.cfg index aeb0bb023..696dd854c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,6 +29,7 @@ ironic_python_agent.extensions = iscsi = ironic_python_agent.extensions.iscsi:ISCSIExtension image = ironic_python_agent.extensions.image:ImageExtension log = ironic_python_agent.extensions.log:LogExtension + rescue = ironic_python_agent.extensions.rescue:RescueExtension ironic_python_agent.hardware_managers = generic = ironic_python_agent.hardware:GenericHardwareManager