Skip to content

Commit

Permalink
Merge pull request #42103 from davidjoliver86/ssh-config-roster
Browse files Browse the repository at this point in the history
ssh config roster for salt-ssh
  • Loading branch information
Mike Place committed Sep 12, 2017
2 parents 5072084 + 0e902f1 commit 87ffd3f
Show file tree
Hide file tree
Showing 4 changed files with 246 additions and 7 deletions.
2 changes: 2 additions & 0 deletions salt/config/__init__.py
Expand Up @@ -925,6 +925,7 @@ def _gather_buffer_space():
'ssh_scan_timeout': float,
'ssh_identities_only': bool,
'ssh_log_file': str,
'ssh_config_file': str,

# Enable ioflo verbose logging. Warning! Very verbose!
'ioflo_verbose': int,
Expand Down Expand Up @@ -1632,6 +1633,7 @@ def _gather_buffer_space():
'ssh_scan_timeout': 0.01,
'ssh_identities_only': False,
'ssh_log_file': os.path.join(salt.syspaths.LOGS_DIR, 'ssh'),
'ssh_config_file': os.path.join(salt.syspaths.HOME_DIR, '.ssh', 'config'),
'master_floscript': os.path.join(FLO_DIR, 'master.flo'),
'worker_floscript': os.path.join(FLO_DIR, 'worker.flo'),
'maintenance_floscript': os.path.join(FLO_DIR, 'maint.flo'),
Expand Down
146 changes: 146 additions & 0 deletions salt/roster/sshconfig.py
@@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
'''
Parses roster entries out of Host directives from SSH config
.. code-block:: bash
salt-ssh --roster sshconfig '*' -r "echo hi"
'''
from __future__ import absolute_import

# Import python libs
import os
import collections
import fnmatch
import re

# Import Salt libs
import salt.utils
from salt.ext.six import string_types

import logging
log = logging.getLogger(__name__)

_SSHConfRegex = collections.namedtuple('_SSHConfRegex', ['target_field', 'pattern'])
_ROSTER_FIELDS = (
_SSHConfRegex(target_field='user', pattern=r'\s+User (.*)'),
_SSHConfRegex(target_field='port', pattern=r'\s+Port (.*)'),
_SSHConfRegex(target_field='priv', pattern=r'\s+IdentityFile (.*)'),
)


def _get_ssh_config_file(opts):
'''
:return: Path to the .ssh/config file - usually <home>/.ssh/config
'''
ssh_config_file = opts.get('ssh_config_file')
if not os.path.isfile(ssh_config_file):
raise IOError('Cannot find SSH config file')
if not os.access(ssh_config_file, os.R_OK):
raise IOError('Cannot access SSH config file: {}'.format(ssh_config_file))
return ssh_config_file


def parse_ssh_config(lines):
'''
Parses lines from the SSH config to create roster targets.
:param lines: Individual lines from the ssh config file
:return: Dictionary of targets in similar style to the flat roster
'''
# transform the list of individual lines into a list of sublists where each
# sublist represents a single Host definition
hosts = []
for line in lines:
if not line or line.startswith('#'):
continue
elif line.startswith('Host '):
hosts.append([])
hosts[-1].append(line)

# construct a dictionary of Host names to mapped roster properties
targets = collections.OrderedDict()
for host_data in hosts:
target = collections.OrderedDict()
hostnames = host_data[0].split()[1:]
for line in host_data[1:]:
for field in _ROSTER_FIELDS:
match = re.match(field.pattern, line)
if match:
target[field.target_field] = match.group(1)
for hostname in hostnames:
targets[hostname] = target

# apply matching for glob hosts
wildcard_targets = []
non_wildcard_targets = []
for target in targets.keys():
if '*' in target or '?' in target:
wildcard_targets.append(target)
else:
non_wildcard_targets.append(target)
for pattern in wildcard_targets:
for candidate in non_wildcard_targets:
if fnmatch.fnmatch(candidate, pattern):
targets[candidate].update(targets[pattern])
del targets[pattern]

# finally, update the 'host' to refer to its declaration in the SSH config
# so that its connection parameters can be utilized
for target in targets:
targets[target]['host'] = target
return targets


def targets(tgt, tgt_type='glob', **kwargs):
'''
Return the targets from the flat yaml file, checks opts for location but
defaults to /etc/salt/roster
'''
ssh_config_file = _get_ssh_config_file(__opts__)
with salt.utils.fopen(ssh_config_file, 'r') as fp:
all_minions = parse_ssh_config([line.rstrip() for line in fp])
rmatcher = RosterMatcher(all_minions, tgt, tgt_type)
matched = rmatcher.targets()
return matched


class RosterMatcher(object):
'''
Matcher for the roster data structure
'''
def __init__(self, raw, tgt, tgt_type):
self.tgt = tgt
self.tgt_type = tgt_type
self.raw = raw

def targets(self):
'''
Execute the correct tgt_type routine and return
'''
try:
return getattr(self, 'ret_{0}_minions'.format(self.tgt_type))()
except AttributeError:
return {}

def ret_glob_minions(self):
'''
Return minions that match via glob
'''
minions = {}
for minion in self.raw:
if fnmatch.fnmatch(minion, self.tgt):
data = self.get_data(minion)
if data:
minions[minion] = data
return minions

def get_data(self, minion):
'''
Return the configured ip
'''
if isinstance(self.raw[minion], string_types):
return {'host': self.raw[minion]}
if isinstance(self.raw[minion], dict):
return self.raw[minion]
return False
18 changes: 11 additions & 7 deletions salt/syspaths.py
Expand Up @@ -34,13 +34,13 @@
import salt._syspaths as __generated_syspaths # pylint: disable=no-name-in-module
except ImportError:
import types
__generated_syspaths = types.ModuleType('salt._syspaths') # future lint: disable=non-unicode-string
for key in (u'ROOT_DIR', u'CONFIG_DIR', u'CACHE_DIR', u'SOCK_DIR',
u'SRV_ROOT_DIR', u'BASE_FILE_ROOTS_DIR',
u'BASE_PILLAR_ROOTS_DIR', u'BASE_THORIUM_ROOTS_DIR',
u'BASE_MASTER_ROOTS_DIR', u'LOGS_DIR', u'PIDFILE_DIR',
u'SPM_FORMULA_PATH', u'SPM_PILLAR_PATH', u'SPM_REACTOR_PATH',
u'SHARE_DIR'):
__generated_syspaths = types.ModuleType('salt._syspaths')
for key in ('ROOT_DIR', 'CONFIG_DIR', 'CACHE_DIR', 'SOCK_DIR',
'SRV_ROOT_DIR', 'BASE_FILE_ROOTS_DIR', 'HOME_DIR',
'BASE_PILLAR_ROOTS_DIR', 'BASE_THORIUM_ROOTS_DIR',
'BASE_MASTER_ROOTS_DIR', 'LOGS_DIR', 'PIDFILE_DIR',
'SPM_FORMULA_PATH', 'SPM_PILLAR_PATH', 'SPM_REACTOR_PATH',
'SHARE_DIR'):
setattr(__generated_syspaths, key, None)


Expand Down Expand Up @@ -139,6 +139,10 @@
if SPM_REACTOR_PATH is None:
SPM_REACTOR_PATH = os.path.join(SRV_ROOT_DIR, u'spm', u'reactor')

HOME_DIR = __generated_syspaths.HOME_DIR
if HOME_DIR is None:
HOME_DIR = os.path.expanduser('~')


__all__ = [
u'ROOT_DIR',
Expand Down
87 changes: 87 additions & 0 deletions tests/unit/test_ssh_config_roster.py
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-

# Import Python libs
from __future__ import absolute_import
import collections

# Import Salt Testing Libs
from tests.support import mock
from tests.support import mixins
from tests.support.unit import skipIf, TestCase

# Import Salt Libs
import salt.roster.sshconfig as sshconfig

_SAMPLE_SSH_CONFIG = """
Host *
User user.mcuserface
Host abc*
IdentityFile ~/.ssh/id_rsa_abc
Host def*
IdentityFile ~/.ssh/id_rsa_def
Host abc.asdfgfdhgjkl.com
HostName 123.123.123.123
Host abc123.asdfgfdhgjkl.com
HostName 123.123.123.124
Host def.asdfgfdhgjkl.com
HostName 234.234.234.234
"""

_TARGET_ABC = collections.OrderedDict([
('user', 'user.mcuserface'),
('priv', '~/.ssh/id_rsa_abc'),
('host', 'abc.asdfgfdhgjkl.com')
])

_TARGET_ABC123 = collections.OrderedDict([
('user', 'user.mcuserface'),
('priv', '~/.ssh/id_rsa_abc'),
('host', 'abc123.asdfgfdhgjkl.com')
])

_TARGET_DEF = collections.OrderedDict([
('user', 'user.mcuserface'),
('priv', '~/.ssh/id_rsa_def'),
('host', 'def.asdfgfdhgjkl.com')
])

_ALL = {
'abc.asdfgfdhgjkl.com': _TARGET_ABC,
'abc123.asdfgfdhgjkl.com': _TARGET_ABC123,
'def.asdfgfdhgjkl.com': _TARGET_DEF
}

_ABC_GLOB = {
'abc.asdfgfdhgjkl.com': _TARGET_ABC,
'abc123.asdfgfdhgjkl.com': _TARGET_ABC123
}


@skipIf(mock.NO_MOCK, mock.NO_MOCK_REASON)
class SSHConfigRosterTestCase(TestCase, mixins.LoaderModuleMockMixin):

@classmethod
def setUpClass(cls):
cls.mock_fp = mock_fp = mock.mock_open(read_data=_SAMPLE_SSH_CONFIG)

def setup_loader_modules(self):
return {sshconfig: {}}

def test_all(self):
with mock.patch('salt.utils.fopen', self.mock_fp):
with mock.patch('salt.roster.sshconfig._get_ssh_config_file'):
self.mock_fp.return_value.__iter__.return_value = _SAMPLE_SSH_CONFIG.splitlines()
targets = sshconfig.targets('*')
self.assertEqual(targets, _ALL)

def test_abc_glob(self):
with mock.patch('salt.utils.fopen', self.mock_fp):
with mock.patch('salt.roster.sshconfig._get_ssh_config_file'):
self.mock_fp.return_value.__iter__.return_value = _SAMPLE_SSH_CONFIG.splitlines()
targets = sshconfig.targets('abc*')
self.assertEqual(targets, _ABC_GLOB)

0 comments on commit 87ffd3f

Please sign in to comment.