1"""
2    :codeauthor: Pedro Algarvio (pedro@algarvio.me)
3
4
5    ====================================
6    Custom Salt TestCase Implementations
7    ====================================
8
9    Custom reusable :class:`TestCase<python2:unittest.TestCase>`
10    implementations.
11"""
12
13import errno
14import io
15import json
16import logging
17import os
18import re
19import subprocess
20import sys
21import tempfile
22import textwrap
23import time
24from datetime import datetime, timedelta
25
26import pytest
27import salt.utils.files
28from saltfactories.utils.processes import terminate_process
29from tests.support.cli_scripts import ScriptPathMixin
30from tests.support.helpers import RedirectStdStreams
31from tests.support.mixins import (  # pylint: disable=unused-import
32    AdaptedConfigurationTestCaseMixin,
33    SaltClientTestCaseMixin,
34)
35from tests.support.runtests import RUNTIME_VARS
36from tests.support.unit import TestCase
37
38STATE_FUNCTION_RUNNING_RE = re.compile(
39    r"""The function (?:"|')(?P<state_func>.*)(?:"|') is running as PID """
40    r"(?P<pid>[\d]+) and was started at (?P<date>.*) with jid (?P<jid>[\d]+)"
41)
42
43log = logging.getLogger(__name__)
44
45
46class ShellCase(TestCase, AdaptedConfigurationTestCaseMixin, ScriptPathMixin):
47    """
48    Execute a test for a shell command
49    """
50
51    RUN_TIMEOUT = 30
52
53    def run_salt(
54        self,
55        arg_str,
56        with_retcode=False,
57        catch_stderr=False,
58        timeout=None,
59        popen_kwargs=None,
60        config_dir=None,
61    ):
62        r'''
63        Run the ``salt`` CLI tool with the provided arguments
64
65        .. code-block:: python
66
67            class MatchTest(ShellCase):
68                def test_list(self):
69                    """
70                    test salt -L matcher
71                    """
72                    data = self.run_salt('-L minion test.ping')
73                    data = '\n'.join(data)
74                    self.assertIn('minion', data)
75        '''
76        if timeout is None:
77            timeout = self.RUN_TIMEOUT
78
79        arg_str = "-t {} {}".format(timeout, arg_str)
80        return self.run_script(
81            "salt",
82            arg_str,
83            with_retcode=with_retcode,
84            catch_stderr=catch_stderr,
85            timeout=timeout,
86            config_dir=config_dir,
87        )
88
89    def run_ssh(
90        self,
91        arg_str,
92        with_retcode=False,
93        catch_stderr=False,
94        timeout=None,
95        wipe=False,
96        raw=False,
97        roster_file=None,
98        ssh_opts="",
99        log_level="error",
100        config_dir=None,
101        **kwargs
102    ):
103        """
104        Execute salt-ssh
105        """
106        if timeout is None:
107            timeout = self.RUN_TIMEOUT
108        if not roster_file:
109            roster_file = os.path.join(RUNTIME_VARS.TMP_CONF_DIR, "roster")
110        arg_str = (
111            "{wipe} {raw} -l {log_level} --ignore-host-keys --priv {client_key}"
112            " --roster-file {roster_file} {ssh_opts} localhost {arg_str} --out=json"
113        ).format(
114            wipe=" -W" if wipe else "",
115            raw=" -r" if raw else "",
116            log_level=log_level,
117            client_key=os.path.join(RUNTIME_VARS.TMP_SSH_CONF_DIR, "client_key"),
118            roster_file=roster_file,
119            ssh_opts=ssh_opts,
120            arg_str=arg_str,
121        )
122        ret = self.run_script(
123            "salt-ssh",
124            arg_str,
125            with_retcode=with_retcode,
126            catch_stderr=catch_stderr,
127            raw=True,
128            timeout=timeout,
129            config_dir=config_dir,
130            **kwargs
131        )
132        log.debug("Result of run_ssh for command '%s %s': %s", arg_str, kwargs, ret)
133        return ret
134
135    def run_run(
136        self,
137        arg_str,
138        with_retcode=False,
139        catch_stderr=False,
140        asynchronous=False,
141        timeout=None,
142        config_dir=None,
143        **kwargs
144    ):
145        """
146        Execute salt-run
147        """
148        if timeout is None:
149            timeout = self.RUN_TIMEOUT
150        asynchronous = kwargs.get("async", asynchronous)
151        arg_str = "{async_flag} -t {timeout} {}".format(
152            arg_str,
153            timeout=timeout,
154            async_flag=" --async" if asynchronous else "",
155        )
156        ret = self.run_script(
157            "salt-run",
158            arg_str,
159            with_retcode=with_retcode,
160            catch_stderr=catch_stderr,
161            timeout=timeout,
162            config_dir=config_dir,
163        )
164        log.debug("Result of run_run for command '%s': %s", arg_str, ret)
165        return ret
166
167    def run_run_plus(self, fun, *arg, **kwargs):
168        """
169        Execute the runner function and return the return data and output in a dict
170        """
171        output = kwargs.pop("_output", None)
172        opts_overrides = kwargs.pop("opts_overrides", None)
173        ret = {"fun": fun}
174
175        # Late import
176        import salt.config
177        import salt.output
178        import salt.runner
179
180        opts = salt.config.client_config(self.get_config_file_path("master"))
181        if opts_overrides:
182            opts.update(opts_overrides)
183
184        opts_arg = list(arg)
185        if kwargs:
186            opts_arg.append({"__kwarg__": True})
187            opts_arg[-1].update(kwargs)
188
189        opts.update({"doc": False, "fun": fun, "arg": opts_arg})
190        with RedirectStdStreams():
191            runner = salt.runner.Runner(opts)
192            ret["return"] = runner.run()
193            try:
194                ret["jid"] = runner.jid
195            except AttributeError:
196                ret["jid"] = None
197
198        # Compile output
199        # TODO: Support outputters other than nested
200        opts["color"] = False
201        opts["output_file"] = io.StringIO()
202        try:
203            salt.output.display_output(ret["return"], opts=opts, out=output)
204            out = opts["output_file"].getvalue()
205            if output is None:
206                out = out.splitlines()
207            elif output == "json":
208                out = json.loads(out)
209            ret["out"] = out
210        finally:
211            opts["output_file"].close()
212        log.debug(
213            "Result of run_run_plus for fun '%s' with arg '%s': %s", fun, opts_arg, ret
214        )
215        return ret
216
217    def run_key(self, arg_str, catch_stderr=False, with_retcode=False, config_dir=None):
218        """
219        Execute salt-key
220        """
221        return self.run_script(
222            "salt-key",
223            arg_str,
224            catch_stderr=catch_stderr,
225            with_retcode=with_retcode,
226            config_dir=config_dir,
227        )
228
229    def run_cp(
230        self,
231        arg_str,
232        with_retcode=False,
233        catch_stderr=False,
234        timeout=None,
235        config_dir=None,
236    ):
237        """
238        Execute salt-cp
239        """
240        if timeout is None:
241            timeout = self.RUN_TIMEOUT
242        # Note: not logging result of run_cp because it will log a bunch of
243        # bytes which will not be very helpful.
244        return self.run_script(
245            "salt-cp",
246            arg_str,
247            with_retcode=with_retcode,
248            catch_stderr=catch_stderr,
249            timeout=timeout,
250            config_dir=config_dir,
251        )
252
253    def run_call(
254        self,
255        arg_str,
256        with_retcode=False,
257        catch_stderr=False,
258        local=False,
259        timeout=None,
260        config_dir=None,
261    ):
262        if timeout is None:
263            timeout = self.RUN_TIMEOUT
264        if not config_dir:
265            config_dir = RUNTIME_VARS.TMP_MINION_CONF_DIR
266        arg_str = "{} {}".format("--local" if local else "", arg_str)
267        ret = self.run_script(
268            "salt-call",
269            arg_str,
270            with_retcode=with_retcode,
271            catch_stderr=catch_stderr,
272            timeout=timeout,
273            config_dir=config_dir,
274        )
275        log.debug("Result of run_call for command '%s': %s", arg_str, ret)
276        return ret
277
278    def run_function(
279        self,
280        function,
281        arg=(),
282        with_retcode=False,
283        catch_stderr=False,
284        local=False,
285        timeout=RUN_TIMEOUT,
286        **kwargs
287    ):
288        """
289        Execute function with salt-call.
290
291        This function is added for compatibility with ModuleCase. This makes it possible to use
292        decorators like @with_system_user.
293        """
294        arg_str = "{} {} {}".format(
295            function,
296            " ".join(str(arg_) for arg_ in arg),
297            " ".join("{}={}".format(*item) for item in kwargs.items()),
298        )
299        return self.run_call(arg_str, with_retcode, catch_stderr, local, timeout)
300
301    def run_cloud(self, arg_str, catch_stderr=False, timeout=None, config_dir=None):
302        """
303        Execute salt-cloud
304        """
305        if timeout is None:
306            timeout = self.RUN_TIMEOUT
307
308        ret = self.run_script(
309            "salt-cloud", arg_str, catch_stderr, timeout=timeout, config_dir=config_dir
310        )
311        log.debug("Result of run_cloud for command '%s': %s", arg_str, ret)
312        return ret
313
314    def run_spm(
315        self,
316        arg_str,
317        with_retcode=False,
318        catch_stderr=False,
319        timeout=None,
320        config_dir=None,
321    ):
322        """
323        Execute spm
324        """
325        if timeout is None:
326            timeout = self.RUN_TIMEOUT
327        ret = self.run_script(
328            "spm",
329            arg_str,
330            with_retcode=with_retcode,
331            catch_stderr=catch_stderr,
332            timeout=timeout,
333            config_dir=config_dir,
334        )
335        log.debug("Result of run_spm for command '%s': %s", arg_str, ret)
336        return ret
337
338    def run_script(
339        self,
340        script,
341        arg_str,
342        catch_stderr=False,
343        with_retcode=False,
344        catch_timeout=False,
345        # FIXME A timeout of zero or disabling timeouts may not return results!
346        timeout=15,
347        raw=False,
348        popen_kwargs=None,
349        log_output=None,
350        config_dir=None,
351        **kwargs
352    ):
353        """
354        Execute a script with the given argument string
355
356        The ``log_output`` argument is ternary, it can be True, False, or None.
357        If the value is boolean, then it forces the results to either be logged
358        or not logged. If it is None, then the return code of the subprocess
359        determines whether or not to log results.
360        """
361
362        import salt.utils.platform
363
364        script_path = self.get_script_path(script)
365        if not os.path.isfile(script_path):
366            return False
367        popen_kwargs = popen_kwargs or {}
368
369        python_path_env_var = os.environ.get("PYTHONPATH") or None
370        if python_path_env_var is None:
371            python_path_entries = [RUNTIME_VARS.CODE_DIR]
372        else:
373            python_path_entries = python_path_env_var.split(os.pathsep)
374            if RUNTIME_VARS.CODE_DIR in python_path_entries:
375                python_path_entries.remove(RUNTIME_VARS.CODE_DIR)
376            python_path_entries.insert(0, RUNTIME_VARS.CODE_DIR)
377        python_path_entries.extend(sys.path[0:])
378
379        if "env" not in popen_kwargs:
380            popen_kwargs["env"] = os.environ.copy()
381
382        popen_kwargs["env"]["PYTHONPATH"] = os.pathsep.join(python_path_entries)
383
384        if "cwd" not in popen_kwargs:
385            popen_kwargs["cwd"] = RUNTIME_VARS.TMP
386
387        if salt.utils.platform.is_windows():
388            cmd = "python "
389        else:
390            cmd = "python{}.{} ".format(*sys.version_info)
391
392        cmd += "{} --config-dir={} {} ".format(
393            script_path, config_dir or RUNTIME_VARS.TMP_CONF_DIR, arg_str
394        )
395        if kwargs:
396            # late import
397            import salt.utils.json
398
399            for key, value in kwargs.items():
400                cmd += "'{}={} '".format(key, salt.utils.json.dumps(value))
401
402        tmp_file = tempfile.SpooledTemporaryFile()
403
404        popen_kwargs = dict(
405            {"shell": True, "stdout": tmp_file, "universal_newlines": True},
406            **popen_kwargs
407        )
408
409        if catch_stderr is True:
410            popen_kwargs["stderr"] = subprocess.PIPE
411
412        if salt.utils.platform.is_windows():
413            # Windows does not support closing FDs
414            close_fds = False
415        elif salt.utils.platform.is_freebsd() and sys.version_info < (3, 9):
416            # Closing FDs in FreeBSD before Py3.9 can be slow
417            #   https://bugs.python.org/issue38061
418            close_fds = False
419        else:
420            close_fds = True
421
422        popen_kwargs["close_fds"] = close_fds
423
424        if not salt.utils.platform.is_windows():
425
426            def detach_from_parent_group():
427                # detach from parent group (no more inherited signals!)
428                os.setpgrp()
429
430            popen_kwargs["preexec_fn"] = detach_from_parent_group
431
432        def format_return(retcode, stdout, stderr=None, timed_out=False):
433            """
434            DRY helper to log script result if it failed, and then return the
435            desired output based on whether or not stderr was desired, and
436            wither or not a retcode was desired.
437            """
438            log_func = log.debug
439            if timed_out:
440                log.error(
441                    "run_script timed out after %d seconds (process killed)", timeout
442                )
443                log_func = log.error
444
445            if log_output is True or timed_out or (log_output is None and retcode != 0):
446                log_func(
447                    "run_script results for: %s %s\n"
448                    "return code: %s\n"
449                    "stdout:\n"
450                    "%s\n\n"
451                    "stderr:\n"
452                    "%s",
453                    script,
454                    arg_str,
455                    retcode,
456                    stdout,
457                    stderr,
458                )
459
460            stdout = stdout or ""
461            stderr = stderr or ""
462
463            if not raw:
464                stdout = stdout.splitlines()
465                stderr = stderr.splitlines()
466
467            ret = [stdout]
468            if catch_stderr:
469                ret.append(stderr)
470            if with_retcode:
471                ret.append(retcode)
472            if catch_timeout:
473                ret.append(timed_out)
474
475            return ret[0] if len(ret) == 1 else tuple(ret)
476
477        log.debug("Running Popen(%r, %r)", cmd, popen_kwargs)
478        process = subprocess.Popen(cmd, **popen_kwargs)
479
480        if timeout is not None:
481            stop_at = datetime.now() + timedelta(seconds=timeout)
482            while True:
483                process.poll()
484                time.sleep(0.1)
485                if datetime.now() <= stop_at:
486                    # We haven't reached the timeout yet
487                    if process.returncode is not None:
488                        break
489                else:
490                    terminate_process(process.pid, kill_children=True)
491                    return format_return(
492                        process.returncode, *process.communicate(), timed_out=True
493                    )
494
495        tmp_file.seek(0)
496
497        try:
498            out = tmp_file.read().decode(__salt_system_encoding__)
499        except (NameError, UnicodeDecodeError):
500            # Let's cross our fingers and hope for the best
501            out = tmp_file.read().decode("utf-8")
502
503        if catch_stderr:
504            _, err = process.communicate()
505            # Force closing stderr/stdout to release file descriptors
506            if process.stdout is not None:
507                process.stdout.close()
508            if process.stderr is not None:
509                process.stderr.close()
510
511            # pylint: disable=maybe-no-member
512            try:
513                return format_return(process.returncode, out, err or "")
514            finally:
515                try:
516                    if os.path.exists(tmp_file.name):
517                        if isinstance(tmp_file.name, str):
518                            # tmp_file.name is an int when using SpooledTemporaryFiles
519                            # int types cannot be used with os.remove() in Python 3
520                            os.remove(tmp_file.name)
521                        else:
522                            # Clean up file handles
523                            tmp_file.close()
524                    process.terminate()
525                except OSError as err:
526                    # process already terminated
527                    pass
528            # pylint: enable=maybe-no-member
529
530        # TODO Remove this?
531        process.communicate()
532        if process.stdout is not None:
533            process.stdout.close()
534
535        try:
536            return format_return(process.returncode, out)
537        finally:
538            try:
539                if os.path.exists(tmp_file.name):
540                    if isinstance(tmp_file.name, str):
541                        # tmp_file.name is an int when using SpooledTemporaryFiles
542                        # int types cannot be used with os.remove() in Python 3
543                        os.remove(tmp_file.name)
544                    else:
545                        # Clean up file handles
546                        tmp_file.close()
547                process.terminate()
548            except OSError as err:
549                # process already terminated
550                pass
551
552
553class SPMTestUserInterface:
554    """
555    Test user interface to SPMClient
556    """
557
558    def __init__(self):
559        self._status = []
560        self._confirm = []
561        self._error = []
562
563    def status(self, msg):
564        self._status.append(msg)
565
566    def confirm(self, action):
567        self._confirm.append(action)
568
569    def error(self, msg):
570        self._error.append(msg)
571
572
573class SPMCase(TestCase, AdaptedConfigurationTestCaseMixin):
574    """
575    Class for handling spm commands
576    """
577
578    def _spm_build_files(self, config):
579        self.formula_dir = os.path.join(
580            " ".join(config["file_roots"]["base"]), "formulas"
581        )
582        self.formula_sls_dir = os.path.join(self.formula_dir, "apache")
583        self.formula_sls = os.path.join(self.formula_sls_dir, "apache.sls")
584        self.formula_file = os.path.join(self.formula_dir, "FORMULA")
585
586        dirs = [self.formula_dir, self.formula_sls_dir]
587        for f_dir in dirs:
588            os.makedirs(f_dir)
589
590        with salt.utils.files.fopen(self.formula_sls, "w") as fp:
591            fp.write(
592                textwrap.dedent(
593                    """\
594                     install-apache:
595                       pkg.installed:
596                         - name: apache2
597                     """
598                )
599            )
600
601        with salt.utils.files.fopen(self.formula_file, "w") as fp:
602            fp.write(
603                textwrap.dedent(
604                    """\
605                     name: apache
606                     os: RedHat, Debian, Ubuntu, Suse, FreeBSD
607                     os_family: RedHat, Debian, Suse, FreeBSD
608                     version: 201506
609                     release: 2
610                     summary: Formula for installing Apache
611                     description: Formula for installing Apache
612                     """
613                )
614            )
615
616    def _spm_config(self, assume_yes=True):
617        self._tmp_spm = tempfile.mkdtemp()
618        config = self.get_temp_config(
619            "minion",
620            **{
621                "spm_logfile": os.path.join(self._tmp_spm, "log"),
622                "spm_repos_config": os.path.join(self._tmp_spm, "etc", "spm.repos"),
623                "spm_cache_dir": os.path.join(self._tmp_spm, "cache"),
624                "spm_build_dir": os.path.join(self._tmp_spm, "build"),
625                "spm_build_exclude": ["apache/.git"],
626                "spm_db_provider": "sqlite3",
627                "spm_files_provider": "local",
628                "spm_db": os.path.join(self._tmp_spm, "packages.db"),
629                "extension_modules": os.path.join(self._tmp_spm, "modules"),
630                "file_roots": {"base": [self._tmp_spm]},
631                "formula_path": os.path.join(self._tmp_spm, "salt"),
632                "pillar_path": os.path.join(self._tmp_spm, "pillar"),
633                "reactor_path": os.path.join(self._tmp_spm, "reactor"),
634                "assume_yes": True if assume_yes else False,
635                "force": False,
636                "verbose": False,
637                "cache": "localfs",
638                "cachedir": os.path.join(self._tmp_spm, "cache"),
639                "spm_repo_dups": "ignore",
640                "spm_share_dir": os.path.join(self._tmp_spm, "share"),
641            }
642        )
643
644        import salt.utils.yaml
645
646        if not os.path.isdir(config["formula_path"]):
647            os.makedirs(config["formula_path"])
648
649        with salt.utils.files.fopen(os.path.join(self._tmp_spm, "spm"), "w") as fp:
650            salt.utils.yaml.safe_dump(config, fp)
651
652        return config
653
654    def _spm_create_update_repo(self, config):
655
656        build_spm = self.run_spm("build", self.config, self.formula_dir)
657
658        c_repo = self.run_spm("create_repo", self.config, self.config["spm_build_dir"])
659
660        repo_conf_dir = self.config["spm_repos_config"] + ".d"
661        os.makedirs(repo_conf_dir)
662
663        with salt.utils.files.fopen(os.path.join(repo_conf_dir, "spm.repo"), "w") as fp:
664            fp.write(
665                textwrap.dedent(
666                    """\
667                     local_repo:
668                       url: file://{}
669                     """.format(
670                        self.config["spm_build_dir"]
671                    )
672                )
673            )
674
675        u_repo = self.run_spm("update_repo", self.config)
676
677    def _spm_client(self, config):
678        import salt.spm
679
680        self.ui = SPMTestUserInterface()
681        client = salt.spm.SPMClient(self.ui, config)
682        return client
683
684    def run_spm(self, cmd, config, arg=None):
685        client = self._spm_client(config)
686        client.run([cmd, arg])
687        client._close()
688        return self.ui._status
689
690
691class ModuleCase(TestCase, SaltClientTestCaseMixin):
692    """
693    Execute a module function
694    """
695
696    def wait_for_all_jobs(self, minions=("minion", "sub_minion"), sleep=0.3):
697        """
698        Wait for all jobs currently running on the list of minions to finish
699        """
700        for minion in minions:
701            while True:
702                ret = self.run_function(
703                    "saltutil.running", minion_tgt=minion, timeout=300
704                )
705                if ret:
706                    log.debug("Waiting for minion's jobs: %s", minion)
707                    time.sleep(sleep)
708                else:
709                    break
710
711    def minion_run(self, _function, *args, **kw):
712        """
713        Run a single salt function on the 'minion' target and condition
714        the return down to match the behavior of the raw function call
715        """
716        return self.run_function(_function, args, **kw)
717
718    def run_function(
719        self,
720        function,
721        arg=(),
722        minion_tgt="minion",
723        timeout=300,
724        master_tgt=None,
725        **kwargs
726    ):
727        """
728        Run a single salt function and condition the return down to match the
729        behavior of the raw function call
730        """
731        known_to_return_none = (
732            "data.get",
733            "file.chown",
734            "file.chgrp",
735            "pkg.refresh_db",
736            "ssh.recv_known_host_entries",
737            "time.sleep",
738            "grains.delkey",
739            "grains.delval",
740        )
741        if "f_arg" in kwargs:
742            kwargs["arg"] = kwargs.pop("f_arg")
743        if "f_timeout" in kwargs:
744            kwargs["timeout"] = kwargs.pop("f_timeout")
745        client = self.client if master_tgt is None else self.clients[master_tgt]
746        log.debug(
747            "Running client.cmd(minion_tgt=%r, function=%r, arg=%r, timeout=%r,"
748            " kwarg=%r)",
749            minion_tgt,
750            function,
751            arg,
752            timeout,
753            kwargs,
754        )
755        orig = client.cmd(minion_tgt, function, arg, timeout=timeout, kwarg=kwargs)
756
757        if minion_tgt not in orig:
758            self.fail(
759                "WARNING(SHOULD NOT HAPPEN #1935): Failed to get a reply "
760                "from the minion '{}'. Command output: {}".format(minion_tgt, orig)
761            )
762        elif orig[minion_tgt] is None and function not in known_to_return_none:
763            self.fail(
764                "WARNING(SHOULD NOT HAPPEN #1935): Failed to get '{}' from "
765                "the minion '{}'. Command output: {}".format(function, minion_tgt, orig)
766            )
767
768        # Try to match stalled state functions
769        orig[minion_tgt] = self._check_state_return(orig[minion_tgt])
770
771        return orig[minion_tgt]
772
773    def run_state(self, function, **kwargs):
774        """
775        Run the state.single command and return the state return structure
776        """
777        ret = self.run_function("state.single", [function], **kwargs)
778        return self._check_state_return(ret)
779
780    def _check_state_return(self, ret):
781        if isinstance(ret, dict):
782            # This is the supposed return format for state calls
783            return ret
784
785        if isinstance(ret, list):
786            jids = []
787            # These are usually errors
788            for item in ret[:]:
789                if not isinstance(item, str):
790                    # We don't know how to handle this
791                    continue
792                match = STATE_FUNCTION_RUNNING_RE.match(item)
793                if not match:
794                    # We don't know how to handle this
795                    continue
796                jid = match.group("jid")
797                if jid in jids:
798                    continue
799
800                jids.append(jid)
801
802                job_data = self.run_function("saltutil.find_job", [jid])
803                job_kill = self.run_function("saltutil.kill_job", [jid])
804                msg = (
805                    "A running state.single was found causing a state lock. "
806                    "Job details: '{}'  Killing Job Returned: '{}'".format(
807                        job_data, job_kill
808                    )
809                )
810                ret.append("[TEST SUITE ENFORCED]{}[/TEST SUITE ENFORCED]".format(msg))
811        return ret
812
813
814class SyndicCase(TestCase, SaltClientTestCaseMixin):
815    """
816    Execute a syndic based execution test
817    """
818
819    _salt_client_config_file_name_ = "syndic_master"
820
821    def run_function(self, function, arg=(), timeout=90):
822        """
823        Run a single salt function and condition the return down to match the
824        behavior of the raw function call
825        """
826        orig = self.client.cmd("minion", function, arg, timeout=timeout)
827        if "minion" not in orig:
828            self.fail(
829                "WARNING(SHOULD NOT HAPPEN #1935): Failed to get a reply "
830                "from the minion. Command output: {}".format(orig)
831            )
832        return orig["minion"]
833
834
835@pytest.mark.requires_sshd_server
836class SSHCase(ShellCase):
837    """
838    Execute a command via salt-ssh
839    """
840
841    def _arg_str(self, function, arg):
842        return "{} {}".format(function, " ".join(arg))
843
844    # pylint: disable=arguments-differ
845    def run_function(
846        self, function, arg=(), timeout=180, wipe=True, raw=False, **kwargs
847    ):
848        """
849        We use a 180s timeout here, which some slower systems do end up needing
850        """
851        ret = self.run_ssh(
852            self._arg_str(function, arg), timeout=timeout, wipe=wipe, raw=raw, **kwargs
853        )
854        log.debug(
855            "SSHCase run_function executed %s with arg %s and kwargs %s",
856            function,
857            arg,
858            kwargs,
859        )
860        log.debug("SSHCase JSON return: %s", ret)
861
862        # Late import
863        import salt.utils.json
864
865        try:
866            return salt.utils.json.loads(ret)["localhost"]
867        except Exception:  # pylint: disable=broad-except
868            return ret
869
870    # pylint: enable=arguments-differ
871    def custom_roster(self, new_roster, data):
872        """
873        helper method to create a custom roster to use for a ssh test
874        """
875        roster = os.path.join(RUNTIME_VARS.TMP_CONF_DIR, "roster")
876
877        with salt.utils.files.fopen(roster, "r") as fp_:
878            conf = salt.utils.yaml.safe_load(fp_)
879
880        conf["localhost"].update(data)
881
882        with salt.utils.files.fopen(new_roster, "w") as fp_:
883            salt.utils.yaml.safe_dump(conf, fp_)
884
885
886class ClientCase(AdaptedConfigurationTestCaseMixin, TestCase):
887    """
888    A base class containing relevant options for starting the various Salt
889    Python API entrypoints
890    """
891
892    def get_opts(self):
893        # Late import
894        import salt.config
895
896        return salt.config.client_config(self.get_config_file_path("master"))
897
898    def mkdir_p(self, path):
899        try:
900            os.makedirs(path)
901        except OSError as exc:  # Python >2.5
902            if exc.errno == errno.EEXIST and os.path.isdir(path):
903                pass
904            else:
905                raise
906