Improve ironic-callback

Add support to new bareon-ironic communication "protocol" - extension
for vendor passthru API. This protocol allow to receive generic
tasks(steps) from bareon-ironic driver, process them on bareon "side"
and send back results.

Right now only one step is implemented - step to inject SSH key.

Change-Id: I9ea828b24085fa72df470eef41ad32d9096f6b40
This commit is contained in:
Dmitry Bogun 2017-02-16 19:05:14 +02:00
parent 62b05760ef
commit 1f33e227f8
4 changed files with 552 additions and 53 deletions

View File

@ -1,4 +1,5 @@
# Copyright 2015 Mirantis, Inc.
#
# Copyright 2017 Cray Inc. All Rights Reserved.
#
# 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
@ -12,72 +13,312 @@
# License for the specific language governing permissions and limitations
# under the License.
import json
import abc
import collections
import inspect
import sys
import time
import traceback
import requests
import six
from bareon.utils import utils
def _process_error(message):
sys.stderr.write(message)
sys.stderr.write('\n')
sys.exit(1)
def main():
"""Script informs Ironic that bootstrap loading is done.
class IronicCallbackApp(object):
"""Communicate to ironic-conductor to complete bootstrap
There are three mandatory parameters in kernel command line.
Ironic prepares these two:
'ironic_api_url' - URL of Ironic API service,
'deployment_id' - UUID of the node in Ironic.
Passed from PXE boot loader:
'BOOTIF' - MAC address of the boot interface,
ironic_api_url - URL of Ironic API service,
deployment_id - UUID of the node in Ironic.
And the last one passed by PXE boot loader:
BOOTIF - MAC address of the boot interface
To get more details about interaction with PXEE boot loader visit
http://www.syslinux.org/wiki/index.php/SYSLINUX#APPEND_-
Example: api_url=http://192.168.122.184:6385
deployment_id=eeeeeeee-dddd-cccc-bbbb-aaaaaaaaaaaa
BOOTIF=01-88-99-aa-bb-cc-dd
"""
kernel_params = utils.parse_kernel_cmdline()
api_url = kernel_params.get('ironic_api_url')
deployment_id = kernel_params.get('deployment_id')
if api_url is None or deployment_id is None:
_process_error('Mandatory parameter ("ironic_api_url" or '
'"deployment_id") is missing.')
bootif = kernel_params.get('BOOTIF')
if bootif is None:
_process_error('Cannot define boot interface, "BOOTIF" parameter is '
'missing.')
@classmethod
def entry_point(cls):
app = cls()
return app()
# The leading `01-' denotes the device type (Ethernet) and is not a part of
# the MAC address
boot_mac = bootif[3:].replace('-', ':')
for n in range(10):
boot_ip = utils.get_interface_ip(boot_mac)
if boot_ip is not None:
break
time.sleep(10)
else:
_process_error('Cannot find IP address of boot interface.')
def __init__(self):
self.kernel_cli_data = _KernelCLIAdapter()
data = {"address": boot_ip,
"status": "ready",
"error_message": "no errors"}
self.base_url = six.moves.urllib.parse.urljoin(
self.kernel_cli_data.api_url,
'v1/nodes/{}/vendor_passthru/'.format(
self.kernel_cli_data.node_uuid))
self.root_url = six.moves.urllib.parse.urljoin(
self.base_url, 'deploy_steps')
passthru = '%(api-url)s/v1/nodes/%(deployment_id)s/vendor_passthru' \
'/pass_deploy_info' % {'api-url': api_url,
'deployment_id': deployment_id}
try:
resp = requests.post(passthru, data=json.dumps(data),
headers={'Content-Type': 'application/json',
'Accept': 'application/json'})
except Exception as e:
_process_error(str(e))
self.http_session = requests.Session()
self.http_session.headers['Accept'] = 'application/json'
if resp.status_code != 202:
_process_error('Wrong status code %d returned from Ironic API' %
resp.status_code)
self.steps_mapping = _StepMapping()
self.work_queue = [self.root_url]
def __call__(self):
rcode = 1
try:
self._do_deploy()
self._do_complete_notify()
except ControlledFail as e:
six.print_('Deployment is incomplete!')
if e.message:
six.print_(e.message)
except Exception:
error = 'Deployment handler internal error!'
error = '\n\n'.join((error, traceback.format_exc()))
six.print_(error)
self.http_session.post(
self.root_url, json=self._make_report(error=error))
else:
rcode = 0
return rcode
def _do_deploy(self):
while self.work_queue:
url = self.work_queue[0]
self._do_step(url)
self.work_queue.pop(0)
def _do_step(self, url):
request_data = self._step_request(url)
step = self._make_step(request_data)
report = self._make_report(error=RuntimeError('unhandled exception'))
try:
results = step()
except Exception as e:
report = self._make_report(step=step, error=e)
raise ControlledFail()
else:
report = self._make_report(step=step, payload=results)
finally:
response_data = self.http_session.post(url, json=report)
response_data = response_data.json()
response_data = _ResponseDataAdapter(response_data)
if response_data.url:
self.work_queue.append(response_data.url)
def _do_complete_notify(self):
url = six.moves.urllib.parse.urljoin(self.base_url, 'pass_deploy_info')
data = {
'address': self.kernel_cli_data.boot_ip,
'error_message': 'no errors'}
self.http_session.post(url, json=data).raise_for_status()
def _step_request(self, url):
reply = self.http_session.get(url)
reply.raise_for_status()
return _RequestDataAdapter(reply.json())
def _make_step(self, data):
try:
step_cls = self.steps_mapping.name_to_step[data.action]
except KeyError:
raise InadequateRequirementError(
'There is no deployment step "{}"'.format(data.action))
return step_cls(data.payload)
@staticmethod
def _make_report(step=None, payload=None, error=None):
name = None
if step is not None:
name = step.name
report = {
'name': name,
'status': bool(error is None)}
if payload is not None:
report['payload'] = payload
if error is not None:
report['status-details'] = str(error)
return report
class _AbstractAdapter(object):
def __init__(self, data):
self._raw = data
def _extract_fields(self, mapping, is_mandatory=False):
missing = set()
for attr, name in mapping:
try:
value = self._raw[name]
except KeyError:
missing.add(name)
continue
setattr(self, attr, value)
if is_mandatory and missing:
raise self._make_missing_exception(missing)
@staticmethod
def _make_missing_exception(missing):
if isinstance(missing, six.text_type):
missing = [missing]
elif not isinstance(missing, collections.Sequence):
missing = [missing]
else:
missing = [str(missing)]
return ValueError(
'Mandatory fields are missing: {}'.format(
', '.join(sorted(missing))))
class _KernelCLIAdapter(_AbstractAdapter):
BOOT_IP_LOOKUP_ATTEMPTS = 10
BOOT_IP_RETRIES_DELAY = 10
api_url = None
node_uuid = None
boot_hw_address = None
def __init__(self):
super(_KernelCLIAdapter, self).__init__(utils.parse_kernel_cmdline())
self._extract_fields({
'api_url': 'ironic_api_url',
'node_uuid': 'deployment_id',
'boot_hw_address': 'BOOTIF'}.items(), is_mandatory=True)
self.api_url = self.api_url.rstrip('/') + '/'
# boot_hw_address extracted from BOOTIF kernel argument. The BOOTIF is
# filled by PXE boot loader using following format:
# <hardware-type>-<hardware-address>
# In case of ethernet network, hardware-type is "01". And
# hardware-address is a NIC's mac address by with '-' as octet
# separator.
#
# See syslinux documentation for more details.
#
# To get mac address in it's usual shape - cut out '01-' and
# replace '-' characters with ':'.
self.boot_hw_address = self.boot_hw_address[3:].replace('-', ':')
self._extract_boot_ip()
def _extract_boot_ip(self):
for n in range(self.BOOT_IP_LOOKUP_ATTEMPTS):
ip = utils.get_interface_ip(self.boot_hw_address)
if ip is not None:
break
time.sleep(self.BOOT_IP_RETRIES_DELAY)
else:
raise ControlledFail('Cannot find IP address of boot interface.')
self.boot_ip = ip
class _RequestDataAdapter(_AbstractAdapter):
action = None
payload = None
def __init__(self, data):
super(_RequestDataAdapter, self).__init__(data)
self._extract_fields({
'action': 'name',
'payload': 'payload'}.items(), is_mandatory=True)
class _ResponseDataAdapter(_AbstractAdapter):
url = None
def __init__(self, data):
super(_ResponseDataAdapter, self).__init__(data)
self._extract_fields({'url': 'url'}.items(), is_mandatory=True)
class _StepMapping(object):
def __init__(self):
self.steps = []
base_cls = _AbstractStep
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 step in self.steps:
self.name_to_step[step.name] = step
self.step_to_name[step] = step.name
@six.add_metaclass(abc.ABCMeta)
class _AbstractStep(_AbstractAdapter):
@abc.abstractproperty
def name(self):
pass
def __init__(self, payload):
super(_AbstractStep, self).__init__(payload)
def __call__(self):
return self._handle()
@abc.abstractmethod
def _handle(self):
pass
class _InjectSSHKeysStep(_AbstractStep):
name = 'inject-ssh-keys'
def __init__(self, payload):
super(_InjectSSHKeysStep, self).__init__(payload)
self.user_ssh_keys = {}
try:
self._extract_keys_map(self._raw['ssh-keys'])
except KeyError as e:
raise self._make_missing_exception(e)
def _extract_keys_map(self, raw_map):
for user, keys in raw_map.items():
if isinstance(keys, collections.Sequence):
pass
elif all(isinstance(x, six.text_type) for x in keys):
pass
else:
raise ValueError(
'Invalid user\'s SSH key definition: user={!r}, '
'keys={!r}'.format(user, keys))
self.user_ssh_keys[user] = keys
def _handle(self):
for login in self.user_ssh_keys:
user_keys = utils.UsersSSHAuthorizedKeys(login)
for key in self.user_ssh_keys[login]:
user_keys.add(key)
user_keys.sync()
class AbstractError(Exception):
pass
class InadequateRequirementError(AbstractError):
pass
class ControlledFail(AbstractError):
pass

View File

@ -0,0 +1,157 @@
#
# Copyright 2017 Cray Inc. All Rights Reserved.
#
# 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 mock
import unittest2
from bareon.cmd import ironic_callback
class TestIronicCallbackApp(unittest2.TestCase):
@mock.patch('bareon.cmd.ironic_callback.IronicCallbackApp._do_step')
def test_workflow(self, do_step):
rcode = self.app()
self.assertEqual(0, rcode)
do_step.assert_called_once_with(
'{}/deploy_steps'.format(self.root_url))
self.mock_http_session.post.assert_called_once_with(
'{}/pass_deploy_info'.format(self.root_url),
json={
'address': self.mock_get_interface_ip.return_value,
'error_message': 'no errors'})
@mock.patch('bareon.cmd.ironic_callback.IronicCallbackApp._do_step')
@mock.patch('bareon.cmd.ironic_callback.IronicCallbackApp._make_report')
def test_workflow_fail(self, make_report, do_step):
do_step.side_effect = RuntimeError()
rcode = self.app()
self.assertEqual(1, rcode)
make_report.assert_called_once_with(error=mock.ANY)
self.mock_http_session.post.assert_called_once_with(
'{}/deploy_steps'.format(self.root_url),
json=make_report.return_value)
@mock.patch('bareon.cmd.ironic_callback.IronicCallbackApp._do_step')
def test_workflow_fail_controlled(self, do_step):
do_step.side_effect = ironic_callback.ControlledFail()
rcode = self.app()
self.assertEqual(1, rcode)
self.assertEqual(0, self.mock_http_session.post.call_count)
@mock.patch('bareon.cmd.ironic_callback.IronicCallbackApp._do_step')
def test_do_deploy(self, do_step):
do_step.side_effect = _AppUrlAddSide(
self.app,
'http://test.local/A',
'http://test.local/B')
self.app()
self.assertEqual([
mock.call('{}/deploy_steps'.format(self.root_url)),
mock.call('http://test.local/A'),
mock.call('http://test.local/B')
], do_step.call_args_list)
@mock.patch('bareon.cmd.ironic_callback._InjectSSHKeysStep._handle')
def test_step_inject_ssh_key(self, step_handler):
self.mock_http_session.get.return_value.json.return_value = {
'name': 'inject-ssh-keys',
'payload': {
'ssh-keys': {
'root': ['SSH KEY (public)']}}}
self.mock_http_session.post.return_value.json.return_value = {
'url': None}
step_handler.return_value = {'step-results': 'dummy'}
self.app()
step_handler.assert_called_once_with()
self.mock_http_session.get.assert_called_once_with(
'{}/deploy_steps'.format(self.root_url))
self.mock_http_session.post.assert_has_calls([
mock.call(
'{}/deploy_steps'.format(self.root_url), json={
'name': 'inject-ssh-keys',
'status': True,
'payload': step_handler.return_value})], any_order=True)
@mock.patch('bareon.cmd.ironic_callback.IronicCallbackApp._make_step')
@mock.patch('bareon.cmd.ironic_callback.IronicCallbackApp._make_report')
def test_step_fail(self, make_report, make_step):
self.mock_http_session.get.return_value.json.return_value = {
'name': 'inject-ssh-keys',
'payload': {
'ssh-keys': {
'root': ['SSH KEY (public)']}}}
self.mock_http_session.post.return_value.json.return_value = {
'url': None}
error = RuntimeError()
make_step.return_value.side_effect = error
self.app()
make_step.return_value.assert_called_once_with()
make_report.assert_has_calls([
mock.call(error=mock.ANY),
mock.call(step=mock.ANY, error=error)])
def setUp(self):
self.mock_parse_kernel_cmdline = mock.Mock()
self.mock_parse_kernel_cmdline.return_value = {
'ironic_api_url': 'http://api.ironic.local:6385/',
'deployment_id': 'ironic-node-uuid',
'BOOTIF': '01-01-02-03-04-05-06'}
self.mock_get_interface_ip = mock.Mock()
self.mock_get_interface_ip.return_value = '127.0.0.2'
self.mock_http_session = mock.Mock()
for path, m in (
('bareon.utils.utils.'
'parse_kernel_cmdline', self.mock_parse_kernel_cmdline),
('bareon.utils.utils.'
'get_interface_ip', self.mock_get_interface_ip)):
patch = mock.patch(path, m)
patch.start()
self.addCleanup(patch.stop)
self.app = ironic_callback.IronicCallbackApp()
patch = mock.patch.object(
self.app, 'http_session', self.mock_http_session)
patch.start()
self.addCleanup(patch.stop)
self.root_url = '{}v1/nodes/{}/vendor_passthru'.format(
self.mock_parse_kernel_cmdline.return_value['ironic_api_url'],
self.mock_parse_kernel_cmdline.return_value['deployment_id'])
class _AppUrlAddSide(object):
def __init__(self, app, *urls):
self.app = app
self.urls = iter(urls)
def __call__(self, *args, **kwargs):
try:
url = next(self.urls)
except StopIteration:
return
self.app.work_queue.append(url)

View File

@ -12,7 +12,9 @@
# License for the specific language governing permissions and limitations
# under the License.
import collections
import copy
import errno
import hashlib
import json
import locale
@ -24,6 +26,7 @@ import shlex
import socket
import string
import subprocess
import tempfile
import time
import difflib
@ -511,3 +514,101 @@ class EqualComparisonMixin(object):
return {
'cls': cls,
'payload': vars(target)}
class UsersSSHAuthorizedKeys(object):
AUTHORIZED_KEYS = 'authorized_keys'
need_sync = False
_known_key_kinds = (
'ssh-dss', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521')
def __init__(self, login):
self.login = login
self.config_dir = self._make_config_dir_path()
self.keys = []
self._load_keys()
def add(self, goal):
parsed_goal = self._parse_key(goal)
if parsed_goal is None:
raise ValueError(
'Unable to parse SSH publick key: {}'.format(goal))
for raw, key in self.keys:
if key[:2] == parsed_goal[:2]:
break
else:
self.keys.append((goal, parsed_goal))
self.need_sync = True
def sync(self):
if not self.need_sync:
return
self._make_config_dir()
data = tempfile.NamedTemporaryFile(
mode='w+t', dir=self.config_dir,
prefix='~{}-'.format(self.AUTHORIZED_KEYS))
try:
for raw, key in self.keys:
data.write(raw)
data.write('\n')
data.flush()
os.chmod(data.name, 0o600)
os.rename(
data.name, os.path.join(self.config_dir, self.AUTHORIZED_KEYS))
self.need_sync = False
finally:
try:
data.close()
except OSError as e:
if e.errno != errno.ENOENT:
raise
def _make_config_dir_path(self):
path = os.path.join('~{}'.format(self.login), '.ssh')
path = os.path.expanduser(path)
return path
def _load_keys(self):
path = os.path.join(self.config_dir, self.AUTHORIZED_KEYS)
try:
with open(path, 'rt') as data:
for line in data:
line = line.strip()
if line.startswith('#'):
continue
key = self._parse_key(line)
if key is None:
continue
self.keys.append((line, key))
except IOError as e:
if e.errno != errno.ENOENT:
raise
def _parse_key(self, line):
match_expr = r'\s*({})\s+'.format(
'|'.join(re.escape(x) for x in self._known_key_kinds))
match = re.search(match_expr, line)
if match is None:
return
line = line[match.start(0):]
line = line.lstrip()
line = re.split(r'\s+', line, 2)
return SSHAuthorizedKey(*line)
def _make_config_dir(self):
try:
os.mkdir(self.config_dir, 0o700)
except OSError as e:
if e.errno != errno.EEXIST:
raise
SSHAuthorizedKey = collections.namedtuple(
'SSHAuthorizedKey', 'kind, hash, comment')

View File

@ -23,7 +23,7 @@ console_scripts =
bareon-copyimage = bareon.cmd.agent:copyimage
bareon-bootloader = bareon.cmd.agent:bootloader
bareon-build-image = bareon.cmd.agent:build_image
bareon-ironic-callback = bareon.cmd.ironic_callback:main
bareon-ironic-callback = bareon.cmd.ironic_callback:IronicCallbackApp.entry_point
bareon-mkbootstrap = bareon.cmd.agent:mkbootstrap
bareon-data-validator = bareon.cmd.validator:main