Add new ssh_pre_flight roster option

This commit is contained in:
ch3ll 2020-03-31 15:05:01 -04:00
parent 6258f6b61b
commit 4fa68c9dce
No known key found for this signature in database
GPG key ID: 1124C6796EBDBD8D
6 changed files with 409 additions and 112 deletions

View file

@ -16,3 +16,31 @@ also support the syntax used in :py:mod:`module.run <salt.states.module.run>`.
The old syntax for the mine_function - as a dict, or as a list with dicts that
contain more than exactly one key - is still supported but discouraged in favor
of the more uniform syntax of module.run.
Salt-SSH updates
================
A new Salt-SSH roster option `ssh_pre_flight` has been added. This enables you to run a
script before Salt-SSH tries to run any commands. You can set this option in the roster
for a specific minion or use the `roster_defaults` to set it for all minions.
Example for setting `ssh_pre_flight` for specific host in roster file
.. code-block:: yaml
minion1:
host: localhost
user: root
passwd: P@ssword
ssh_pre_flight: /srv/salt/pre_flight.sh
Example for setting `ssh_pre_flight` using roster_defaults, so all minions
run this script.
.. code-block:: yaml
roster_defaults:
ssh_pre_flight: /srv/salt/pre_flight.sh
The `ssh_pre_flight` script will only run if the thin dir is not currently on the
minion.

View file

@ -61,6 +61,20 @@ The information which can be stored in a roster ``target`` is the following:
# components. Defaults to /tmp/salt-<hash>.
cmd_umask: # umask to enforce for the salt-call command. Should be in
# octal (so for 0o077 in YAML you would do 0077, or 63)
ssh_pre_flight: # Path to a script that will run before all other salt-ssh
# commands. Will only run the first time when the thin dir
# does not exist. Added in Sodium Release.
.. _ssh_pre_flight:
ssh_pre_flight
--------------
A Salt-SSH roster option `ssh_pre_flight` was added in the Sodium release. This enables
you to run a script before Salt-SSH tries to run any commands. You can set this option
in the roster for a specific minion or use the `roster_defaults` to set it for all minions.
This script will only run if the thin dir is not currently on the minion. This means it will
only run on the first run of salt-ssh or if you have recently wiped out your thin dir.
.. _roster_defaults:

View file

