diff --git a/bareon/cmd/ironic_callback.py b/bareon/cmd/ironic_callback.py index 068b07b..8816fad 100755 --- a/bareon/cmd/ironic_callback.py +++ b/bareon/cmd/ironic_callback.py @@ -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: + # - + # 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 diff --git a/bareon/tests/test_ironic_callback.py b/bareon/tests/test_ironic_callback.py new file mode 100644 index 0000000..ccdb9bc --- /dev/null +++ b/bareon/tests/test_ironic_callback.py @@ -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) diff --git a/bareon/utils/utils.py b/bareon/utils/utils.py index 36b7c85..ddd56a2 100644 --- a/bareon/utils/utils.py +++ b/bareon/utils/utils.py @@ -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') diff --git a/setup.cfg b/setup.cfg index 25149a5..f055236 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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