New communication interface with bareon instance

Set of tools that represent some sort of API to communicate with bareon
instance. This API use vendor_passthru ironic API to "catch" request
from bareon instance to ironic API. So bareon-ironic driver can receive
bareon insnstance requests. This is existing communication channel.

Before it was used to receive notification from bareon instance about
successfull node load.

Now this channel is extended to send "generic" tasks(step) from
bareon-ironic driver to bareon instance. Right now only one task(step)
is used - step to inject SSH key into bareon instance.

This new "steps" interface allow to refuse from preinstalled SSH key
in bareon instance, right now. And in future it allow to refuse from SSH
communication between bareon-ironic and bareon instance...

Change-Id: I0791807c7cb3dba70c71c4f46e5eddf01da76cdd
This commit is contained in:
Dmitry Bogun 2017-02-16 19:05:47 +02:00
parent 2155173777
commit 7468264200
6 changed files with 393 additions and 77 deletions

View File

@ -1,7 +1,5 @@
# #
# Copyright 2015 Mirantis, Inc. # Copyright 2017 Cray Inc., All Rights Reserved
#
# Copyright 2016 Cray Inc., All Rights Reserved
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may # 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 # not use this file except in compliance with the License. You may obtain
@ -19,8 +17,13 @@
Bareon deploy driver. Bareon deploy driver.
""" """
import abc
import inspect
import json import json
import os import os
import pprint
import stat
import sys
import eventlet import eventlet
import pkg_resources import pkg_resources
@ -35,7 +38,6 @@ from ironic.common import boot_devices
from ironic.common import exception from ironic.common import exception
from ironic.common import states from ironic.common import states
from ironic.common.i18n import _ from ironic.common.i18n import _
from ironic.common.i18n import _LE
from ironic.common.i18n import _LI from ironic.common.i18n import _LI
from ironic.conductor import task_manager from ironic.conductor import task_manager
from ironic.conductor import utils as manager_utils from ironic.conductor import utils as manager_utils
@ -496,16 +498,13 @@ class BareonVendor(base.VendorInterface):
:param task: a TaskManager instance :param task: a TaskManager instance
:param method: method to be validated :param method: method to be validated
""" """
if method == 'exec_actions': if method in ('exec_actions', 'deploy_steps'):
return return
if method == 'switch_boot': if method == 'switch_boot':
self.validate_switch_boot(task, **kwargs) self.validate_switch_boot(task, **kwargs)
return return
if not kwargs.get('status'):
raise exception.MissingParameterValue(_('Unknown Bareon status'
' on a node.'))
if not kwargs.get('address'): if not kwargs.get('address'):
raise exception.MissingParameterValue(_('Bareon must pass ' raise exception.MissingParameterValue(_('Bareon must pass '
'address of a node.')) 'address of a node.'))
@ -520,22 +519,46 @@ class BareonVendor(base.VendorInterface):
raise exception.MissingParameterValue(_('No ssh user info ' raise exception.MissingParameterValue(_('No ssh user info '
'passed.')) 'passed.'))
@base.passthru(['GET', 'POST'], async=False)
def deploy_steps(self, task, **data):
http_method = data.pop('http_method')
driver_info = _NodeDriverInfoAdapter(task.node)
if http_method == 'GET':
ssh_keys_step = _InjectSSHKeyStepRequest(task, driver_info)
return ssh_keys_step()
steps_mapping = _DeployStepMapping()
data = _DeployStepsAdapter(data)
try:
request_cls = steps_mapping.name_to_step[data.action]
except KeyError:
if data.action is not None:
raise RuntimeError(
'There is no name mapping for deployment step: '
'{!r}'.format(data.action))
message = (
'Bareon\'s callback service have failed with internall error')
if data.status_details:
message += '\nFailure details: {}'.format(
pprint.pformat(data.status_details))
# TODO(dbogun): add support for existing log extraction mechanism
deploy_utils.set_failed_state(
task, message, collect_logs=False)
else:
handler = request_cls.result_handler(
task, driver_info, data)
handler()
return {'url': None}
@base.passthru(['POST']) @base.passthru(['POST'])
@task_manager.require_exclusive_lock @task_manager.require_exclusive_lock
def pass_deploy_info(self, task, **kwargs): def pass_deploy_info(self, task, **kwargs):
"""Continues the deployment of baremetal node.""" """Continues the deployment of baremetal node."""
node = task.node node = task.node
task.process_event('resume') task.process_event('resume')
err_msg = _('Failed to continue deployment with Bareon.')
agent_status = kwargs.get('status')
if agent_status != 'ready':
LOG.error(_LE('Deploy failed for node %(node)s. Bareon is not '
'in ready state, error: %(error)s'),
{'node': node.uuid,
'error': kwargs.get('error_message')})
deploy_utils.set_failed_state(task, err_msg)
return
driver_info = _NodeDriverInfoAdapter(task.node) driver_info = _NodeDriverInfoAdapter(task.node)
@ -618,7 +641,7 @@ class BareonVendor(base.VendorInterface):
def _deploy_failed(self, task, msg): def _deploy_failed(self, task, msg):
LOG.error(msg) LOG.error(msg)
deploy_utils.set_failed_state(task, msg) deploy_utils.set_failed_state(task, msg, collect_logs=False)
def _check_bareon_version(self, ssh, node_uuid): def _check_bareon_version(self, ssh, node_uuid):
try: try:
@ -972,62 +995,193 @@ def get_tenant_images_json_path(node):
"tenant_images.json") "tenant_images.json")
# TODO(dbogun): handle all driver_info keys class _AbstractAdapter(object):
class _NodeDriverInfoAdapter(object): def __init__(self, data):
ssh_port = None self._raw = data
# TODO(dbogun): check API way to defined access defaults
ssh_login = 'root'
ssh_key = '/etc/ironic/bareon_key'
entry_point = 'bareon-provision'
def __init__(self, node): def _extract_fields(self, mapping):
self.node = node for attr, name in mapping:
self._raw = self.node.driver_info
self._errors = []
self._extract_ssh_port()
self._extract_optional_parameters()
self._validate()
if self._errors:
raise exception.InvalidParameterValue(_(
'The following errors were encountered while parsing '
'driver_info:\n {}').format(' \n'.join(self._errors)))
def _extract_ssh_port(self):
key = 'bareon_ssh_port'
try:
port = self._raw[key]
port = int(port)
if not 0 < port < 65536:
raise ValueError(
'Port number {} is outside of allowed range'.format(port))
except KeyError:
port = None
except ValueError as e:
self._errors.append('{}: {}'.format(key, str(e)))
return
self.ssh_port = port
def _extract_optional_parameters(self):
for attr, name in (
('ssh_key', 'bareon_key_filename'),
('ssh_login', 'bareon_username'),
('entry_point', 'bareon_deploy_script')):
try: try:
value = self._raw[name] value = self._raw[name]
except KeyError: except KeyError:
continue continue
setattr(self, attr, value) setattr(self, attr, value)
class _DeployStepsAdapter(_AbstractAdapter):
action = action_payload = None
status = status_details = None
def __init__(self, data):
super(_DeployStepsAdapter, self).__init__(data)
self._extract_fields({
'action': 'name',
'status': 'status'}.items())
self.action_payload = self._raw.get('payload', {})
self.status_details = self._raw.get('status-details', '')
# TODO(dbogun): handle all driver_info keys
class _NodeDriverInfoAdapter(_AbstractAdapter):
_exc_prefix = 'driver_info: '
ssh_port = None
# TODO(dbogun): check API way to defined access defaults
ssh_login = 'root'
ssh_key = '/etc/ironic/bareon_key'
ssh_key_pub = None
entry_point = 'bareon-provision'
def __init__(self, node):
super(_NodeDriverInfoAdapter, self).__init__(node.driver_info)
self.node = node
self._extract_fields({
'ssh_port': 'bareon_ssh_port',
'ssh_key': 'bareon_key_filename',
'ssh_key_pub': 'bareon_public_key_filename',
'ssh_login': 'bareon_username',
'entry_point': 'bareon_deploy_script'}.items())
self._process()
self._validate()
def _process(self):
if self.ssh_key_pub is None:
self.ssh_key_pub = '{}.pub'.format(self.ssh_key)
if self.ssh_port is not None:
self.ssh_port = int(self.ssh_port)
if not 0 < self.ssh_port < 65536:
raise exception.InvalidParameterValue(
'{}Invalid SSH port number({}) is outside of allowed '
'range.'.format(self._exc_prefix, 'bareon_ssh_port'))
def _validate(self): def _validate(self):
self._validate_ssh_key() self._validate_ssh_key()
def _validate_ssh_key(self): def _validate_ssh_key(self):
missing = []
pkey_stats = None
for idx, target in enumerate((self.ssh_key, self.ssh_key_pub)):
try:
target_stat = os.stat(target)
if not idx:
pkey_stats = target_stat
except OSError as e:
missing.append(e)
missing = ['{0.filename}: {0.strerror}'.format(x) for x in missing]
if missing:
raise exception.InvalidParameterValue(
'{}Unable to use SSH key:\n{}'.format(
self._exc_prefix, '\n'.join(missing)))
issue = None
if not stat.S_ISREG(pkey_stats.st_mode):
issue = 'SSH private key {!r} is not a regular file.'.format(
self.ssh_key)
if pkey_stats.st_mode & 0o177:
issue = 'Permissions {} for {!r} are too open.'.format(
oct(pkey_stats.st_mode & 0o777), self.ssh_key)
if issue:
raise exception.InvalidParameterValue(issue)
@six.add_metaclass(abc.ABCMeta)
class _AbstractDeployStepHandler(object):
def __init__(self, task, driver_info):
self.task = task
self.driver_info = driver_info
@abc.abstractmethod
def __call__(self):
pass
@six.add_metaclass(abc.ABCMeta)
class _AbstractDeployStepResult(_AbstractDeployStepHandler):
def __init__(self, task, driver_info, step_info):
super(_AbstractDeployStepResult, self).__init__(task, driver_info)
self.step_info = step_info
def __call__(self):
if not self.step_info.status:
self._handle_error()
return
return self._handle()
@abc.abstractmethod
def _handle(self):
pass
def _handle_error(self):
message = 'Deployment step "{}" have failed: {}'.format(
self.step_info.action, self.step_info.status_details)
# TODO(dbogun): add support for existing log extraction mechanism
deploy_utils.set_failed_state(self.task, message, collect_logs=False)
@six.add_metaclass(abc.ABCMeta)
class _AbstractDeployStepRequest(_AbstractDeployStepHandler):
@abc.abstractproperty
def name(self):
pass
@abc.abstractproperty
def result_handler(self):
pass
def __call__(self):
payload = self._handle()
return {
'name': self.name,
'payload': payload}
@abc.abstractmethod
def _handle(self):
pass
class _InjectSSHKeyStepResult(_AbstractDeployStepResult):
def _handle(self):
pass
class _InjectSSHKeyStepRequest(_AbstractDeployStepRequest):
name = 'inject-ssh-keys'
result_handler = _InjectSSHKeyStepResult
def _handle(self):
try: try:
open(self.ssh_key).close() with open(self.driver_info.ssh_key_pub) as data:
ssh_key = data.read()
except IOError as e: except IOError as e:
self._errors.append( raise bareon_exception.DeployTaskError(
'Unable to use "{key}" as ssh private key: ' name=type(self).__name__, details=e)
'{e.strerror} (errno={e.errno})'.format(key=self.ssh_key, e=e))
return {
'ssh-keys': {
self.driver_info.ssh_login: [ssh_key]}}
class _DeployStepMapping(object):
def __init__(self):
self.steps = []
base_cls = _AbstractDeployStepRequest
target = sys.modules[__name__]
for name in dir(target):
value = getattr(target, name)
if (inspect.isclass(value)
and issubclass(value, base_cls)
and value is not base_cls):
self.steps.append(value)
self.name_to_step = {}
self.step_to_name = {}
for task in self.steps:
self.name_to_step[task.name] = task
self.step_to_name[task] = task.name

View File

@ -1,5 +1,5 @@
# #
# Copyright 2016 Cray Inc., All Rights Reserved # Copyright 2017 Cray Inc., All Rights Reserved
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may # 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 # not use this file except in compliance with the License. You may obtain
@ -15,6 +15,8 @@
"""Bareon driver exceptions""" """Bareon driver exceptions"""
import pprint
from ironic.common import exception from ironic.common import exception
from ironic.common.i18n import _ from ironic.common.i18n import _
@ -46,3 +48,19 @@ class DeployTerminationSucceed(exception.IronicException):
class BootSwitchFailed(exception.IronicException): class BootSwitchFailed(exception.IronicException):
message = _("Boot switch failed. Error: %(error)s") message = _("Boot switch failed. Error: %(error)s")
class DeployProtocolError(exception.IronicException):
_msg_fmt = _('Corrupted deploy protocol message: %(details)s\n%(payload)s')
def __init__(self, message=None, **substitute):
payload = substitute.pop('message', {})
payload = pprint.pformat(payload)
super(DeployProtocolError, self).__init__(
message, payload=payload, **substitute)
class DeployTaskError(exception.IronicException):
_msg_fmt = _(
'Node deploy task "%(name)s" have failed: %(details)s')

View File

@ -1,5 +1,5 @@
# #
# Copyright 2016 Cray Inc., All Rights Reserved # Copyright 2017 Cray Inc., All Rights Reserved
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may # 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 # not use this file except in compliance with the License. You may obtain
@ -13,6 +13,8 @@
# 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 fixtures
from ironic.tests import base from ironic.tests import base
from ironic.tests.unit.db import base as db_base from ironic.tests.unit.db import base as db_base
@ -22,4 +24,9 @@ class AbstractTestCase(base.TestCase):
class AbstractDBTestCase(db_base.DbTestCase): class AbstractDBTestCase(db_base.DbTestCase):
pass def setUp(self):
super(AbstractDBTestCase, self).setUp()
self.config(enabled_drivers=['bare_swift_ssh'])
self.temp_dir = self.useFixture(fixtures.TempDir())

