Accept and use a TLS certificate from the agent
Accepts the certificate from a heartbeat and stores its path in driver_internal_info for further usage by the agent client (or any 3rd party deploy implementations). Similarly to agent_url, the certificate is protected from further changes (unless the local copy does not exist) and is removed on reboot or tear down (unless fast-tracking). Change-Id: I81b326116e62cd86ad22b533f55d061e5ed53e96 Story: #2007214 Task: #40603
This commit is contained in:
parent
f6b65cb68f
commit
2b676a6864
@ -2,6 +2,11 @@
|
|||||||
REST API Version History
|
REST API Version History
|
||||||
========================
|
========================
|
||||||
|
|
||||||
|
1.68 (Victoria, master)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Added the ``agent_verify_ca`` parameter to the ramdisk heartbeat API.
|
||||||
|
|
||||||
1.67 (Victoria, master)
|
1.67 (Victoria, master)
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
||||||
|
@ -173,9 +173,9 @@ class HeartbeatController(rest.RestController):
|
|||||||
"""Controller handling heartbeats from deploy ramdisk."""
|
"""Controller handling heartbeats from deploy ramdisk."""
|
||||||
|
|
||||||
@expose.expose(None, types.uuid_or_name, str,
|
@expose.expose(None, types.uuid_or_name, str,
|
||||||
str, str, status_code=http_client.ACCEPTED)
|
str, str, str, status_code=http_client.ACCEPTED)
|
||||||
def post(self, node_ident, callback_url, agent_version=None,
|
def post(self, node_ident, callback_url, agent_version=None,
|
||||||
agent_token=None):
|
agent_token=None, agent_verify_ca=None):
|
||||||
"""Process a heartbeat from the deploy ramdisk.
|
"""Process a heartbeat from the deploy ramdisk.
|
||||||
|
|
||||||
:param node_ident: the UUID or logical name of a node.
|
:param node_ident: the UUID or logical name of a node.
|
||||||
@ -186,6 +186,7 @@ class HeartbeatController(rest.RestController):
|
|||||||
last release before sending agent_version was introduced) will be
|
last release before sending agent_version was introduced) will be
|
||||||
assumed.
|
assumed.
|
||||||
:param agent_token: randomly generated validation token.
|
:param agent_token: randomly generated validation token.
|
||||||
|
:param agent_verify_ca: TLS certificate to use to connect to the agent.
|
||||||
:raises: NodeNotFound if node with provided UUID or name was not found.
|
:raises: NodeNotFound if node with provided UUID or name was not found.
|
||||||
:raises: InvalidUuidOrName if node_ident is not valid name or UUID.
|
:raises: InvalidUuidOrName if node_ident is not valid name or UUID.
|
||||||
:raises: NoValidHost if RPC topic for node could not be retrieved.
|
:raises: NoValidHost if RPC topic for node could not be retrieved.
|
||||||
@ -202,6 +203,11 @@ class HeartbeatController(rest.RestController):
|
|||||||
cdict = api.request.context.to_policy_values()
|
cdict = api.request.context.to_policy_values()
|
||||||
policy.authorize('baremetal:node:ipa_heartbeat', cdict, cdict)
|
policy.authorize('baremetal:node:ipa_heartbeat', cdict, cdict)
|
||||||
|
|
||||||
|
if (agent_verify_ca is not None
|
||||||
|
and not api_utils.allow_verify_ca_in_heartbeat()):
|
||||||
|
raise exception.InvalidParameterValue(
|
||||||
|
_('Field "agent_verify_ca" not recognised in this version'))
|
||||||
|
|
||||||
rpc_node = api_utils.get_rpc_node_with_suffix(node_ident)
|
rpc_node = api_utils.get_rpc_node_with_suffix(node_ident)
|
||||||
dii = rpc_node['driver_internal_info']
|
dii = rpc_node['driver_internal_info']
|
||||||
agent_url = dii.get('agent_url')
|
agent_url = dii.get('agent_url')
|
||||||
@ -231,4 +237,4 @@ class HeartbeatController(rest.RestController):
|
|||||||
|
|
||||||
api.request.rpcapi.heartbeat(
|
api.request.rpcapi.heartbeat(
|
||||||
api.request.context, rpc_node.uuid, callback_url,
|
api.request.context, rpc_node.uuid, callback_url,
|
||||||
agent_version, agent_token, topic=topic)
|
agent_version, agent_token, agent_verify_ca, topic=topic)
|
||||||
|
@ -1391,3 +1391,8 @@ def allow_local_link_connection_network_type():
|
|||||||
"""Check if network_type is allowed in ports link_local_connection"""
|
"""Check if network_type is allowed in ports link_local_connection"""
|
||||||
return (api.request.version.minor
|
return (api.request.version.minor
|
||||||
>= versions.MINOR_64_LOCAL_LINK_CONNECTION_NETWORK_TYPE)
|
>= versions.MINOR_64_LOCAL_LINK_CONNECTION_NETWORK_TYPE)
|
||||||
|
|
||||||
|
|
||||||
|
def allow_verify_ca_in_heartbeat():
|
||||||
|
"""Check if heartbeat accepts agent_verify_ca."""
|
||||||
|
return api.request.version.minor >= versions.MINOR_68_HEARTBEAT_VERIFY_CA
|
||||||
|
@ -105,6 +105,7 @@ BASE_VERSION = 1
|
|||||||
# v1.65: Add lessee to the node object.
|
# v1.65: Add lessee to the node object.
|
||||||
# v1.66: Add support for node network_data field.
|
# v1.66: Add support for node network_data field.
|
||||||
# v1.67: Add support for port_uuid/portgroup_uuid in node vif_attach
|
# v1.67: Add support for port_uuid/portgroup_uuid in node vif_attach
|
||||||
|
# v1.68: Add agent_verify_ca to heartbeat.
|
||||||
|
|
||||||
MINOR_0_JUNO = 0
|
MINOR_0_JUNO = 0
|
||||||
MINOR_1_INITIAL_VERSION = 1
|
MINOR_1_INITIAL_VERSION = 1
|
||||||
@ -174,6 +175,7 @@ MINOR_64_LOCAL_LINK_CONNECTION_NETWORK_TYPE = 64
|
|||||||
MINOR_65_NODE_LESSEE = 65
|
MINOR_65_NODE_LESSEE = 65
|
||||||
MINOR_66_NODE_NETWORK_DATA = 66
|
MINOR_66_NODE_NETWORK_DATA = 66
|
||||||
MINOR_67_NODE_VIF_ATTACH_PORT = 67
|
MINOR_67_NODE_VIF_ATTACH_PORT = 67
|
||||||
|
MINOR_68_HEARTBEAT_VERIFY_CA = 68
|
||||||
|
|
||||||
# When adding another version, update:
|
# When adding another version, update:
|
||||||
# - MINOR_MAX_VERSION
|
# - MINOR_MAX_VERSION
|
||||||
@ -181,7 +183,7 @@ MINOR_67_NODE_VIF_ATTACH_PORT = 67
|
|||||||
# explanation of what changed in the new version
|
# explanation of what changed in the new version
|
||||||
# - common/release_mappings.py, RELEASE_MAPPING['master']['api']
|
# - common/release_mappings.py, RELEASE_MAPPING['master']['api']
|
||||||
|
|
||||||
MINOR_MAX_VERSION = MINOR_67_NODE_VIF_ATTACH_PORT
|
MINOR_MAX_VERSION = MINOR_68_HEARTBEAT_VERIFY_CA
|
||||||
|
|
||||||
# String representations of the minor and maximum versions
|
# String representations of the minor and maximum versions
|
||||||
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)
|
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)
|
||||||
|
@ -248,8 +248,8 @@ RELEASE_MAPPING = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'master': {
|
'master': {
|
||||||
'api': '1.67',
|
'api': '1.68',
|
||||||
'rpc': '1.50',
|
'rpc': '1.51',
|
||||||
'objects': {
|
'objects': {
|
||||||
'Allocation': ['1.1'],
|
'Allocation': ['1.1'],
|
||||||
'Node': ['1.35'],
|
'Node': ['1.35'],
|
||||||
|
@ -91,7 +91,7 @@ class ConductorManager(base_manager.BaseConductorManager):
|
|||||||
# NOTE(rloo): This must be in sync with rpcapi.ConductorAPI's.
|
# NOTE(rloo): This must be in sync with rpcapi.ConductorAPI's.
|
||||||
# NOTE(pas-ha): This also must be in sync with
|
# NOTE(pas-ha): This also must be in sync with
|
||||||
# ironic.common.release_mappings.RELEASE_MAPPING['master']
|
# ironic.common.release_mappings.RELEASE_MAPPING['master']
|
||||||
RPC_API_VERSION = '1.50'
|
RPC_API_VERSION = '1.51'
|
||||||
|
|
||||||
target = messaging.Target(version=RPC_API_VERSION)
|
target = messaging.Target(version=RPC_API_VERSION)
|
||||||
|
|
||||||
@ -3099,11 +3099,12 @@ class ConductorManager(base_manager.BaseConductorManager):
|
|||||||
@messaging.expected_exceptions(exception.InvalidParameterValue)
|
@messaging.expected_exceptions(exception.InvalidParameterValue)
|
||||||
@messaging.expected_exceptions(exception.NoFreeConductorWorker)
|
@messaging.expected_exceptions(exception.NoFreeConductorWorker)
|
||||||
def heartbeat(self, context, node_id, callback_url, agent_version=None,
|
def heartbeat(self, context, node_id, callback_url, agent_version=None,
|
||||||
agent_token=None):
|
agent_token=None, agent_verify_ca=None):
|
||||||
"""Process a heartbeat from the ramdisk.
|
"""Process a heartbeat from the ramdisk.
|
||||||
|
|
||||||
:param context: request context.
|
:param context: request context.
|
||||||
:param node_id: node id or uuid.
|
:param node_id: node id or uuid.
|
||||||
|
:param callback_url: URL to reach back to the ramdisk.
|
||||||
:param agent_version: The version of the agent that is heartbeating. If
|
:param agent_version: The version of the agent that is heartbeating. If
|
||||||
not provided it either indicates that the agent that is
|
not provided it either indicates that the agent that is
|
||||||
heartbeating is a version before sending agent_version was
|
heartbeating is a version before sending agent_version was
|
||||||
@ -3111,8 +3112,8 @@ class ConductorManager(base_manager.BaseConductorManager):
|
|||||||
RPC version is pinned so the API isn't passing us the
|
RPC version is pinned so the API isn't passing us the
|
||||||
agent_version, in these cases assume agent v3.0.0 (the last release
|
agent_version, in these cases assume agent v3.0.0 (the last release
|
||||||
before sending agent_version was introduced).
|
before sending agent_version was introduced).
|
||||||
:param callback_url: URL to reach back to the ramdisk.
|
|
||||||
:param agent_token: randomly generated validation token.
|
:param agent_token: randomly generated validation token.
|
||||||
|
:param agent_verify_ca: TLS certificate for the agent.
|
||||||
:raises: NoFreeConductorWorker if there are no conductors to process
|
:raises: NoFreeConductorWorker if there are no conductors to process
|
||||||
this heartbeat request.
|
this heartbeat request.
|
||||||
"""
|
"""
|
||||||
@ -3144,9 +3145,13 @@ class ConductorManager(base_manager.BaseConductorManager):
|
|||||||
raise exception.InvalidParameterValue(
|
raise exception.InvalidParameterValue(
|
||||||
_('TLS is required by configuration'))
|
_('TLS is required by configuration'))
|
||||||
|
|
||||||
|
if agent_verify_ca:
|
||||||
|
agent_verify_ca = utils.store_agent_certificate(
|
||||||
|
task.node, agent_verify_ca)
|
||||||
|
|
||||||
task.spawn_after(
|
task.spawn_after(
|
||||||
self._spawn_worker, task.driver.deploy.heartbeat,
|
self._spawn_worker, task.driver.deploy.heartbeat,
|
||||||
task, callback_url, agent_version)
|
task, callback_url, agent_version, agent_verify_ca)
|
||||||
|
|
||||||
@METRICS.timer('ConductorManager.vif_list')
|
@METRICS.timer('ConductorManager.vif_list')
|
||||||
@messaging.expected_exceptions(exception.NetworkError,
|
@messaging.expected_exceptions(exception.NetworkError,
|
||||||
|
@ -103,13 +103,14 @@ class ConductorAPI(object):
|
|||||||
heartbeat
|
heartbeat
|
||||||
| 1.50 - Added set_indicator_state, get_indicator_state and
|
| 1.50 - Added set_indicator_state, get_indicator_state and
|
||||||
| get_supported_indicators.
|
| get_supported_indicators.
|
||||||
|
| 1.51 - Added agent_verify_ca to heartbeat.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# NOTE(rloo): This must be in sync with manager.ConductorManager's.
|
# NOTE(rloo): This must be in sync with manager.ConductorManager's.
|
||||||
# NOTE(pas-ha): This also must be in sync with
|
# NOTE(pas-ha): This also must be in sync with
|
||||||
# ironic.common.release_mappings.RELEASE_MAPPING['master']
|
# ironic.common.release_mappings.RELEASE_MAPPING['master']
|
||||||
RPC_API_VERSION = '1.50'
|
RPC_API_VERSION = '1.51'
|
||||||
|
|
||||||
def __init__(self, topic=None):
|
def __init__(self, topic=None):
|
||||||
super(ConductorAPI, self).__init__()
|
super(ConductorAPI, self).__init__()
|
||||||
@ -898,7 +899,7 @@ class ConductorAPI(object):
|
|||||||
node_id=node_id, clean_steps=clean_steps)
|
node_id=node_id, clean_steps=clean_steps)
|
||||||
|
|
||||||
def heartbeat(self, context, node_id, callback_url, agent_version,
|
def heartbeat(self, context, node_id, callback_url, agent_version,
|
||||||
agent_token=None, topic=None):
|
agent_token=None, agent_verify_ca=None, topic=None):
|
||||||
"""Process a node heartbeat.
|
"""Process a node heartbeat.
|
||||||
|
|
||||||
:param context: request context.
|
:param context: request context.
|
||||||
@ -907,6 +908,7 @@ class ConductorAPI(object):
|
|||||||
:param topic: RPC topic. Defaults to self.topic.
|
:param topic: RPC topic. Defaults to self.topic.
|
||||||
:param agent_token: randomly generated validation token.
|
:param agent_token: randomly generated validation token.
|
||||||
:param agent_version: the version of the agent that is heartbeating
|
:param agent_version: the version of the agent that is heartbeating
|
||||||
|
:param agent_verify_ca: TLS certificate for the agent.
|
||||||
:raises: InvalidParameterValue if an invalid agent token is received.
|
:raises: InvalidParameterValue if an invalid agent token is received.
|
||||||
"""
|
"""
|
||||||
new_kws = {}
|
new_kws = {}
|
||||||
@ -917,6 +919,9 @@ class ConductorAPI(object):
|
|||||||
if self.client.can_send_version('1.49'):
|
if self.client.can_send_version('1.49'):
|
||||||
version = '1.49'
|
version = '1.49'
|
||||||
new_kws['agent_token'] = agent_token
|
new_kws['agent_token'] = agent_token
|
||||||
|
if self.client.can_send_version('1.51'):
|
||||||
|
version = '1.51'
|
||||||
|
new_kws['agent_verify_ca'] = agent_verify_ca
|
||||||
cctxt = self.client.prepare(topic=topic or self.topic, version=version)
|
cctxt = self.client.prepare(topic=topic or self.topic, version=version)
|
||||||
return cctxt.call(context, 'heartbeat', node_id=node_id,
|
return cctxt.call(context, 'heartbeat', node_id=node_id,
|
||||||
callback_url=callback_url, **new_kws)
|
callback_url=callback_url, **new_kws)
|
||||||
|
@ -16,6 +16,7 @@ import contextlib
|
|||||||
import crypt
|
import crypt
|
||||||
import datetime
|
import datetime
|
||||||
from distutils.version import StrictVersion
|
from distutils.version import StrictVersion
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@ -462,6 +463,8 @@ def wipe_internal_info_on_power_off(node):
|
|||||||
# Wipe cached steps since they may change after reboot.
|
# Wipe cached steps since they may change after reboot.
|
||||||
driver_internal_info.pop('agent_cached_deploy_steps', None)
|
driver_internal_info.pop('agent_cached_deploy_steps', None)
|
||||||
driver_internal_info.pop('agent_cached_clean_steps', None)
|
driver_internal_info.pop('agent_cached_clean_steps', None)
|
||||||
|
# Remove TLS certificate since it's regenerated on each run.
|
||||||
|
driver_internal_info.pop('agent_verify_ca', None)
|
||||||
node.driver_internal_info = driver_internal_info
|
node.driver_internal_info = driver_internal_info
|
||||||
|
|
||||||
|
|
||||||
@ -473,6 +476,8 @@ def wipe_token_and_url(task):
|
|||||||
# Remove agent_url since it will be re-asserted
|
# Remove agent_url since it will be re-asserted
|
||||||
# upon the next deployment attempt.
|
# upon the next deployment attempt.
|
||||||
info.pop('agent_url', None)
|
info.pop('agent_url', None)
|
||||||
|
# Remove TLS certificate since it's regenerated on each run.
|
||||||
|
info.pop('agent_verify_ca', None)
|
||||||
task.node.driver_internal_info = info
|
task.node.driver_internal_info = info
|
||||||
|
|
||||||
|
|
||||||
@ -1232,3 +1237,45 @@ def get_attached_vif(port):
|
|||||||
if inspection_vif:
|
if inspection_vif:
|
||||||
return (inspection_vif, 'inspecting')
|
return (inspection_vif, 'inspecting')
|
||||||
return (None, None)
|
return (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def store_agent_certificate(node, agent_verify_ca):
|
||||||
|
"""Store certificate received from the agent and return its path."""
|
||||||
|
existing_verify_ca = node.driver_internal_info.get(
|
||||||
|
'agent_verify_ca')
|
||||||
|
if existing_verify_ca:
|
||||||
|
if os.path.exists(existing_verify_ca):
|
||||||
|
try:
|
||||||
|
with open(existing_verify_ca, 'rt') as fp:
|
||||||
|
existing_text = fp.read()
|
||||||
|
except EnvironmentError:
|
||||||
|
with excutils.save_and_reraise_exception():
|
||||||
|
LOG.exception('Could not read the existing TLS certificate'
|
||||||
|
' for node %s', node.uuid)
|
||||||
|
|
||||||
|
if existing_text.strip() != agent_verify_ca.strip():
|
||||||
|
LOG.error('Content mismatch for agent_verify_ca for '
|
||||||
|
'node %s', node.uuid)
|
||||||
|
raise exception.InvalidParameterValue(
|
||||||
|
_('Detected change in ramdisk provided "agent_verify_ca"'))
|
||||||
|
else:
|
||||||
|
return existing_verify_ca
|
||||||
|
else:
|
||||||
|
LOG.info('Current agent_verify_ca was not found for node '
|
||||||
|
'%s, assuming take over and storing', node.uuid)
|
||||||
|
|
||||||
|
fname = os.path.join(CONF.agent.certificates_path, '%s.crt' % node.uuid)
|
||||||
|
try:
|
||||||
|
# FIXME(dtantsur): it makes more sense to create this path on conductor
|
||||||
|
# start-up, but it requires reworking a ton of unit tests.
|
||||||
|
os.makedirs(CONF.agent.certificates_path, exist_ok=True)
|
||||||
|
with open(fname, 'wt') as fp:
|
||||||
|
fp.write(agent_verify_ca)
|
||||||
|
except EnvironmentError:
|
||||||
|
with excutils.save_and_reraise_exception():
|
||||||
|
LOG.exception('Could not save the TLS certificate for node %s',
|
||||||
|
node.uuid)
|
||||||
|
else:
|
||||||
|
LOG.debug('Saved the custom certificate for node %(node)s to %(file)s',
|
||||||
|
{'node': node.uuid, 'file': fname})
|
||||||
|
return fname
|
||||||
|
@ -149,6 +149,10 @@ opts = [
|
|||||||
mutable=True,
|
mutable=True,
|
||||||
help=_('If set to True, callback URLs without https:// will '
|
help=_('If set to True, callback URLs without https:// will '
|
||||||
'be rejected by the conductor.')),
|
'be rejected by the conductor.')),
|
||||||
|
cfg.StrOpt('certificates_path',
|
||||||
|
default='/var/lib/ironic/certificates',
|
||||||
|
help=_('Path for TLS certificates used to validate '
|
||||||
|
'connections to the ramdisk.')),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -475,12 +475,14 @@ class DeployInterface(BaseInterface):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def heartbeat(self, task, callback_url, agent_version):
|
def heartbeat(self, task, callback_url, agent_version,
|
||||||
|
agent_verify_ca=None):
|
||||||
"""Record a heartbeat for the node.
|
"""Record a heartbeat for the node.
|
||||||
|
|
||||||
:param task: A TaskManager instance containing the node to act on.
|
:param task: A TaskManager instance containing the node to act on.
|
||||||
:param callback_url: a URL to use to call to the ramdisk.
|
:param callback_url: a URL to use to call to the ramdisk.
|
||||||
:param agent_version: The version of the agent that is heartbeating
|
:param agent_version: The version of the agent that is heartbeating
|
||||||
|
:param agent_verify_ca: TLS certificate for the agent.
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
LOG.warning('Got heartbeat message from node %(node)s, but '
|
LOG.warning('Got heartbeat message from node %(node)s, but '
|
||||||
|
@ -607,12 +607,14 @@ class HeartbeatMixin(object):
|
|||||||
manager_utils.rescuing_error_handler(task, last_error)
|
manager_utils.rescuing_error_handler(task, last_error)
|
||||||
|
|
||||||
@METRICS.timer('HeartbeatMixin.heartbeat')
|
@METRICS.timer('HeartbeatMixin.heartbeat')
|
||||||
def heartbeat(self, task, callback_url, agent_version):
|
def heartbeat(self, task, callback_url, agent_version,
|
||||||
|
agent_verify_ca=None):
|
||||||
"""Process a heartbeat.
|
"""Process a heartbeat.
|
||||||
|
|
||||||
:param task: task to work with.
|
:param task: task to work with.
|
||||||
:param callback_url: agent HTTP API URL.
|
:param callback_url: agent HTTP API URL.
|
||||||
:param agent_version: The version of the agent that is heartbeating
|
:param agent_version: The version of the agent that is heartbeating
|
||||||
|
:param agent_verify_ca: TLS certificate for the agent.
|
||||||
"""
|
"""
|
||||||
# NOTE(pas-ha) immediately skip the rest if nothing to do
|
# NOTE(pas-ha) immediately skip the rest if nothing to do
|
||||||
if (task.node.provision_state not in self.heartbeat_allowed_states
|
if (task.node.provision_state not in self.heartbeat_allowed_states
|
||||||
@ -641,6 +643,8 @@ class HeartbeatMixin(object):
|
|||||||
# datetime.datetime.strptime(var, "%Y-%m-%d %H:%M:%S.%f")
|
# datetime.datetime.strptime(var, "%Y-%m-%d %H:%M:%S.%f")
|
||||||
driver_internal_info['agent_last_heartbeat'] = str(
|
driver_internal_info['agent_last_heartbeat'] = str(
|
||||||
timeutils.utcnow().isoformat())
|
timeutils.utcnow().isoformat())
|
||||||
|
if agent_verify_ca:
|
||||||
|
driver_internal_info['agent_verify_ca'] = agent_verify_ca
|
||||||
node.driver_internal_info = driver_internal_info
|
node.driver_internal_info = driver_internal_info
|
||||||
node.save()
|
node.save()
|
||||||
|
|
||||||
|
@ -77,7 +77,8 @@ class AgentClient(object):
|
|||||||
})
|
})
|
||||||
|
|
||||||
def _get_verify(self, node):
|
def _get_verify(self, node):
|
||||||
return node.driver_info.get('agent_verify_ca', True)
|
return (node.driver_internal_info.get('agent_verify_ca')
|
||||||
|
or node.driver_info.get('agent_verify_ca', True))
|
||||||
|
|
||||||
def _raise_if_typeerror(self, result, node, method):
|
def _raise_if_typeerror(self, result, node, method):
|
||||||
error = result.get('command_error')
|
error = result.get('command_error')
|
||||||
|
@ -225,7 +225,7 @@ class TestHeartbeat(test_api_base.BaseApiTest):
|
|||||||
self.assertEqual(b'', response.body)
|
self.assertEqual(b'', response.body)
|
||||||
mock_heartbeat.assert_called_once_with(mock.ANY, mock.ANY,
|
mock_heartbeat.assert_called_once_with(mock.ANY, mock.ANY,
|
||||||
node.uuid, 'url', None, 'x',
|
node.uuid, 'url', None, 'x',
|
||||||
topic='test-topic')
|
None, topic='test-topic')
|
||||||
|
|
||||||
@mock.patch.object(rpcapi.ConductorAPI, 'heartbeat', autospec=True)
|
@mock.patch.object(rpcapi.ConductorAPI, 'heartbeat', autospec=True)
|
||||||
def test_ok_with_json(self, mock_heartbeat):
|
def test_ok_with_json(self, mock_heartbeat):
|
||||||
@ -240,7 +240,7 @@ class TestHeartbeat(test_api_base.BaseApiTest):
|
|||||||
mock_heartbeat.assert_called_once_with(mock.ANY, mock.ANY,
|
mock_heartbeat.assert_called_once_with(mock.ANY, mock.ANY,
|
||||||
node.uuid, 'url', None,
|
node.uuid, 'url', None,
|
||||||
'maybe some magic',
|
'maybe some magic',
|
||||||
topic='test-topic')
|
None, topic='test-topic')
|
||||||
|
|
||||||
@mock.patch.object(rpcapi.ConductorAPI, 'heartbeat', autospec=True)
|
@mock.patch.object(rpcapi.ConductorAPI, 'heartbeat', autospec=True)
|
||||||
def test_ok_by_name(self, mock_heartbeat):
|
def test_ok_by_name(self, mock_heartbeat):
|
||||||
@ -255,7 +255,7 @@ class TestHeartbeat(test_api_base.BaseApiTest):
|
|||||||
mock_heartbeat.assert_called_once_with(mock.ANY, mock.ANY,
|
mock_heartbeat.assert_called_once_with(mock.ANY, mock.ANY,
|
||||||
node.uuid, 'url', None,
|
node.uuid, 'url', None,
|
||||||
'token',
|
'token',
|
||||||
topic='test-topic')
|
None, topic='test-topic')
|
||||||
|
|
||||||
@mock.patch.object(rpcapi.ConductorAPI, 'heartbeat', autospec=True)
|
@mock.patch.object(rpcapi.ConductorAPI, 'heartbeat', autospec=True)
|
||||||
def test_ok_agent_version(self, mock_heartbeat):
|
def test_ok_agent_version(self, mock_heartbeat):
|
||||||
@ -271,7 +271,7 @@ class TestHeartbeat(test_api_base.BaseApiTest):
|
|||||||
mock_heartbeat.assert_called_once_with(mock.ANY, mock.ANY,
|
mock_heartbeat.assert_called_once_with(mock.ANY, mock.ANY,
|
||||||
node.uuid, 'url', '1.4.1',
|
node.uuid, 'url', '1.4.1',
|
||||||
'meow',
|
'meow',
|
||||||
topic='test-topic')
|
None, topic='test-topic')
|
||||||
|
|
||||||
@mock.patch.object(rpcapi.ConductorAPI, 'heartbeat', autospec=True)
|
@mock.patch.object(rpcapi.ConductorAPI, 'heartbeat', autospec=True)
|
||||||
def test_old_API_agent_version_error(self, mock_heartbeat):
|
def test_old_API_agent_version_error(self, mock_heartbeat):
|
||||||
@ -308,5 +308,33 @@ class TestHeartbeat(test_api_base.BaseApiTest):
|
|||||||
self.assertEqual(b'', response.body)
|
self.assertEqual(b'', response.body)
|
||||||
mock_heartbeat.assert_called_once_with(mock.ANY, mock.ANY,
|
mock_heartbeat.assert_called_once_with(mock.ANY, mock.ANY,
|
||||||
node.uuid, 'url', None,
|
node.uuid, 'url', None,
|
||||||
'abcdef1',
|
'abcdef1', None,
|
||||||
topic='test-topic')
|
topic='test-topic')
|
||||||
|
|
||||||
|
@mock.patch.object(rpcapi.ConductorAPI, 'heartbeat', autospec=True)
|
||||||
|
def test_ok_agent_verify_ca(self, mock_heartbeat):
|
||||||
|
node = obj_utils.create_test_node(self.context)
|
||||||
|
response = self.post_json(
|
||||||
|
'/heartbeat/%s' % node.uuid,
|
||||||
|
{'callback_url': 'url',
|
||||||
|
'agent_token': 'meow',
|
||||||
|
'agent_verify_ca': 'abcdef1'},
|
||||||
|
headers={api_base.Version.string: str(api_v1.max_version())})
|
||||||
|
self.assertEqual(http_client.ACCEPTED, response.status_int)
|
||||||
|
self.assertEqual(b'', response.body)
|
||||||
|
mock_heartbeat.assert_called_once_with(mock.ANY, mock.ANY,
|
||||||
|
node.uuid, 'url', None,
|
||||||
|
'meow', 'abcdef1',
|
||||||
|
topic='test-topic')
|
||||||
|
|
||||||
|
@mock.patch.object(rpcapi.ConductorAPI, 'heartbeat', autospec=True)
|
||||||
|
def test_old_API_agent_verify_ca_error(self, mock_heartbeat):
|
||||||
|
node = obj_utils.create_test_node(self.context)
|
||||||
|
response = self.post_json(
|
||||||
|
'/heartbeat/%s' % node.uuid,
|
||||||
|
{'callback_url': 'url',
|
||||||
|
'agent_token': 'meow',
|
||||||
|
'agent_verify_ca': 'abcd'},
|
||||||
|
headers={api_base.Version.string: '1.67'},
|
||||||
|
expect_errors=True)
|
||||||
|
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
|
||||||
|
@ -7256,7 +7256,7 @@ class DoNodeAdoptionTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
|
|||||||
self.service.heartbeat(self.context, node.uuid, 'http://callback',
|
self.service.heartbeat(self.context, node.uuid, 'http://callback',
|
||||||
agent_token='magic')
|
agent_token='magic')
|
||||||
mock_heartbeat.assert_called_with(mock.ANY, mock.ANY,
|
mock_heartbeat.assert_called_with(mock.ANY, mock.ANY,
|
||||||
'http://callback', '3.0.0')
|
'http://callback', '3.0.0', None)
|
||||||
|
|
||||||
@mock.patch('ironic.drivers.modules.fake.FakeDeploy.heartbeat',
|
@mock.patch('ironic.drivers.modules.fake.FakeDeploy.heartbeat',
|
||||||
autospec=True)
|
autospec=True)
|
||||||
@ -7279,7 +7279,7 @@ class DoNodeAdoptionTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
|
|||||||
self.service.heartbeat(self.context, node.uuid, 'http://callback',
|
self.service.heartbeat(self.context, node.uuid, 'http://callback',
|
||||||
'1.4.1', agent_token='magic')
|
'1.4.1', agent_token='magic')
|
||||||
mock_heartbeat.assert_called_with(mock.ANY, mock.ANY,
|
mock_heartbeat.assert_called_with(mock.ANY, mock.ANY,
|
||||||
'http://callback', '1.4.1')
|
'http://callback', '1.4.1', None)
|
||||||
|
|
||||||
@mock.patch('ironic.drivers.modules.fake.FakeDeploy.heartbeat',
|
@mock.patch('ironic.drivers.modules.fake.FakeDeploy.heartbeat',
|
||||||
autospec=True)
|
autospec=True)
|
||||||
@ -7327,7 +7327,7 @@ class DoNodeAdoptionTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
|
|||||||
self.service.heartbeat(self.context, node.uuid, 'http://callback',
|
self.service.heartbeat(self.context, node.uuid, 'http://callback',
|
||||||
agent_token='a secret')
|
agent_token='a secret')
|
||||||
mock_heartbeat.assert_called_with(mock.ANY, mock.ANY,
|
mock_heartbeat.assert_called_with(mock.ANY, mock.ANY,
|
||||||
'http://callback', '3.0.0')
|
'http://callback', '3.0.0', None)
|
||||||
|
|
||||||
@mock.patch('ironic.drivers.modules.fake.FakeDeploy.heartbeat',
|
@mock.patch('ironic.drivers.modules.fake.FakeDeploy.heartbeat',
|
||||||
autospec=True)
|
autospec=True)
|
||||||
@ -7351,7 +7351,7 @@ class DoNodeAdoptionTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
|
|||||||
self.service.heartbeat(self.context, node.uuid, 'http://callback',
|
self.service.heartbeat(self.context, node.uuid, 'http://callback',
|
||||||
agent_token='a secret')
|
agent_token='a secret')
|
||||||
mock_heartbeat.assert_called_with(mock.ANY, mock.ANY,
|
mock_heartbeat.assert_called_with(mock.ANY, mock.ANY,
|
||||||
'http://callback', '3.0.0')
|
'http://callback', '3.0.0', None)
|
||||||
|
|
||||||
@mock.patch('ironic.drivers.modules.fake.FakeDeploy.heartbeat',
|
@mock.patch('ironic.drivers.modules.fake.FakeDeploy.heartbeat',
|
||||||
autospec=True)
|
autospec=True)
|
||||||
@ -7449,7 +7449,6 @@ class DoNodeAdoptionTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
|
|||||||
mock_spawn.reset_mock()
|
mock_spawn.reset_mock()
|
||||||
|
|
||||||
mock_spawn.side_effect = self._fake_spawn
|
mock_spawn.side_effect = self._fake_spawn
|
||||||
|
|
||||||
exc = self.assertRaises(messaging.rpc.ExpectedException,
|
exc = self.assertRaises(messaging.rpc.ExpectedException,
|
||||||
self.service.heartbeat, self.context,
|
self.service.heartbeat, self.context,
|
||||||
node.uuid, 'http://callback',
|
node.uuid, 'http://callback',
|
||||||
@ -7458,6 +7457,33 @@ class DoNodeAdoptionTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
|
|||||||
self.assertIn('TLS is required', str(exc.exc_info[1]))
|
self.assertIn('TLS is required', str(exc.exc_info[1]))
|
||||||
self.assertFalse(mock_heartbeat.called)
|
self.assertFalse(mock_heartbeat.called)
|
||||||
|
|
||||||
|
@mock.patch.object(conductor_utils, 'store_agent_certificate',
|
||||||
|
autospec=True)
|
||||||
|
@mock.patch('ironic.drivers.modules.fake.FakeDeploy.heartbeat',
|
||||||
|
autospec=True)
|
||||||
|
@mock.patch('ironic.conductor.manager.ConductorManager._spawn_worker',
|
||||||
|
autospec=True)
|
||||||
|
def test_heartbeat_with_agent_verify_ca(self, mock_spawn,
|
||||||
|
mock_heartbeat,
|
||||||
|
mock_store_cert):
|
||||||
|
node = obj_utils.create_test_node(
|
||||||
|
self.context, driver='fake-hardware',
|
||||||
|
provision_state=states.DEPLOYING,
|
||||||
|
target_provision_state=states.ACTIVE,
|
||||||
|
driver_internal_info={'agent_secret_token': 'a secret'})
|
||||||
|
mock_store_cert.return_value = '/path/to/crt'
|
||||||
|
|
||||||
|
self._start_service()
|
||||||
|
|
||||||
|
mock_spawn.reset_mock()
|
||||||
|
|
||||||
|
mock_spawn.side_effect = self._fake_spawn
|
||||||
|
self.service.heartbeat(self.context, node.uuid, 'http://callback',
|
||||||
|
agent_token='a secret', agent_verify_ca='abcd')
|
||||||
|
mock_heartbeat.assert_called_with(
|
||||||
|
mock.ANY, mock.ANY, 'http://callback', '3.0.0',
|
||||||
|
'/path/to/crt')
|
||||||
|
|
||||||
|
|
||||||
@mgr_utils.mock_record_keepalive
|
@mgr_utils.mock_record_keepalive
|
||||||
class DestroyVolumeConnectorTestCase(mgr_utils.ServiceSetUpMixin,
|
class DestroyVolumeConnectorTestCase(mgr_utils.ServiceSetUpMixin,
|
||||||
|
@ -516,7 +516,7 @@ class RPCAPITestCase(db_base.DbTestCase):
|
|||||||
node_id='fake-node',
|
node_id='fake-node',
|
||||||
callback_url='http://ramdisk.url:port',
|
callback_url='http://ramdisk.url:port',
|
||||||
agent_version=None,
|
agent_version=None,
|
||||||
version='1.49')
|
version='1.51')
|
||||||
|
|
||||||
def test_heartbeat_agent_token(self):
|
def test_heartbeat_agent_token(self):
|
||||||
self._test_rpcapi('heartbeat',
|
self._test_rpcapi('heartbeat',
|
||||||
@ -525,7 +525,7 @@ class RPCAPITestCase(db_base.DbTestCase):
|
|||||||
callback_url='http://ramdisk.url:port',
|
callback_url='http://ramdisk.url:port',
|
||||||
agent_version=None,
|
agent_version=None,
|
||||||
agent_token='xyz1',
|
agent_token='xyz1',
|
||||||
version='1.49')
|
version='1.51')
|
||||||
|
|
||||||
def test_destroy_volume_connector(self):
|
def test_destroy_volume_connector(self):
|
||||||
fake_volume_connector = db_utils.get_test_volume_connector()
|
fake_volume_connector = db_utils.get_test_volume_connector()
|
||||||
|
@ -9,6 +9,9 @@
|
|||||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@ -2136,3 +2139,52 @@ class GetAttachedVifTestCase(db_base.DbTestCase):
|
|||||||
vif, use = conductor_utils.get_attached_vif(self.port)
|
vif, use = conductor_utils.get_attached_vif(self.port)
|
||||||
self.assertEqual('1', vif)
|
self.assertEqual('1', vif)
|
||||||
self.assertEqual('inspecting', use)
|
self.assertEqual('inspecting', use)
|
||||||
|
|
||||||
|
|
||||||
|
class StoreAgentCertificateTestCase(db_base.DbTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(StoreAgentCertificateTestCase, self).setUp()
|
||||||
|
self.node = obj_utils.create_test_node(self.context,
|
||||||
|
driver='fake-hardware')
|
||||||
|
self.tempdir = tempfile.mkdtemp()
|
||||||
|
CONF.set_override('certificates_path', self.tempdir, group='agent')
|
||||||
|
self.fname = os.path.join(self.tempdir, '%s.crt' % self.node.uuid)
|
||||||
|
|
||||||
|
def test_store_new(self):
|
||||||
|
result = conductor_utils.store_agent_certificate(self.node,
|
||||||
|
'cert text')
|
||||||
|
self.assertEqual(self.fname, result)
|
||||||
|
with open(self.fname, 'rt') as fp:
|
||||||
|
self.assertEqual('cert text', fp.read())
|
||||||
|
|
||||||
|
def test_store_existing(self):
|
||||||
|
old_fname = os.path.join(self.tempdir, 'old.crt')
|
||||||
|
with open(old_fname, 'wt') as fp:
|
||||||
|
fp.write('cert text')
|
||||||
|
|
||||||
|
self.node.driver_internal_info['agent_verify_ca'] = old_fname
|
||||||
|
result = conductor_utils.store_agent_certificate(self.node,
|
||||||
|
'cert text')
|
||||||
|
self.assertEqual(old_fname, result)
|
||||||
|
self.assertFalse(os.path.exists(self.fname))
|
||||||
|
|
||||||
|
def test_no_change(self):
|
||||||
|
old_fname = os.path.join(self.tempdir, 'old.crt')
|
||||||
|
with open(old_fname, 'wt') as fp:
|
||||||
|
fp.write('cert text')
|
||||||
|
|
||||||
|
self.node.driver_internal_info['agent_verify_ca'] = old_fname
|
||||||
|
self.assertRaises(exception.InvalidParameterValue,
|
||||||
|
conductor_utils.store_agent_certificate,
|
||||||
|
self.node, 'new cert text')
|
||||||
|
self.assertFalse(os.path.exists(self.fname))
|
||||||
|
|
||||||
|
def test_take_over(self):
|
||||||
|
old_fname = os.path.join(self.tempdir, 'old.crt')
|
||||||
|
self.node.driver_internal_info['agent_verify_ca'] = old_fname
|
||||||
|
result = conductor_utils.store_agent_certificate(self.node,
|
||||||
|
'cert text')
|
||||||
|
self.assertEqual(self.fname, result)
|
||||||
|
with open(self.fname, 'rt') as fp:
|
||||||
|
self.assertEqual('cert text', fp.read())
|
||||||
|
@ -258,6 +258,28 @@ class TestAgentClient(base.TestCase):
|
|||||||
timeout=60,
|
timeout=60,
|
||||||
verify='/path/to/agent.crt')
|
verify='/path/to/agent.crt')
|
||||||
|
|
||||||
|
def test__command_verify_internal(self):
|
||||||
|
response_data = {'status': 'ok'}
|
||||||
|
self.client.session.post.return_value = MockResponse(response_data)
|
||||||
|
method = 'standby.run_image'
|
||||||
|
image_info = {'image_id': 'test_image'}
|
||||||
|
params = {'image_info': image_info}
|
||||||
|
|
||||||
|
self.node.driver_info['agent_verify_ca'] = True
|
||||||
|
self.node.driver_internal_info['agent_verify_ca'] = '/path/to/crt'
|
||||||
|
|
||||||
|
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,
|
||||||
|
verify='/path/to/crt')
|
||||||
|
|
||||||
@mock.patch('time.sleep', lambda seconds: None)
|
@mock.patch('time.sleep', lambda seconds: None)
|
||||||
def test__command_poll(self):
|
def test__command_poll(self):
|
||||||
response_data = {'status': 'ok'}
|
response_data = {'status': 'ok'}
|
||||||
|
4
releasenotes/notes/agent-verify-ca-6efa3dfc469bab02.yaml
Normal file
4
releasenotes/notes/agent-verify-ca-6efa3dfc469bab02.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
features:
|
||||||
|
- |
|
||||||
|
Adds an ability to accept a custom TLS certificate in the heartbeat API.
|
Loading…
Reference in New Issue
Block a user