@ -895,6 +895,11 @@ class Single(object):
self.fsclient = fsclient
self.context = {"master_opts": self.opts, "fileclient": self.fsclient}
self.ssh_pre_flight = kwargs.get('ssh_pre_flight', None)
if self.ssh_pre_flight:
self.ssh_pre_file = os.path.basename(self.ssh_pre_flight)
if isinstance(argv, six.string_types):
self.argv = [argv]
else:
@ -974,6 +979,18 @@ class Single(object):
return arg
return "".join(["\\" + char if re.match(r"\W", char) else char for char in arg])
def run_ssh_pre_flight(self):
'''
Run our pre_flight script before running any ssh commands
'''
script = os.path.join(tempfile.gettempdir(), self.ssh_pre_file)
self.shell.send(
self.ssh_pre_flight,
script)
return self.execute_script(script)
def deploy(self):
"""
Deploy salt-thin
@ -1008,6 +1025,18 @@ class Single(object):
"""
stdout = stderr = retcode = None
if self.ssh_pre_flight:
if os.path.exists(self.thin_dir):
log.debug('{0} thin dir already exists. Not running ssh_pre_flight script'.format(self.thin_dir))
elif not os.path.exists(self.ssh_pre_flight):
log.error('The ssh_pre_flight script {0} does not exist'.format(self.ssh_pre_flight))
else:
stdout, stderr, retcode = self.run_ssh_pre_flight()
if stderr:
log.error('Error running ssh_pre_flight script {0}'.format(self.ssh_pre_file))
return stdout, stderr, retcode
log.debug('Successfully ran the ssh_pre_flight script: {0}'.format(self.ssh_pre_file))
if self.opts.get("raw_shell", False):
cmd_str = " ".join([self._escape_arg(arg) for arg in self.argv])
stdout, stderr, retcode = self.shell.exec_cmd(cmd_str)
@ -1263,6 +1292,26 @@ ARGS = {arguments}\n'''.format(
return cmd
def execute_script(self, script, extension='py', pre_dir=''):
'''
execute a script on the minion then delete
'''
if extension == 'ps1':
ret = self.shell.exec_cmd('"powershell {0}"'.format(script))
else:
if not self.winrm:
ret = self.shell.exec_cmd('/bin/sh \'{0}{1}\''.format(pre_dir, script))
else:
ret = saltwinshell.call_python(self, script)
# Remove file from target system
if not self.winrm:
self.shell.exec_cmd('rm \'{0}{1}\''.format(pre_dir, script))
else:
self.shell.exec_cmd('del {0}'.format(script))
return ret
def shim_cmd(self, cmd_str, extension="py"):
"""
Run a shim command.
@ -1293,22 +1342,9 @@ ARGS = {arguments}\n'''.format(
except IOError:
pass
# Execute shim
if extension == "ps1":
ret = self.shell.exec_cmd('"powershell {0}"'.format(target_shim_file))
else:
if not self.winrm:
ret = self.shell.exec_cmd(
"/bin/sh '$HOME/{0}'".format(target_shim_file)
)
else:
ret = saltwinshell.call_python(self, target_shim_file)
# Remove shim from target system
if not self.winrm:
self.shell.exec_cmd("rm '$HOME/{0}'".format(target_shim_file))
else:
self.shell.exec_cmd("del {0}".format(target_shim_file))
ret = self.execute_script(script=target_shim_file,
extension=extension,
pre_dir='$HOME/')
return ret

View file

@ -10,6 +10,10 @@ import shutil
# Import salt testing libs
from tests.support.case import SSHCase
from tests.support.runtests import RUNTIME_VARS
# Import salt libs
import salt.utils.yaml
class SSHTest(SSHCase):
@ -34,6 +38,26 @@ class SSHTest(SSHCase):
os.path.exists(os.path.join(thin_dir, "salt-call"))
os.path.exists(os.path.join(thin_dir, "running_data"))
def test_ssh_pre_flight(self):
'''
test ssh when ssh_pre_flight is set
ensure the script runs successfully
'''
roster = os.path.join(RUNTIME_VARS.TMP, 'pre_flight_roster')
data = {'ssh_pre_flight': os.path.join(RUNTIME_VARS.TMP, 'ssh_pre_flight.sh')}
self.custom_roster(roster, data)
test_script = os.path.join(RUNTIME_VARS.TMP,
'test-pre-flight-script-worked.txt')
with salt.utils.files.fopen(data['ssh_pre_flight'], 'w') as fp_:
fp_.write('touch {0}'.format(test_script))
ret = self.run_function('test.ping', roster_file=roster)
assert os.path.exists(test_script)
def tearDown(self):
"""
make sure to clean up any old ssh directories

View file

@ -27,6 +27,7 @@ import time
from datetime import datetime, timedelta
# Import 3rd-party libs
import salt.utils.files
from salt.ext import six
from salt.ext.six.moves import cStringIO # pylint: disable=import-error
from tests.support.cli_scripts import ScriptPathMixin
@ -83,26 +84,21 @@ class ShellTestCase(TestCase, AdaptedConfigurationTestCaseMixin, ScriptPathMixin
timeout=timeout,
)
def run_ssh(
self,
arg_str,
with_retcode=False,
timeout=25,
catch_stderr=False,
wipe=False,
raw=False,
**kwargs
):
"""
def run_ssh(self, arg_str, with_retcode=False, timeout=25,
catch_stderr=False, wipe=False, raw=False, roster_file=None, **kwargs):
'''
Execute salt-ssh
"""
arg_str = "{0} {1} -c {2} -i --priv {3} --roster-file {4} localhost {5} --out=json".format(
" -W" if wipe else "",
" -r" if raw else "",
'''
if not roster_file:
roster_file = os.path.join(RUNTIME_VARS.TMP_CONF_DIR, 'roster')
arg_str = '{0} {1} -c {2} -i --priv {3} --roster-file {4} localhost {5} --out=json'.format(
' -W' if wipe else '',
' -r' if raw else '',
self.config_dir,
os.path.join(RUNTIME_VARS.TMP_CONF_DIR, "key_test"),
os.path.join(RUNTIME_VARS.TMP_CONF_DIR, "roster"),
arg_str,
os.path.join(RUNTIME_VARS.TMP_CONF_DIR, 'key_test'),
roster_file,
arg_str
)
return self.run_script(
"salt-ssh",
@ -527,37 +523,29 @@ class ShellCase(ShellTestCase, AdaptedConfigurationTestCaseMixin, ScriptPathMixi
log.debug("Result of run_spm for command '%s': %s", arg_str, ret)
return ret
def run_ssh(
self,
arg_str,
with_retcode=False,
catch_stderr=False,
timeout=RUN_TIMEOUT,
wipe=True,
raw=False,
**kwargs
):
"""
def run_ssh(self, arg_str, with_retcode=False, catch_stderr=False, # pylint: disable=W0221
timeout=RUN_TIMEOUT, wipe=True, raw=False, roster_file=None, **kwargs):
'''
Execute salt-ssh
"""
arg_str = "{0} -ldebug{1} -c {2} -i --priv {3} --roster-file {4} --out=json localhost {5}".format(
" -W" if wipe else "",
" -r" if raw else "",
'''
if not roster_file:
roster_file = os.path.join(RUNTIME_VARS.TMP_CONF_DIR, 'roster')
arg_str = '{0} -ldebug{1} -c {2} -i --priv {3} --roster-file {4} --out=json localhost {5}'.format(
' -W' if wipe else '',
' -r' if raw else '',
self.config_dir,
os.path.join(RUNTIME_VARS.TMP_CONF_DIR, "key_test"),
os.path.join(RUNTIME_VARS.TMP_CONF_DIR, "roster"),
arg_str,
)
ret = self.run_script(
"salt-ssh",
arg_str,
with_retcode=with_retcode,
catch_stderr=catch_stderr,
timeout=timeout,
raw=True,
**kwargs
)
log.debug("Result of run_ssh for command '%s %s': %s", arg_str, kwargs, ret)
os.path.join(RUNTIME_VARS.TMP_CONF_DIR, 'key_test'),
roster_file,
arg_str)
ret = self.run_script('salt-ssh',
arg_str,
with_retcode=with_retcode,
catch_stderr=catch_stderr,
timeout=timeout,
raw=True,
**kwargs)
log.debug('Result of run_ssh for command \'%s %s\': %s', arg_str, kwargs, ret)
return ret
# pylint: enable=arguments-differ
@ -750,9 +738,6 @@ class SPMCase(TestCase, AdaptedConfigurationTestCaseMixin):
for f_dir in dirs:
os.makedirs(f_dir)
# Late import
import salt.utils.files
with salt.utils.files.fopen(self.formula_sls, "w") as fp:
fp.write(
textwrap.dedent(
@ -807,7 +792,6 @@ class SPMCase(TestCase, AdaptedConfigurationTestCaseMixin):
}
)
import salt.utils.files
import salt.utils.yaml
if not os.path.isdir(config["formula_path"]):
@ -827,9 +811,6 @@ class SPMCase(TestCase, AdaptedConfigurationTestCaseMixin):
repo_conf_dir = self.config["spm_repos_config"] + ".d"
os.makedirs(repo_conf_dir)
# Late import
import salt.utils.files
with salt.utils.files.fopen(os.path.join(repo_conf_dir, "spm.repo"), "w") as fp:
fp.write(
textwrap.dedent(
@ -1123,6 +1104,20 @@ class SSHCase(ShellCase):
except Exception: # pylint: disable=broad-except
return ret
def custom_roster(self, new_roster, data):
'''
helper method to create a custom roster to use for a ssh test
'''
roster = os.path.join(RUNTIME_VARS.TMP_CONF_DIR, 'roster')
with salt.utils.files.fopen(roster, 'r') as fp_:
conf = salt.utils.yaml.safe_load(fp_)
conf['localhost'].update(data)
with salt.utils.files.fopen(new_roster, 'w') as fp_:
salt.utils.yaml.safe_dump(conf, fp_)
class ClientCase(AdaptedConfigurationTestCaseMixin, TestCase):
"""

View file

@ -18,12 +18,12 @@ import salt.utils.path
import salt.utils.thin
import salt.utils.yaml
from salt.client import ssh
from tests.support.case import ShellCase
from tests.support.mock import MagicMock, patch
# Import Salt Testing libs
from tests.support.runtests import RUNTIME_VARS
from tests.support.unit import TestCase, skipIf
from tests.support.case import ShellCase
from tests.support.mock import MagicMock, patch, call
ROSTER = """
localhost:
@ -113,48 +113,248 @@ class SSHRosterDefaults(TestCase):
class SSHSingleTests(TestCase):
def setUp(self):
self.tmp_cachedir = tempfile.mkdtemp(dir=RUNTIME_VARS.TMP)
self.argv = ['ssh.set_auth_key', 'root', 'hobn+amNAXSBTiOXEqlBjGB...rsa root@master']
self.opts = {
'argv': self.argv,
'__role': 'master',
'cachedir': self.tmp_cachedir,
'extension_modules': os.path.join(self.tmp_cachedir, 'extmods'),
}
self.target = {
'passwd': 'abc123',
'ssh_options': None,
'sudo': False,
'identities_only': False,
'host': 'login1',
'user': 'root',
'timeout': 65,
'remote_port_forwards': None,
'sudo_user': '',
'port': '22',
'priv': '/etc/salt/pki/master/ssh/salt-ssh.rsa'
}
def test_single_opts(self):
""" Sanity check for ssh.Single options
"""
argv = ["ssh.set_auth_key", "root", "hobn+amNAXSBTiOXEqlBjGB...rsa root@master"]
opts = {
"argv": argv,
"__role": "master",
"cachedir": self.tmp_cachedir,
"extension_modules": os.path.join(self.tmp_cachedir, "extmods"),
}
target = {
"passwd": "abc123",
"ssh_options": None,
"sudo": False,
"identities_only": False,
"host": "login1",
"user": "root",
"timeout": 65,
"remote_port_forwards": None,
"sudo_user": "",
"port": "22",
"priv": "/etc/salt/pki/master/ssh/salt-ssh.rsa",
}
''' Sanity check for ssh.Single options
'''
single = ssh.Single(
opts,
opts["argv"],
"localhost",
mods={},
fsclient=None,
thin=salt.utils.thin.thin_path(opts["cachedir"]),
mine=False,
**target
)
self.opts,
self.opts['argv'],
'localhost',
mods={},
fsclient=None,
thin=salt.utils.thin.thin_path(self.opts['cachedir']),
mine=False,
**self.target)
self.assertEqual(single.shell._ssh_opts(), "")
self.assertEqual(
single.shell._cmd_str("date +%s"),
"ssh login1 "
"-o KbdInteractiveAuthentication=no -o "
"PasswordAuthentication=yes -o ConnectTimeout=65 -o Port=22 "
"-o IdentityFile=/etc/salt/pki/master/ssh/salt-ssh.rsa "
"-o User=root date +%s",
)
self.assertEqual(single.shell._ssh_opts(), '')
self.assertEqual(single.shell._cmd_str('date +%s'), 'ssh login1 '
'-o KbdInteractiveAuthentication=no -o '
'PasswordAuthentication=yes -o ConnectTimeout=65 -o Port=22 '
'-o IdentityFile=/etc/salt/pki/master/ssh/salt-ssh.rsa '
'-o User=root date +%s')
def test_run_with_pre_flight(self):
'''
test Single.run() when ssh_pre_flight is set
and script successfully runs
'''
target = self.target.copy()
target['ssh_pre_flight'] = os.path.join(RUNTIME_VARS.TMP, 'script.sh')
single = ssh.Single(
self.opts,
self.opts['argv'],
'localhost',
mods={},
fsclient=None,
thin=salt.utils.thin.thin_path(self.opts['cachedir']),
mine=False,
**target)
cmd_ret = ('Success', '', 0)
mock_flight = MagicMock(return_value=cmd_ret)
mock_cmd = MagicMock(return_value=cmd_ret)
patch_flight = patch('salt.client.ssh.Single.run_ssh_pre_flight', mock_flight)
patch_cmd = patch('salt.client.ssh.Single.cmd_block', mock_cmd)
patch_os = patch('os.path.exists', side_effect=[False, True])
with patch_os, patch_flight, patch_cmd:
ret = single.run()
mock_cmd.assert_called()
mock_flight.assert_called()
assert ret == cmd_ret
def test_run_with_pre_flight_stderr(self):
'''
test Single.run() when ssh_pre_flight is set
and script errors when run
'''
target = self.target.copy()
target['ssh_pre_flight'] = os.path.join(RUNTIME_VARS.TMP, 'script.sh')
single = ssh.Single(
self.opts,
self.opts['argv'],
'localhost',
mods={},
fsclient=None,
thin=salt.utils.thin.thin_path(self.opts['cachedir']),
mine=False,
**target)
cmd_ret = ('', 'Error running script', 1)
mock_flight = MagicMock(return_value=cmd_ret)
mock_cmd = MagicMock(return_value=cmd_ret)
patch_flight = patch('salt.client.ssh.Single.run_ssh_pre_flight', mock_flight)
patch_cmd = patch('salt.client.ssh.Single.cmd_block', mock_cmd)
patch_os = patch('os.path.exists', side_effect=[False, True])
with patch_os, patch_flight, patch_cmd:
ret = single.run()
mock_cmd.assert_not_called()
mock_flight.assert_called()
assert ret == cmd_ret
def test_run_with_pre_flight_script_doesnot_exist(self):
'''
test Single.run() when ssh_pre_flight is set
and the script does not exist
'''
target = self.target.copy()
target['ssh_pre_flight'] = os.path.join(RUNTIME_VARS.TMP, 'script.sh')
single = ssh.Single(
self.opts,
self.opts['argv'],
'localhost',
mods={},
fsclient=None,
thin=salt.utils.thin.thin_path(self.opts['cachedir']),
mine=False,
**target)
cmd_ret = ('Success', '', 0)
mock_flight = MagicMock(return_value=cmd_ret)
mock_cmd = MagicMock(return_value=cmd_ret)
patch_flight = patch('salt.client.ssh.Single.run_ssh_pre_flight', mock_flight)
patch_cmd = patch('salt.client.ssh.Single.cmd_block', mock_cmd)
patch_os = patch('os.path.exists', side_effect=[False, False])
with patch_os, patch_flight, patch_cmd:
ret = single.run()
mock_cmd.assert_called()
mock_flight.assert_not_called()
assert ret == cmd_ret
def test_run_with_pre_flight_thin_dir_exists(self):
'''
test Single.run() when ssh_pre_flight is set
and thin_dir already exists
'''
target = self.target.copy()
target['ssh_pre_flight'] = os.path.join(RUNTIME_VARS.TMP, 'script.sh')
single = ssh.Single(
self.opts,
self.opts['argv'],
'localhost',
mods={},
fsclient=None,
thin=salt.utils.thin.thin_path(self.opts['cachedir']),
mine=False,
**target)
cmd_ret = ('', 'Error running script', 1)
mock_flight = MagicMock(return_value=cmd_ret)
mock_cmd = MagicMock(return_value=cmd_ret)
patch_flight = patch('salt.client.ssh.Single.run_ssh_pre_flight', mock_flight)
patch_cmd = patch('salt.client.ssh.Single.cmd_block', mock_cmd)
patch_os = patch('os.path.exists', return_value=True)
with patch_os, patch_flight, patch_cmd:
ret = single.run()
mock_cmd.assert_called()
mock_flight.assert_not_called()
assert ret == cmd_ret
def test_execute_script(self):
'''
test Single.execute_script()
'''
single = ssh.Single(
self.opts,
self.opts['argv'],
'localhost',
mods={},
fsclient=None,
thin=salt.utils.thin.thin_path(self.opts['cachedir']),
mine=False,
winrm=False,
**self.target)
exp_ret = ('Success', '', 0)
mock_cmd = MagicMock(return_value=exp_ret)
patch_cmd = patch('salt.client.ssh.shell.Shell.exec_cmd', mock_cmd)
script = os.path.join(RUNTIME_VARS.TMP, 'script.sh')
with patch_cmd:
ret = single.execute_script(script=script)
assert ret == exp_ret
assert mock_cmd.call_count == 2
assert [call("/bin/sh '{0}'".format(script)),
call("rm '{0}'".format(script))] == mock_cmd.call_args_list
def test_shim_cmd(self):
'''
test Single.shim_cmd()
'''
single = ssh.Single(
self.opts,
self.opts['argv'],
'localhost',
mods={},
fsclient=None,
thin=salt.utils.thin.thin_path(self.opts['cachedir']),
mine=False,
winrm=False,
tty=True,
**self.target)
exp_ret = ('Success', '', 0)
mock_cmd = MagicMock(return_value=exp_ret)
patch_cmd = patch('salt.client.ssh.shell.Shell.exec_cmd', mock_cmd)
patch_rand = patch('os.urandom', return_value=b'5\xd9l\xca\xc2\xff')
with patch_cmd, patch_rand:
ret = single.shim_cmd(cmd_str='echo test')
assert ret == exp_ret
assert [call('mkdir -p '),
call("/bin/sh '$HOME/.35d96ccac2ff.py'"),
call("rm '$HOME/.35d96ccac2ff.py'")] == mock_cmd.call_args_list
def test_run_ssh_pre_flight(self):
'''
test Single.run_ssh_pre_flight
'''
target = self.target.copy()
target['ssh_pre_flight'] = os.path.join(RUNTIME_VARS.TMP, 'script.sh')
single = ssh.Single(
self.opts,
self.opts['argv'],
'localhost',
mods={},
fsclient=None,
thin=salt.utils.thin.thin_path(self.opts['cachedir']),
mine=False,
winrm=False,
tty=True,
**target)
exp_ret = ('Success', '', 0)
mock_cmd = MagicMock(return_value=exp_ret)
patch_cmd = patch('salt.client.ssh.shell.Shell.exec_cmd', mock_cmd)
exp_tmp = os.path.join(tempfile.gettempdir(), os.path.basename(target['ssh_pre_flight']))
with patch_cmd:
ret = single.run_ssh_pre_flight()
assert ret == exp_ret
assert [call("/bin/sh '{0}'".format(exp_tmp)),
call("rm '{0}'".format(exp_tmp))] == mock_cmd.call_args_list