mirror of
https://github.com/saltstack/salt.git
synced 2025-04-16 17:50:20 +00:00
Merge pull request #42103 from davidjoliver86/ssh-config-roster
ssh config roster for salt-ssh
This commit is contained in:
commit
87ffd3f105
4 changed files with 246 additions and 7 deletions
|
@ -925,6 +925,7 @@ VALID_OPTS = {
|
|||
'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,
|
||||
|
@ -1632,6 +1633,7 @@ DEFAULT_MASTER_OPTS = {
|
|||
'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'),
|
||||
|
|
146
salt/roster/sshconfig.py
Normal file
146
salt/roster/sshconfig.py
Normal file
|
@ -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
|
|
@ -34,13 +34,13 @@ try:
|
|||
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)
|
||||
|
||||
|
||||
|
@ -139,6 +139,10 @@ SPM_REACTOR_PATH = __generated_syspaths.SPM_REACTOR_PATH
|
|||
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',
|
||||
|
|
87
tests/unit/test_ssh_config_roster.py
Normal file
87
tests/unit/test_ssh_config_roster.py
Normal file
|
@ -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)
|
Loading…
Add table
Reference in a new issue