
In order to provide increased security, it is necessary to hash the rescue password in advance of it being stored into the database and to provide some sort of control for hash strength. This change IS incompatible with prior IPA versions with regard to use of the rescue feature, but I fully expect we will backport the change to IPA on to stable branches and perform a release as it is a security improvement. Change-Id: I1e118467a536229de6f7c245c1c48f0af38dcef2 Story: 2006777 Task: 27301
478 lines
20 KiB
Python
478 lines
20 KiB
Python
# Copyright 2014 Rackspace, Inc.
|
|
#
|
|
# 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.
|
|
|
|
from http import client as http_client
|
|
import json
|
|
|
|
import mock
|
|
import requests
|
|
import retrying
|
|
|
|
from ironic.common import exception
|
|
from ironic import conf
|
|
from ironic.drivers.modules import agent_client
|
|
from ironic.tests import base
|
|
|
|
|
|
CONF = conf.CONF
|
|
|
|
|
|
class MockResponse(object):
|
|
def __init__(self, text, status_code=http_client.OK):
|
|
assert isinstance(text, str)
|
|
self.text = text
|
|
self.status_code = status_code
|
|
|
|
def json(self):
|
|
return json.loads(self.text)
|
|
|
|
|
|
class MockNode(object):
|
|
def __init__(self):
|
|
self.uuid = 'uuid'
|
|
self.driver_internal_info = {
|
|
'agent_url': "http://127.0.0.1:9999",
|
|
'hardware_manager_version': {'generic': '1'}
|
|
}
|
|
self.instance_info = {}
|
|
|
|
def as_dict(self, secure=False):
|
|
assert secure, 'agent_client must pass secure=True'
|
|
return {
|
|
'uuid': self.uuid,
|
|
'driver_internal_info': self.driver_internal_info,
|
|
'instance_info': self.instance_info
|
|
}
|
|
|
|
|
|
class TestAgentClient(base.TestCase):
|
|
def setUp(self):
|
|
super(TestAgentClient, self).setUp()
|
|
self.client = agent_client.AgentClient()
|
|
self.client.session = mock.MagicMock(autospec=requests.Session)
|
|
self.node = MockNode()
|
|
|
|
def test_content_type_header(self):
|
|
client = agent_client.AgentClient()
|
|
self.assertEqual('application/json',
|
|
client.session.headers['Content-Type'])
|
|
|
|
def test__get_command_url(self):
|
|
command_url = self.client._get_command_url(self.node)
|
|
expected = self.node.driver_internal_info['agent_url'] + '/v1/commands'
|
|
self.assertEqual(expected, command_url)
|
|
|
|
def test__get_command_url_fail(self):
|
|
del self.node.driver_internal_info['agent_url']
|
|
self.assertRaises(exception.IronicException,
|
|
self.client._get_command_url,
|
|
self.node)
|
|
|
|
def test__get_command_body(self):
|
|
expected = json.dumps({'name': 'prepare_image', 'params': {}})
|
|
self.assertEqual(expected,
|
|
self.client._get_command_body('prepare_image', {}))
|
|
|
|
def test__command(self):
|
|
response_data = {'status': 'ok'}
|
|
response_text = json.dumps(response_data)
|
|
self.client.session.post.return_value = MockResponse(response_text)
|
|
method = 'standby.run_image'
|
|
image_info = {'image_id': 'test_image'}
|
|
params = {'image_info': image_info}
|
|
|
|
url = self.client._get_command_url(self.node)
|
|
body = self.client._get_command_body(method, params)
|
|
|
|
response = self.client._command(self.node, method, params)
|
|
self.assertEqual(response, response_data)
|
|
self.client.session.post.assert_called_once_with(
|
|
url,
|
|
data=body,
|
|
params={'wait': 'false'},
|
|
timeout=60)
|
|
|
|
def test__command_fail_json(self):
|
|
response_text = 'this be not json matey!'
|
|
self.client.session.post.return_value = MockResponse(response_text)
|
|
method = 'standby.run_image'
|
|
image_info = {'image_id': 'test_image'}
|
|
params = {'image_info': image_info}
|
|
|
|
url = self.client._get_command_url(self.node)
|
|
body = self.client._get_command_body(method, params)
|
|
|
|
self.assertRaises(exception.IronicException,
|
|
self.client._command,
|
|
self.node, method, params)
|
|
self.client.session.post.assert_called_once_with(
|
|
url,
|
|
data=body,
|
|
params={'wait': 'false'},
|
|
timeout=60)
|
|
|
|
def test__command_fail_post(self):
|
|
error = 'Boom'
|
|
self.client.session.post.side_effect = requests.RequestException(error)
|
|
method = 'foo.bar'
|
|
params = {}
|
|
|
|
self.client._get_command_url(self.node)
|
|
self.client._get_command_body(method, params)
|
|
|
|
e = self.assertRaises(exception.IronicException,
|
|
self.client._command,
|
|
self.node, method, params)
|
|
self.assertEqual('Error invoking agent command %(method)s for node '
|
|
'%(node)s. Error: %(error)s' %
|
|
{'method': method, 'node': self.node.uuid,
|
|
'error': error}, str(e))
|
|
|
|
def test__command_fail_connect(self):
|
|
error = 'Boom'
|
|
self.client.session.post.side_effect = requests.ConnectionError(error)
|
|
method = 'foo.bar'
|
|
params = {}
|
|
|
|
self.client._get_command_url(self.node)
|
|
self.client._get_command_body(method, params)
|
|
|
|
e = self.assertRaises(exception.AgentConnectionFailed,
|
|
self.client._command,
|
|
self.node, method, params)
|
|
self.assertEqual('Connection to agent failed: Failed to connect to '
|
|
'the agent running on node %(node)s for invoking '
|
|
'command %(method)s. Error: %(error)s' %
|
|
{'method': method, 'node': self.node.uuid,
|
|
'error': error}, str(e))
|
|
|
|
def test__command_error_code(self):
|
|
response_text = '{"faultstring": "you dun goofd"}'
|
|
self.client.session.post.return_value = MockResponse(
|
|
response_text, status_code=http_client.BAD_REQUEST)
|
|
method = 'standby.run_image'
|
|
image_info = {'image_id': 'test_image'}
|
|
params = {'image_info': image_info}
|
|
|
|
url = self.client._get_command_url(self.node)
|
|
body = self.client._get_command_body(method, params)
|
|
|
|
self.assertRaises(exception.AgentAPIError,
|
|
self.client._command,
|
|
self.node, method, params)
|
|
self.client.session.post.assert_called_once_with(
|
|
url,
|
|
data=body,
|
|
params={'wait': 'false'},
|
|
timeout=60)
|
|
|
|
def test_get_commands_status(self):
|
|
with mock.patch.object(self.client.session, 'get',
|
|
autospec=True) as mock_get:
|
|
res = mock.MagicMock(spec_set=['json'])
|
|
res.json.return_value = {'commands': []}
|
|
mock_get.return_value = res
|
|
self.assertEqual([], self.client.get_commands_status(self.node))
|
|
agent_url = self.node.driver_internal_info.get('agent_url')
|
|
mock_get.assert_called_once_with(
|
|
'%(agent_url)s/%(api_version)s/commands' % {
|
|
'agent_url': agent_url,
|
|
'api_version': CONF.agent.agent_api_version},
|
|
timeout=CONF.agent.command_timeout)
|
|
|
|
def test_prepare_image(self):
|
|
self.client._command = mock.MagicMock(spec_set=[])
|
|
image_info = {'image_id': 'image'}
|
|
params = {'image_info': image_info}
|
|
|
|
self.client.prepare_image(self.node,
|
|
image_info,
|
|
wait=False)
|
|
self.client._command.assert_called_once_with(
|
|
node=self.node, method='standby.prepare_image',
|
|
params=params, wait=False)
|
|
|
|
def test_prepare_image_with_configdrive(self):
|
|
self.client._command = mock.MagicMock(spec_set=[])
|
|
configdrive_url = 'http://swift/configdrive'
|
|
self.node.instance_info['configdrive'] = configdrive_url
|
|
image_info = {'image_id': 'image'}
|
|
params = {
|
|
'image_info': image_info,
|
|
'configdrive': configdrive_url,
|
|
}
|
|
|
|
self.client.prepare_image(self.node,
|
|
image_info,
|
|
wait=False)
|
|
self.client._command.assert_called_once_with(
|
|
node=self.node, method='standby.prepare_image',
|
|
params=params, wait=False)
|
|
|
|
def test_start_iscsi_target(self):
|
|
self.client._command = mock.MagicMock(spec_set=[])
|
|
iqn = 'fake-iqn'
|
|
port = agent_client.DEFAULT_IPA_PORTAL_PORT
|
|
wipe_disk_metadata = False
|
|
params = {'iqn': iqn, 'portal_port': port,
|
|
'wipe_disk_metadata': wipe_disk_metadata}
|
|
|
|
self.client.start_iscsi_target(self.node, iqn)
|
|
self.client._command.assert_called_once_with(
|
|
node=self.node, method='iscsi.start_iscsi_target',
|
|
params=params, wait=True)
|
|
|
|
def test_start_iscsi_target_custom_port(self):
|
|
self.client._command = mock.MagicMock(spec_set=[])
|
|
iqn = 'fake-iqn'
|
|
port = 3261
|
|
wipe_disk_metadata = False
|
|
params = {'iqn': iqn, 'portal_port': port,
|
|
'wipe_disk_metadata': wipe_disk_metadata}
|
|
|
|
self.client.start_iscsi_target(self.node, iqn, portal_port=port)
|
|
self.client._command.assert_called_once_with(
|
|
node=self.node, method='iscsi.start_iscsi_target',
|
|
params=params, wait=True)
|
|
|
|
def test_start_iscsi_target_wipe_disk_metadata(self):
|
|
self.client._command = mock.MagicMock(spec_set=[])
|
|
iqn = 'fake-iqn'
|
|
port = agent_client.DEFAULT_IPA_PORTAL_PORT
|
|
wipe_disk_metadata = True
|
|
params = {'iqn': iqn, 'portal_port': port,
|
|
'wipe_disk_metadata': wipe_disk_metadata}
|
|
|
|
self.client.start_iscsi_target(self.node, iqn,
|
|
wipe_disk_metadata=wipe_disk_metadata)
|
|
self.client._command.assert_called_once_with(
|
|
node=self.node, method='iscsi.start_iscsi_target',
|
|
params=params, wait=True)
|
|
|
|
def _test_install_bootloader(self, root_uuid, efi_system_part_uuid=None,
|
|
prep_boot_part_uuid=None):
|
|
self.client._command = mock.MagicMock(spec_set=[])
|
|
params = {'root_uuid': root_uuid,
|
|
'efi_system_part_uuid': efi_system_part_uuid,
|
|
'prep_boot_part_uuid': prep_boot_part_uuid}
|
|
|
|
self.client.install_bootloader(
|
|
self.node, root_uuid, efi_system_part_uuid=efi_system_part_uuid,
|
|
prep_boot_part_uuid=prep_boot_part_uuid)
|
|
self.client._command.assert_called_once_with(
|
|
node=self.node, method='image.install_bootloader', params=params,
|
|
wait=True)
|
|
|
|
def test_install_bootloader(self):
|
|
self._test_install_bootloader(root_uuid='fake-root-uuid',
|
|
efi_system_part_uuid='fake-efi-uuid')
|
|
|
|
def test_install_bootloaderi_with_prep(self):
|
|
self._test_install_bootloader(root_uuid='fake-root-uuid',
|
|
efi_system_part_uuid='fake-efi-uuid',
|
|
prep_boot_part_uuid='fake-prep-uuid')
|
|
|
|
def test_get_clean_steps(self):
|
|
self.client._command = mock.MagicMock(spec_set=[])
|
|
ports = []
|
|
expected_params = {
|
|
'node': self.node.as_dict(secure=True),
|
|
'ports': []
|
|
}
|
|
|
|
self.client.get_clean_steps(self.node,
|
|
ports)
|
|
self.client._command.assert_called_once_with(
|
|
node=self.node, method='clean.get_clean_steps',
|
|
params=expected_params, wait=True)
|
|
|
|
def test_execute_clean_step(self):
|
|
self.client._command = mock.MagicMock(spec_set=[])
|
|
ports = []
|
|
step = {'priority': 10, 'step': 'erase_devices', 'interface': 'deploy'}
|
|
expected_params = {
|
|
'step': step,
|
|
'node': self.node.as_dict(secure=True),
|
|
'ports': [],
|
|
'clean_version':
|
|
self.node.driver_internal_info['hardware_manager_version']
|
|
}
|
|
self.client.execute_clean_step(step,
|
|
self.node,
|
|
ports)
|
|
self.client._command.assert_called_once_with(
|
|
node=self.node, method='clean.execute_clean_step',
|
|
params=expected_params)
|
|
|
|
def test_power_off(self):
|
|
self.client._command = mock.MagicMock(spec_set=[])
|
|
self.client.power_off(self.node)
|
|
self.client._command.assert_called_once_with(
|
|
node=self.node, method='standby.power_off', params={})
|
|
|
|
def test_sync(self):
|
|
self.client._command = mock.MagicMock(spec_set=[])
|
|
self.client.sync(self.node)
|
|
self.client._command.assert_called_once_with(
|
|
node=self.node, method='standby.sync', params={}, wait=True)
|
|
|
|
def test_finalize_rescue(self):
|
|
self.client._command = mock.MagicMock(spec_set=[])
|
|
self.node.instance_info['rescue_password'] = 'password'
|
|
self.node.instance_info['hashed_rescue_password'] = '1234'
|
|
expected_params = {
|
|
'rescue_password': '1234',
|
|
'hashed': True,
|
|
}
|
|
self.client.finalize_rescue(self.node)
|
|
self.client._command.assert_called_once_with(
|
|
node=self.node, method='rescue.finalize_rescue',
|
|
params=expected_params)
|
|
|
|
def test_finalize_rescue_exc(self):
|
|
# node does not have 'rescue_password' set in its 'instance_info'
|
|
self.client._command = mock.MagicMock(spec_set=[])
|
|
self.assertRaises(exception.IronicException,
|
|
self.client.finalize_rescue,
|
|
self.node)
|
|
self.assertFalse(self.client._command.called)
|
|
|
|
def test_finalize_rescue_fallback(self):
|
|
self.config(require_rescue_password_hashed=False, group="conductor")
|
|
self.client._command = mock.MagicMock(spec_set=[])
|
|
self.node.instance_info['rescue_password'] = 'password'
|
|
self.node.instance_info['hashed_rescue_password'] = '1234'
|
|
self.client._command.side_effect = [
|
|
exception.AgentAPIError('blah'),
|
|
('', '')]
|
|
self.client.finalize_rescue(self.node)
|
|
self.client._command.assert_has_calls([
|
|
mock.call(node=mock.ANY, method='rescue.finalize_rescue',
|
|
params={'rescue_password': '1234',
|
|
'hashed': True}),
|
|
mock.call(node=mock.ANY, method='rescue.finalize_rescue',
|
|
params={'rescue_password': 'password'})])
|
|
|
|
def test_finalize_rescue_fallback_restricted(self):
|
|
self.config(require_rescue_password_hashed=True, group="conductor")
|
|
self.client._command = mock.MagicMock(spec_set=[])
|
|
self.node.instance_info['rescue_password'] = 'password'
|
|
self.node.instance_info['hashed_rescue_password'] = '1234'
|
|
self.client._command.side_effect = exception.AgentAPIError('blah')
|
|
self.assertRaises(exception.InstanceRescueFailure,
|
|
self.client.finalize_rescue,
|
|
self.node)
|
|
self.client._command.assert_has_calls([
|
|
mock.call(node=mock.ANY, method='rescue.finalize_rescue',
|
|
params={'rescue_password': '1234',
|
|
'hashed': True})])
|
|
|
|
def test__command_agent_client(self):
|
|
response_data = {'status': 'ok'}
|
|
response_text = json.dumps(response_data)
|
|
self.client.session.post.return_value = MockResponse(response_text)
|
|
method = 'standby.run_image'
|
|
image_info = {'image_id': 'test_image'}
|
|
params = {'image_info': image_info}
|
|
i_info = self.node.driver_internal_info
|
|
i_info['agent_secret_token'] = 'magical'
|
|
self.node.driver_internal_info = i_info
|
|
url = self.client._get_command_url(self.node)
|
|
body = self.client._get_command_body(method, params)
|
|
|
|
response = self.client._command(self.node, method, params)
|
|
self.assertEqual(response, response_data)
|
|
self.client.session.post.assert_called_once_with(
|
|
url,
|
|
data=body,
|
|
params={'wait': 'false',
|
|
'agent_token': 'magical'},
|
|
timeout=60)
|
|
|
|
|
|
class TestAgentClientAttempts(base.TestCase):
|
|
def setUp(self):
|
|
super(TestAgentClientAttempts, self).setUp()
|
|
self.client = agent_client.AgentClient()
|
|
self.client.session = mock.MagicMock(autospec=requests.Session)
|
|
self.node = MockNode()
|
|
|
|
@mock.patch.object(retrying.time, 'sleep', autospec=True)
|
|
def test__command_fail_all_attempts(self, mock_sleep):
|
|
mock_sleep.return_value = None
|
|
error = 'Connection Timeout'
|
|
method = 'standby.run_image'
|
|
image_info = {'image_id': 'test_image'}
|
|
params = {'image_info': image_info}
|
|
self.client.session.post.side_effect = [requests.Timeout(error),
|
|
requests.Timeout(error),
|
|
requests.Timeout(error),
|
|
requests.Timeout(error)]
|
|
self.client._get_command_url(self.node)
|
|
self.client._get_command_body(method, params)
|
|
|
|
e = self.assertRaises(exception.AgentConnectionFailed,
|
|
self.client._command,
|
|
self.node, method, params)
|
|
self.assertEqual('Connection to agent failed: Failed to connect to '
|
|
'the agent running on node %(node)s for invoking '
|
|
'command %(method)s. Error: %(error)s' %
|
|
{'method': method, 'node': self.node.uuid,
|
|
'error': error}, str(e))
|
|
self.assertEqual(3, self.client.session.post.call_count)
|
|
|
|
@mock.patch.object(retrying.time, 'sleep', autospec=True)
|
|
def test__command_succeed_after_two_timeouts(self, mock_sleep):
|
|
mock_sleep.return_value = None
|
|
error = 'Connection Timeout'
|
|
response_data = {'status': 'ok'}
|
|
response_text = json.dumps(response_data)
|
|
method = 'standby.run_image'
|
|
image_info = {'image_id': 'test_image'}
|
|
params = {'image_info': image_info}
|
|
self.client.session.post.side_effect = [requests.Timeout(error),
|
|
requests.Timeout(error),
|
|
MockResponse(response_text)]
|
|
|
|
response = self.client._command(self.node, method, params)
|
|
self.assertEqual(3, self.client.session.post.call_count)
|
|
self.assertEqual(response, response_data)
|
|
self.client.session.post.assert_called_with(
|
|
self.client._get_command_url(self.node),
|
|
data=self.client._get_command_body(method, params),
|
|
params={'wait': 'false'},
|
|
timeout=60)
|
|
|
|
@mock.patch.object(retrying.time, 'sleep', autospec=True)
|
|
def test__command_succeed_after_one_timeout(self, mock_sleep):
|
|
mock_sleep.return_value = None
|
|
error = 'Connection Timeout'
|
|
response_data = {'status': 'ok'}
|
|
response_text = json.dumps(response_data)
|
|
method = 'standby.run_image'
|
|
image_info = {'image_id': 'test_image'}
|
|
params = {'image_info': image_info}
|
|
self.client.session.post.side_effect = [requests.Timeout(error),
|
|
MockResponse(response_text),
|
|
requests.Timeout(error)]
|
|
|
|
response = self.client._command(self.node, method, params)
|
|
self.assertEqual(2, self.client.session.post.call_count)
|
|
self.assertEqual(response, response_data)
|
|
self.client.session.post.assert_called_with(
|
|
self.client._get_command_url(self.node),
|
|
data=self.client._get_command_body(method, params),
|
|
params={'wait': 'false'},
|
|
timeout=60)
|