Merge "Rescue extension for CoreOS with DHCP tenant networks"

This commit is contained in:
Zuul 2017-12-11 21:14:09 +00:00 committed by Gerrit Code Review
commit 893c63f24a
9 changed files with 381 additions and 83 deletions

View File

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

View File

@ -0,0 +1,37 @@
#!/bin/bash
create_rescue_user() {
echo "Adding rescue user with root privileges..."
crypted_pass=$(</etc/ipa-rescue-config/ipa-rescue-password)
sudo useradd -m rescue -G sudo -p $crypted_pass
sudo echo "rescue ALL=(ALL) NOPASSWD: ALL" > /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

View File

@ -0,0 +1,5 @@
[Match]
Name=RESCUE_NETWORK_INTERFACE
[Network]
DHCP=yes

View File

@ -193,6 +193,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.
@ -322,6 +324,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
@ -375,24 +401,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()

View File

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

View File

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

View File

@ -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)
@ -547,32 +608,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)

View File

@ -0,0 +1,4 @@
---
features:
- Adds an extension to support rescue mode for CoreOS agents using DHCP
for the tenant network.

View File

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