Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #42103 from davidjoliver86/ssh-config-roster
ssh config roster for salt-ssh
- Loading branch information
Showing
4 changed files
with
246 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |