Merge "Improve ironic-callback"

This commit is contained in:
Jenkins 2017-03-02 10:22:08 +00:00 committed by Gerrit Code Review
commit 245cbf02a6
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 # 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
@ -12,72 +13,312 @@
# 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 json import abc
import collections
import inspect
import sys import sys
import time import time
import traceback
import requests import requests
import six
from bareon.utils import utils from bareon.utils import utils
def _process_error(message): class IronicCallbackApp(object):
sys.stderr.write(message) """Communicate to ironic-conductor to complete bootstrap
sys.stderr.write('\n')
sys.exit(1)
def main():
"""Script informs Ironic that bootstrap loading is done.
There are three mandatory parameters in kernel command line. There are three mandatory parameters in kernel command line.
Ironic prepares these two: Ironic prepares these two:
'ironic_api_url' - URL of Ironic API service, ironic_api_url - URL of Ironic API service,
'deployment_id' - UUID of the node in Ironic. deployment_id - UUID of the node in Ironic.
Passed from PXE boot loader:
'BOOTIF' - MAC address of the boot interface, 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_- 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') @classmethod
if bootif is None: def entry_point(cls):
_process_error('Cannot define boot interface, "BOOTIF" parameter is ' app = cls()
'missing.') return app()
# The leading `01-' denotes the device type (Ethernet) and is not a part of def __init__(self):
# the MAC address self.kernel_cli_data = _KernelCLIAdapter()
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.')
data = {"address": boot_ip, self.base_url = six.moves.urllib.parse.urljoin(
"status": "ready", self.kernel_cli_data.api_url,
"error_message": "no errors"} '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' \ self.http_session = requests.Session()
'/pass_deploy_info' % {'api-url': api_url, self.http_session.headers['Accept'] = 'application/json'
'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))
if resp.status_code != 202: self.steps_mapping = _StepMapping()
_process_error('Wrong status code %d returned from Ironic API' %
resp.status_code) 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 # License for the specific language governing permissions and limitations
# under the License. # under the License.
import collections
import copy import copy
import errno
import hashlib import hashlib
import json import json
import locale import locale
@ -24,6 +26,7 @@ import shlex
import socket import socket
import string import string
import subprocess import subprocess
import tempfile
import time import time
import difflib import difflib
@ -511,3 +514,101 @@ class EqualComparisonMixin(object):
return { return {
'cls': cls, 'cls': cls,
'payload': vars(target)} '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-copyimage = bareon.cmd.agent:copyimage
bareon-bootloader = bareon.cmd.agent:bootloader bareon-bootloader = bareon.cmd.agent:bootloader
bareon-build-image = bareon.cmd.agent:build_image 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-mkbootstrap = bareon.cmd.agent:mkbootstrap
bareon-data-validator = bareon.cmd.validator:main bareon-data-validator = bareon.cmd.validator:main