View File

@ -1,5 +1,5 @@
# #
# Copyright 2016 Cray Inc., All Rights Reserved # Copyright 2017 Cray Inc., All Rights Reserved
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may # 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 # not use this file except in compliance with the License. You may obtain
@ -14,6 +14,7 @@
# under the License. # under the License.
import json import json
import os
import fixtures import fixtures
import mock import mock
@ -32,13 +33,6 @@ SWIFT_DEPLOY_IMAGE_MODE = resources.PullSwiftTempurlResource.MODE
class BareonBaseTestCase(base.AbstractDBTestCase): class BareonBaseTestCase(base.AbstractDBTestCase):
def setUp(self):
super(BareonBaseTestCase, self).setUp()
self.config(enabled_drivers=['bare_swift_ssh'])
self.temp_dir = self.useFixture(fixtures.TempHomeDir())
@mock.patch.object(bareon_base.BareonDeploy, @mock.patch.object(bareon_base.BareonDeploy,
"_get_deploy_driver", "_get_deploy_driver",
mock.Mock(return_value="test_driver")) mock.Mock(return_value="test_driver"))
@ -239,6 +233,105 @@ class TestDeploymentConfigValidator(base.AbstractTestCase):
self.tmpdir.join('corrupted.json')) self.tmpdir.join('corrupted.json'))
class TestVendorDeployment(base.AbstractDBTestCase):
temp_dir = None
ssh_key = ssh_key_pub = node = None
ssh_key_payload = 'SSH KEY (private)'
ssh_key_pub_payload = 'SSH KEY (public)'
def test_deploy_steps_get(self):
vendor_iface = bareon_base.BareonVendor()
args = {
'http_method': 'GET'}
with task_manager.acquire(
self.context, self.node.uuid,
driver_name='bare_swift_ssh') as task:
step = vendor_iface.deploy_steps(task, **args)
expected_step = {
'name': 'inject-ssh-keys',
'payload': {
'ssh-keys': {
'root': [self.ssh_key_pub_payload]}}}
self.assertEqual(expected_step, step)
@mock.patch.object(bareon_base._InjectSSHKeyStepResult, '_handle')
def test_deploy_steps_post(self, result_handler):
vendor_iface = bareon_base.BareonVendor()
args = {
'http_method': 'POST',
'name': 'inject-ssh-keys',
'status': True}
with task_manager.acquire(
self.context, self.node.uuid,
driver_name='bare_swift_ssh') as task:
step = vendor_iface.deploy_steps(task, **args)
expected_step = {'url': None}
self.assertEqual(expected_step, step)
self.assertEqual(1, result_handler.call_count)
@mock.patch('ironic.drivers.modules.deploy_utils.set_failed_state')
def test_deploy_steps_post_fail(self, set_failed_state):
vendor_iface = bareon_base.BareonVendor()
args = {
'http_method': 'POST',
'name': 'inject-ssh-keys',
'status': False,
'status-details': 'Error during step execution.'}
with task_manager.acquire(
self.context, self.node.uuid,
driver_name='bare_swift_ssh') as task:
step = vendor_iface.deploy_steps(task, **args)
expected_step = {'url': None}
self.assertEqual(expected_step, step)
self.assertEqual(1, set_failed_state.call_count)
@mock.patch('ironic.drivers.modules.deploy_utils.set_failed_state')
def test_deploy_steps_post_fail_unbinded(self, set_failed_state):
vendor_iface = bareon_base.BareonVendor()
args = {
'http_method': 'POST',
'name': None,
'status': False,
'status-details': 'Error during step execution.'}
with task_manager.acquire(
self.context, self.node.uuid,
driver_name='bare_swift_ssh') as task:
step = vendor_iface.deploy_steps(task, **args)
expected_step = {'url': None}
self.assertEqual(expected_step, step)
self.assertEqual(1, set_failed_state.call_count)
def setUp(self):
super(TestVendorDeployment, self).setUp()
self.ssh_key = self.temp_dir.join('bareon-ssh.key')
self.ssh_key_pub = self.ssh_key + '.pub'
open(self.ssh_key, 'wt').write('SSH KEY (private)')
open(self.ssh_key_pub, 'wt').write('SSH KEY (public)')
os.chmod(self.ssh_key, 0o600)
self.node = test_utils.create_test_node(
self.context,
driver_info={
'bareon_key_filename': self.ssh_key,
'bareon_username': 'root'})
class DummyError(Exception): class DummyError(Exception):
@property @property
def message(self): def message(self):

