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:
parent
62b05760ef
commit
1f33e227f8
@ -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
|
||||
|
157
bareon/tests/test_ironic_callback.py
Normal file
157
bareon/tests/test_ironic_callback.py
Normal 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)
|
@ -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')
|
||||
|
@ -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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user