View File

@ -0,0 +1,44 @@
From 9787aa4765db729d05d8c9acff19adb4f9181189 Mon Sep 17 00:00:00 2001
From: Dmitry Bogun <dbogun@mirantis.com>
Date: Mon, 20 Feb 2017 16:31:34 +0200
Subject: [PATCH 3/3] Allow access to bareon-ironic vendor passthru API
endpoints
Bareon-ironic driver have implemented extended vendor passthru
"protocol" used in communication between bareon-ironic driver and bareon
instance. This "protoco" use one more http endpoint. This endpoint must
be treated in same was as already existed enpoint
vendor_passthru/pass_deploy_info
---
ironic/api/config.py | 1 +
ironic/api/controllers/v1/node.py | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/ironic/api/config.py b/ironic/api/config.py
index 49231d5..8627dc2 100644
--- a/ironic/api/config.py
+++ b/ironic/api/config.py
@@ -36,6 +36,7 @@ app = {
'/v1/drivers/[a-z0-9_]*/vendor_passthru/lookup',
'/v1/nodes/[a-z0-9\-]+/vendor_passthru/heartbeat',
'/v1/nodes/[a-z0-9\-]+/vendor_passthru/pass_deploy_info',
+ '/v1/nodes/[a-z0-9\-]+/vendor_passthru/deploy_steps',
],
}
diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py
index d93c2c4..9422772 100644
--- a/ironic/api/controllers/v1/node.py
+++ b/ironic/api/controllers/v1/node.py
@@ -980,7 +980,7 @@ class NodeVendorPassthruController(rest.RestController):
:param data: body of data to supply to the specified method.
"""
cdict = pecan.request.context.to_dict()
- if method in ('heartbeat', 'pass_deploy_info'):
+ if method in ('heartbeat', 'pass_deploy_info', 'deploy_steps'):
policy.authorize('baremetal:node:ipa_heartbeat', cdict, cdict)
else:
policy.authorize('baremetal:node:vendor_passthru', cdict, cdict)
--
2.10.2

View File

@ -1,6 +1,6 @@
[metadata] [metadata]
name = bareon-ironic name = bareon-ironic
version = 1.0.0 version = 1.0.1
author = Cray Inc. author = Cray Inc.
summary = Bareon-based deployment driver for Ironic summary = Bareon-based deployment driver for Ironic
classifier = classifier =