saltfactories_compat.py 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117
  1. """
  2. saltfactories.utils.processes.salts
  3. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  4. Salt's related daemon classes and CLI processes implementations
  5. """
  6. import atexit
  7. import json
  8. import logging
  9. import os
  10. import pprint
  11. import re
  12. import stat
  13. import subprocess
  14. import sys
  15. import tempfile
  16. import textwrap
  17. import time
  18. import weakref
  19. from collections import namedtuple
  20. from operator import itemgetter
  21. import psutil # pylint: disable=3rd-party-module-not-gated
  22. import pytest
  23. import salt.client
  24. from saltfactories.exceptions import FactoryTimeout as ProcessTimeout
  25. from saltfactories.utils.processes import terminate_process
  26. SALT_KEY_LOG_LEVEL_SUPPORTED = False
  27. log = logging.getLogger(__name__)
  28. class Popen(subprocess.Popen):
  29. def __init__(self, *args, **kwargs):
  30. for key in ("stdout", "stderr"):
  31. if key in kwargs:
  32. raise RuntimeError(
  33. "{}.Popen() does not accept {} as a valid keyword argument".format(
  34. __name__, key
  35. )
  36. )
  37. stdout = tempfile.SpooledTemporaryFile(512000)
  38. kwargs["stdout"] = stdout
  39. stderr = tempfile.SpooledTemporaryFile(512000)
  40. kwargs["stderr"] = stderr
  41. super().__init__(*args, **kwargs)
  42. self.__stdout = stdout
  43. self.__stderr = stderr
  44. weakref.finalize(self, stdout.close)
  45. weakref.finalize(self, stderr.close)
  46. def communicate(self, input=None): # pylint: disable=arguments-differ
  47. super().communicate(input)
  48. stdout = stderr = None
  49. if self.__stdout:
  50. self.__stdout.flush()
  51. self.__stdout.seek(0)
  52. stdout = self.__stdout.read()
  53. # We want str type on Py3 and Unicode type on Py2
  54. # pylint: disable=undefined-variable
  55. stdout = stdout.decode(__salt_system_encoding__)
  56. # pylint: enable=undefined-variable
  57. if self.__stderr:
  58. self.__stderr.flush()
  59. self.__stderr.seek(0)
  60. stderr = self.__stderr.read()
  61. # We want str type on Py3 and Unicode type on Py2
  62. # pylint: disable=undefined-variable
  63. stderr = stderr.decode(__salt_system_encoding__)
  64. # pylint: enable=undefined-variable
  65. return stdout, stderr
  66. class ProcessResult(
  67. namedtuple("ProcessResult", ("exitcode", "stdout", "stderr", "cmdline"))
  68. ):
  69. """
  70. This class serves the purpose of having a common result class which will hold the
  71. resulting data from a subprocess command.
  72. """
  73. __slots__ = ()
  74. def __new__(cls, exitcode, stdout, stderr, cmdline=None):
  75. if not isinstance(exitcode, int):
  76. raise ValueError(
  77. "'exitcode' needs to be an integer, not '{}'".format(type(exitcode))
  78. )
  79. return super().__new__(cls, exitcode, stdout, stderr, cmdline=cmdline)
  80. # These are copied from the namedtuple verbose output in order to quiet down PyLint
  81. exitcode = property(itemgetter(0), doc="ProcessResult exit code property")
  82. stdout = property(itemgetter(1), doc="ProcessResult stdout property")
  83. stderr = property(itemgetter(2), doc="ProcessResult stderr property")
  84. cmdline = property(itemgetter(3), doc="ProcessResult cmdline property")
  85. def __str__(self):
  86. message = self.__class__.__name__
  87. if self.cmdline:
  88. message += "\n Command Line: {}".format(self.cmdline)
  89. if self.exitcode is not None:
  90. message += "\n Exitcode: {}".format(self.exitcode)
  91. if self.stdout or self.stderr:
  92. message += "\n Process Output:"
  93. if self.stdout:
  94. message += "\n >>>>> STDOUT >>>>>\n{}\n <<<<< STDOUT <<<<<".format(
  95. self.stdout
  96. )
  97. if self.stderr:
  98. message += "\n >>>>> STDERR >>>>>\n{}\n <<<<< STDERR <<<<<".format(
  99. self.stderr
  100. )
  101. return message + "\n"
  102. class ShellResult(
  103. namedtuple("ShellResult", ("exitcode", "stdout", "stderr", "json", "cmdline"))
  104. ):
  105. """
  106. This class serves the purpose of having a common result class which will hold the
  107. resulting data from a subprocess command.
  108. """
  109. __slots__ = ()
  110. def __new__(cls, exitcode, stdout, stderr, json=None, cmdline=None):
  111. if not isinstance(exitcode, int):
  112. raise ValueError(
  113. "'exitcode' needs to be an integer, not '{}'".format(type(exitcode))
  114. )
  115. return super().__new__(
  116. cls, exitcode, stdout, stderr, json=json, cmdline=cmdline
  117. )
  118. # These are copied from the namedtuple verbose output in order to quiet down PyLint
  119. exitcode = property(itemgetter(0), doc="ShellResult exit code property")
  120. stdout = property(itemgetter(1), doc="ShellResult stdout property")
  121. stderr = property(itemgetter(2), doc="ShellResult stderr property")
  122. json = property(
  123. itemgetter(3), doc="ShellResult stdout JSON decoded, when parseable."
  124. )
  125. cmdline = property(itemgetter(4), doc="ShellResult cmdline property")
  126. def __str__(self):
  127. message = self.__class__.__name__
  128. if self.cmdline:
  129. message += "\n Command Line: {}".format(self.cmdline)
  130. if self.exitcode is not None:
  131. message += "\n Exitcode: {}".format(self.exitcode)
  132. if self.stdout or self.stderr:
  133. message += "\n Process Output:"
  134. if self.stdout:
  135. message += "\n >>>>> STDOUT >>>>>\n{}\n <<<<< STDOUT <<<<<".format(
  136. self.stdout
  137. )
  138. if self.stderr:
  139. message += "\n >>>>> STDERR >>>>>\n{}\n <<<<< STDERR <<<<<".format(
  140. self.stderr
  141. )
  142. if self.json:
  143. message += "\n JSON Object:\n"
  144. message += "".join(
  145. " {}".format(line) for line in pprint.pformat(self.json)
  146. )
  147. return message + "\n"
  148. def __eq__(self, other):
  149. """
  150. Allow comparison against the parsed JSON or the output
  151. """
  152. if self.json:
  153. return self.json == other
  154. return self.stdout == other
  155. class FactoryProcess:
  156. """
  157. Base class for subprocesses
  158. """
  159. def __init__(
  160. self,
  161. cli_script_name,
  162. slow_stop=True,
  163. environ=None,
  164. cwd=None,
  165. base_script_args=None,
  166. ):
  167. """
  168. Args:
  169. cli_script_name(str):
  170. This is the string containing the name of the binary to call on the subprocess, either the
  171. full path to it, or the basename. In case of the basename, the directory containing the
  172. basename must be in your ``$PATH`` variable.
  173. slow_stop(bool):
  174. Wether to terminate the processes by sending a :py:attr:`SIGTERM` signal or by calling
  175. :py:meth:`~subprocess.Popen.terminate` on the sub-procecess.
  176. When code coverage is enabled, one will want `slow_stop` set to `True` so that coverage data
  177. can be written down to disk.
  178. environ(dict):
  179. A dictionary of `key`, `value` pairs to add to the environment.
  180. cwd (str):
  181. The path to the current working directory
  182. base_script_args(list or tuple):
  183. An list or tuple iterable of the base arguments to use when building the command line to
  184. launch the process
  185. """
  186. self.cli_script_name = cli_script_name
  187. self.slow_stop = slow_stop
  188. self.environ = environ or os.environ.copy()
  189. self.cwd = cwd or os.getcwd()
  190. self._terminal = None
  191. self._terminal_result = None
  192. self._terminal_timeout = None
  193. self._children = []
  194. self._base_script_args = base_script_args
  195. def get_display_name(self):
  196. """
  197. Returns a name to show when process stats reports are enabled
  198. """
  199. return self.cli_script_name
  200. def get_log_prefix(self):
  201. """
  202. Returns the log prefix that shall be used for a salt daemon forwarding log records.
  203. It is also used by :py:func:`start_daemon` when starting the daemon subprocess.
  204. """
  205. return "[{}] ".format(self.cli_script_name)
  206. def get_script_path(self):
  207. """
  208. Returns the path to the script to run
  209. """
  210. if os.path.isabs(self.cli_script_name):
  211. script_path = self.cli_script_name
  212. else:
  213. script_path = salt.utils.path.which(self.cli_script_name)
  214. if not os.path.exists(script_path):
  215. pytest.fail("The CLI script {!r} does not exist".format(script_path))
  216. return script_path
  217. def get_base_script_args(self):
  218. """
  219. Returns any additional arguments to pass to the CLI script
  220. """
  221. if self._base_script_args:
  222. return list(self._base_script_args)
  223. return []
  224. def get_script_args(self): # pylint: disable=no-self-use
  225. """
  226. Returns any additional arguments to pass to the CLI script
  227. """
  228. return []
  229. def build_cmdline(self, *args, **kwargs):
  230. return (
  231. [self.get_script_path()]
  232. + self.get_base_script_args()
  233. + self.get_script_args()
  234. + list(args)
  235. )
  236. def init_terminal(self, cmdline, **kwargs):
  237. """
  238. Instantiate a terminal with the passed cmdline and kwargs and return it.
  239. Additionaly, it sets a reference to it in self._terminal and also collects
  240. an initial listing of child processes which will be used when terminating the
  241. terminal
  242. """
  243. self._terminal = Popen(cmdline, **kwargs)
  244. # A little sleep to allow the subprocess to start
  245. time.sleep(0.125)
  246. try:
  247. for child in psutil.Process(self._terminal.pid).children(recursive=True):
  248. if child not in self._children:
  249. self._children.append(child)
  250. except psutil.NoSuchProcess:
  251. # The terminal process is gone
  252. pass
  253. atexit.register(self.terminate)
  254. return self._terminal
  255. def terminate(self):
  256. """
  257. Terminate the started daemon
  258. """
  259. if self._terminal is None:
  260. return self._terminal_result
  261. log.info("%sStopping %s", self.get_log_prefix(), self.__class__.__name__)
  262. # Collect any child processes information before terminating the process
  263. try:
  264. for child in psutil.Process(self._terminal.pid).children(recursive=True):
  265. if child not in self._children:
  266. self._children.append(child)
  267. except psutil.NoSuchProcess:
  268. # The terminal process is gone
  269. pass
  270. # poll the terminal before trying to terminate it, running or not, so that
  271. # the right returncode is set on the popen object
  272. self._terminal.poll()
  273. # Lets log and kill any child processes which salt left behind
  274. terminate_process(
  275. pid=self._terminal.pid,
  276. kill_children=True,
  277. children=self._children,
  278. slow_stop=self.slow_stop,
  279. )
  280. stdout, stderr = self._terminal.communicate()
  281. try:
  282. log_message = "{}Terminated {}.".format(
  283. self.get_log_prefix(), self.__class__.__name__
  284. )
  285. if stdout or stderr:
  286. log_message += " Process Output:"
  287. if stdout:
  288. log_message += "\n>>>>> STDOUT >>>>>\n{}\n<<<<< STDOUT <<<<<".format(
  289. stdout.strip()
  290. )
  291. if stderr:
  292. log_message += "\n>>>>> STDERR >>>>>\n{}\n<<<<< STDERR <<<<<".format(
  293. stderr.strip()
  294. )
  295. log_message += "\n"
  296. log.info(log_message)
  297. self._terminal_result = ProcessResult(
  298. self._terminal.returncode, stdout, stderr, cmdline=self._terminal.args
  299. )
  300. return self._terminal_result
  301. finally:
  302. self._terminal = None
  303. self._children = []
  304. @property
  305. def pid(self):
  306. terminal = getattr(self, "_terminal", None)
  307. if not terminal:
  308. return
  309. return terminal.pid
  310. def __repr__(self):
  311. return "<{} display_name='{}'>".format(
  312. self.__class__.__name__, self.get_display_name()
  313. )
  314. class FactoryScriptBase(FactoryProcess):
  315. """
  316. Base class for CLI scripts
  317. """
  318. def __init__(self, *args, **kwargs):
  319. """
  320. Base class for non daemonic CLI processes
  321. Check base class(es) for additional supported parameters
  322. Args:
  323. default_timeout(int):
  324. The maximum ammount of seconds that a script should run
  325. """
  326. default_timeout = kwargs.pop("default_timeout", None)
  327. super().__init__(*args, **kwargs)
  328. if default_timeout is None:
  329. if not sys.platform.startswith(("win", "darwin")):
  330. default_timeout = 30
  331. else:
  332. # Windows and macOS are just slower.
  333. default_timeout = 120
  334. self.default_timeout = default_timeout
  335. self._terminal_timeout_set_explicitly = False
  336. def run(self, *args, **kwargs):
  337. """
  338. Run the given command synchronously
  339. """
  340. start_time = time.time()
  341. timeout = kwargs.pop("_timeout", None)
  342. # Build the cmdline to pass to the terminal
  343. # We set the _terminal_timeout attribute while calling build_cmdline in case it needs
  344. # access to that information to build the command line
  345. self._terminal_timeout = timeout or self.default_timeout
  346. self._terminal_timeout_set_explicitly = timeout is not None
  347. cmdline = self.build_cmdline(*args, **kwargs)
  348. timeout_expire = time.time() + self._terminal_timeout
  349. log.info(
  350. "%sRunning %r in CWD: %s ...", self.get_log_prefix(), cmdline, self.cwd
  351. )
  352. terminal = self.init_terminal(cmdline, cwd=self.cwd, env=self.environ,)
  353. timmed_out = False
  354. while True:
  355. if timeout_expire < time.time():
  356. timmed_out = True
  357. break
  358. if terminal.poll() is not None:
  359. break
  360. time.sleep(0.25)
  361. result = self.terminate()
  362. if timmed_out:
  363. raise ProcessTimeout(
  364. "{}Failed to run: {}; Error: Timed out after {:.2f} seconds!".format(
  365. self.get_log_prefix(), cmdline, time.time() - start_time
  366. ),
  367. stdout=result.stdout,
  368. stderr=result.stderr,
  369. cmdline=cmdline,
  370. exitcode=result.exitcode,
  371. )
  372. exitcode = result.exitcode
  373. stdout, stderr, json_out = self.process_output(
  374. result.stdout, result.stderr, cmdline=cmdline
  375. )
  376. log.info(
  377. "%sCompleted %r in CWD: %s after %.2f seconds",
  378. self.get_log_prefix(),
  379. cmdline,
  380. self.cwd,
  381. time.time() - start_time,
  382. )
  383. return ShellResult(exitcode, stdout, stderr, json=json_out, cmdline=cmdline)
  384. def process_output(self, stdout, stderr, cmdline=None):
  385. if stdout:
  386. try:
  387. json_out = json.loads(stdout)
  388. except ValueError:
  389. log.debug(
  390. "%sFailed to load JSON from the following output:\n%r",
  391. self.get_log_prefix(),
  392. stdout,
  393. )
  394. json_out = None
  395. else:
  396. json_out = None
  397. return stdout, stderr, json_out
  398. class FactoryPythonScriptBase(FactoryScriptBase):
  399. def __init__(self, *args, **kwargs):
  400. """
  401. Base class for python scripts based CLI processes
  402. Check base class(es) for additional supported parameters
  403. Args:
  404. python_executable(str):
  405. The path to the python executable to use
  406. """
  407. python_executable = kwargs.pop("python_executable", None)
  408. super().__init__(*args, **kwargs)
  409. self.python_executable = python_executable or sys.executable
  410. # We really do not want buffered output
  411. self.environ.setdefault("PYTHONUNBUFFERED", "1")
  412. # Don't write .pyc files or create them in __pycache__ directories
  413. self.environ.setdefault("PYTHONDONTWRITEBYTECODE", "1")
  414. def build_cmdline(self, *args, **kwargs):
  415. cmdline = super().build_cmdline(*args, **kwargs)
  416. if cmdline[0] != self.python_executable:
  417. cmdline.insert(0, self.python_executable)
  418. return cmdline
  419. class FactoryDaemonScriptBase(FactoryProcess):
  420. def is_alive(self):
  421. """
  422. Returns true if the process is alive
  423. """
  424. terminal = getattr(self, "_terminal", None)
  425. if not terminal:
  426. return False
  427. return terminal.poll() is None
  428. def get_check_ports(self): # pylint: disable=no-self-use
  429. """
  430. Return a list of ports to check against to ensure the daemon is running
  431. """
  432. return []
  433. def start(self):
  434. """
  435. Start the daemon subprocess
  436. """
  437. log.info(
  438. "%sStarting DAEMON %s in CWD: %s",
  439. self.get_log_prefix(),
  440. self.cli_script_name,
  441. self.cwd,
  442. )
  443. cmdline = self.build_cmdline()
  444. log.info("%sRunning %r...", self.get_log_prefix(), cmdline)
  445. self.init_terminal(
  446. cmdline, env=self.environ, cwd=self.cwd,
  447. )
  448. self._children.extend(psutil.Process(self.pid).children(recursive=True))
  449. return True
  450. class SaltConfigMixin:
  451. @property
  452. def config_dir(self):
  453. if "conf_file" in self.config:
  454. return os.path.dirname(self.config["conf_file"])
  455. @property
  456. def config_file(self):
  457. if "conf_file" in self.config:
  458. return self.config["conf_file"]
  459. def __repr__(self):
  460. return "<{} id='{id}' role='{__role}'>".format(
  461. self.__class__.__name__, **self.config
  462. )
  463. class SaltScriptBase(FactoryPythonScriptBase, SaltConfigMixin):
  464. __cli_timeout_supported__ = False
  465. __cli_log_level_supported__ = True
  466. def __init__(self, *args, **kwargs):
  467. config = kwargs.pop("config", None) or {}
  468. hard_crash = kwargs.pop("salt_hard_crash", False)
  469. super().__init__(*args, **kwargs)
  470. self.config = config
  471. self.hard_crash = hard_crash
  472. def get_script_args(self):
  473. """
  474. Returns any additional arguments to pass to the CLI script
  475. """
  476. if not self.hard_crash:
  477. return super().get_script_args()
  478. return ["--hard-crash"]
  479. def get_minion_tgt(self, kwargs):
  480. minion_tgt = None
  481. if "minion_tgt" in kwargs:
  482. minion_tgt = kwargs.pop("minion_tgt")
  483. return minion_tgt
  484. def build_cmdline(self, *args, **kwargs): # pylint: disable=arguments-differ
  485. log.debug("Building cmdline. Input args: %s; Input kwargs: %s;", args, kwargs)
  486. minion_tgt = self._minion_tgt = self.get_minion_tgt(kwargs)
  487. cmdline = []
  488. args = list(args)
  489. # Handle the config directory flag
  490. for arg in args:
  491. if arg.startswith("--config-dir="):
  492. break
  493. if arg in ("-c", "--config-dir"):
  494. break
  495. else:
  496. cmdline.append("--config-dir={}".format(self.config_dir))
  497. # Handle the timeout CLI flag, if supported
  498. if self.__cli_timeout_supported__:
  499. salt_cli_timeout_next = False
  500. for arg in args:
  501. if arg.startswith("--timeout="):
  502. # Let's actually change the _terminal_timeout value which is used to
  503. # calculate when the run() method should actually timeout
  504. if self._terminal_timeout_set_explicitly is False:
  505. salt_cli_timeout = arg.split("--timeout=")[-1]
  506. try:
  507. self._terminal_timeout = int(salt_cli_timeout) + 5
  508. except ValueError:
  509. # Not a number? Let salt do it's error handling
  510. pass
  511. break
  512. if salt_cli_timeout_next:
  513. if self._terminal_timeout_set_explicitly is False:
  514. try:
  515. self._terminal_timeout = int(arg) + 5
  516. except ValueError:
  517. # Not a number? Let salt do it's error handling
  518. pass
  519. break
  520. if arg == "-t" or arg.startswith("--timeout"):
  521. salt_cli_timeout_next = True
  522. continue
  523. else:
  524. salt_cli_timeout = self._terminal_timeout
  525. if salt_cli_timeout and self._terminal_timeout_set_explicitly is False:
  526. # Shave off a few seconds so that the salt command times out before the terminal does
  527. salt_cli_timeout -= 5
  528. if salt_cli_timeout:
  529. # If it's still a positive number, add it to the salt command CLI flags
  530. cmdline.append("--timeout={}".format(salt_cli_timeout))
  531. # Handle the output flag
  532. for arg in args:
  533. if arg in ("--out", "--output"):
  534. break
  535. if arg.startswith(("--out=", "--output=")):
  536. break
  537. else:
  538. # No output was passed, the default output is JSON
  539. cmdline.append("--out=json")
  540. if self.__cli_log_level_supported__:
  541. # Handle the logging flag
  542. for arg in args:
  543. if arg in ("-l", "--log-level"):
  544. break
  545. if arg.startswith("--log-level="):
  546. break
  547. else:
  548. # Default to being quiet on console output
  549. cmdline.append("--log-level=quiet")
  550. if minion_tgt:
  551. cmdline.append(minion_tgt)
  552. # Add the remaning args
  553. cmdline.extend(args)
  554. for key in kwargs:
  555. value = kwargs[key]
  556. if not isinstance(value, str):
  557. value = json.dumps(value)
  558. cmdline.append("{}={}".format(key, value))
  559. cmdline = super().build_cmdline(*cmdline)
  560. log.debug("Built cmdline: %s", cmdline)
  561. return cmdline
  562. def process_output(self, stdout, stderr, cmdline=None):
  563. stdout, stderr, json_out = super().process_output(
  564. stdout, stderr, cmdline=cmdline
  565. )
  566. if json_out and isinstance(json_out, str) and "--out=json" in cmdline:
  567. # Sometimes the parsed JSON is just a string, for example:
  568. # OUTPUT: '"The salt master could not be contacted. Is master running?"\n'
  569. # LOADED JSON: 'The salt master could not be contacted. Is master running?'
  570. #
  571. # In this case, we assign the loaded JSON to stdout and reset json_out
  572. stdout = json_out
  573. json_out = None
  574. return stdout, stderr, json_out
  575. class SaltDaemonScriptBase(
  576. FactoryDaemonScriptBase, FactoryPythonScriptBase, SaltConfigMixin
  577. ):
  578. def __init__(self, *args, **kwargs):
  579. config = kwargs.pop("config", None) or {}
  580. extra_checks_callback = kwargs.pop("extra_checks_callback", None)
  581. super().__init__(*args, **kwargs)
  582. self.config = config
  583. self.extra_checks_callback = extra_checks_callback
  584. def get_base_script_args(self):
  585. script_args = super().get_base_script_args()
  586. config_dir = self.config_dir
  587. if config_dir:
  588. script_args.append("--config-dir={}".format(config_dir))
  589. script_args.append("--log-level=quiet")
  590. return script_args
  591. def get_check_events(self):
  592. """
  593. Return a list of tuples in the form of `(master_id, event_tag)` check against to ensure the daemon is running
  594. """
  595. raise NotImplementedError
  596. def get_log_prefix(self):
  597. """
  598. Returns the log prefix that shall be used for a salt daemon forwarding log records.
  599. It is also used by :py:func:`start_daemon` when starting the daemon subprocess.
  600. """
  601. try:
  602. return self._log_prefix
  603. except AttributeError:
  604. try:
  605. pytest_config_key = "pytest-{}".format(self.config["__role"])
  606. log_prefix = (
  607. self.config.get(pytest_config_key, {}).get("log", {}).get("prefix")
  608. or ""
  609. )
  610. if log_prefix:
  611. self._log_prefix = "[{}] ".format(
  612. log_prefix.format(
  613. cli_name=os.path.basename(self.cli_script_name)
  614. )
  615. )
  616. except KeyError:
  617. # This should really be a salt daemon which always set's `__role` in its config
  618. self._log_prefix = super().get_log_prefix()
  619. return self._log_prefix
  620. def get_display_name(self):
  621. """
  622. Returns a name to show when process stats reports are enabled
  623. """
  624. try:
  625. return self._display_name
  626. except AttributeError:
  627. self._display_name = self.get_log_prefix().strip().lstrip("[").rstrip("]")
  628. return self._display_name
  629. def run_extra_checks(self, salt_factories):
  630. """
  631. This extra check is here so that we confirm the daemon is up as soon as it get's responsive
  632. """
  633. if self.extra_checks_callback is None:
  634. return True
  635. return self.extra_checks_callback(salt_factories, self.config)
  636. class SaltMaster(SaltDaemonScriptBase):
  637. """
  638. Simple subclass to define a salt master daemon
  639. """
  640. def get_check_events(self):
  641. """
  642. Return a list of tuples in the form of `(master_id, event_tag)` check against to ensure the daemon is running
  643. """
  644. yield self.config["id"], "salt/master/{id}/start".format(**self.config)
  645. class SaltMinion(SaltDaemonScriptBase):
  646. """
  647. Simple subclass to define a salt minion daemon
  648. """
  649. def get_base_script_args(self):
  650. script_args = super().get_base_script_args()
  651. if sys.platform.startswith("win") is False:
  652. script_args.append("--disable-keepalive")
  653. return script_args
  654. def get_check_events(self):
  655. """
  656. Return a list of tuples in the form of `(master_id, event_tag)` check against to ensure the daemon is running
  657. """
  658. pytest_config = self.config["pytest-{}".format(self.config["__role"])]
  659. yield pytest_config["master_config"]["id"], "salt/{__role}/{id}/start".format(
  660. **self.config
  661. )
  662. class SaltSyndic(SaltDaemonScriptBase):
  663. """
  664. Simple subclass to define a salt minion daemon
  665. """
  666. def get_check_events(self):
  667. """
  668. Return a list of tuples in the form of `(master_id, event_tag)` check against to ensure the daemon is running
  669. """
  670. pytest_config = self.config["pytest-{}".format(self.config["__role"])]
  671. yield pytest_config["master_config"]["id"], "salt/{__role}/{id}/start".format(
  672. **self.config
  673. )
  674. class SaltProxyMinion(SaltDaemonScriptBase):
  675. """
  676. Simple subclass to define a salt proxy minion daemon
  677. """
  678. def __init__(self, *args, **kwargs):
  679. include_proxyid_cli_flag = kwargs.pop("include_proxyid_cli_flag", True)
  680. super().__init__(*args, **kwargs)
  681. self.include_proxyid_cli_flag = include_proxyid_cli_flag
  682. def get_base_script_args(self):
  683. script_args = super().get_base_script_args()
  684. if sys.platform.startswith("win") is False:
  685. script_args.append("--disable-keepalive")
  686. if self.include_proxyid_cli_flag is True:
  687. script_args.extend(["--proxyid", self.config["id"]])
  688. return script_args
  689. def get_check_events(self):
  690. """
  691. Return a list of tuples in the form of `(master_id, event_tag)` check against to ensure the daemon is running
  692. """
  693. pytest_config = self.config["pytest-{}".format(self.config["__role"])]
  694. yield pytest_config["master_config"]["id"], "salt/{__role}/{id}/start".format(
  695. **self.config
  696. )
  697. class SaltCLI(SaltScriptBase):
  698. """
  699. Simple subclass to the salt CLI script
  700. """
  701. __cli_timeout_supported__ = True
  702. def process_output(self, stdout, stderr, cmdline=None):
  703. if (
  704. "No minions matched the target. No command was sent, no jid was assigned.\n"
  705. in stdout
  706. ):
  707. stdout = stdout.split("\n", 1)[1:][0]
  708. old_stdout = None
  709. if "--show-jid" in cmdline and stdout.startswith("jid: "):
  710. old_stdout = stdout
  711. stdout = stdout.split("\n", 1)[-1].strip()
  712. stdout, stderr, json_out = SaltScriptBase.process_output(
  713. self, stdout, stderr, cmdline
  714. )
  715. if old_stdout is not None:
  716. stdout = old_stdout
  717. if json_out:
  718. try:
  719. return stdout, stderr, json_out[self._minion_tgt]
  720. except KeyError:
  721. return stdout, stderr, json_out
  722. return stdout, stderr, json_out
  723. class SaltCallCLI(SaltScriptBase):
  724. """
  725. Simple subclass to the salt-call CLI script
  726. """
  727. __cli_timeout_supported__ = True
  728. def get_minion_tgt(self, kwargs):
  729. return None
  730. def process_output(self, stdout, stderr, cmdline=None):
  731. # Under salt-call, the minion target is always "local"
  732. self._minion_tgt = "local"
  733. stdout, stderr, json_out = SaltScriptBase.process_output(
  734. self, stdout, stderr, cmdline
  735. )
  736. if json_out:
  737. try:
  738. return stdout, stderr, json_out[self._minion_tgt]
  739. except KeyError:
  740. return stdout, stderr, json_out
  741. return stdout, stderr, json_out
  742. class SaltRunCLI(SaltScriptBase):
  743. """
  744. Simple subclass to the salt-run CLI script
  745. """
  746. __cli_timeout_supported__ = True
  747. def get_minion_tgt(self, kwargs):
  748. return None
  749. def process_output(self, stdout, stderr, cmdline=None):
  750. if (
  751. "No minions matched the target. No command was sent, no jid was assigned.\n"
  752. in stdout
  753. ):
  754. stdout = stdout.split("\n", 1)[1:][0]
  755. return super().process_output(stdout, stderr, cmdline=cmdline)
  756. class SaltCpCLI(SaltScriptBase):
  757. """
  758. Simple subclass to the salt-cp CLI script
  759. """
  760. __cli_timeout_supported__ = True
  761. def process_output(self, stdout, stderr, cmdline=None):
  762. if (
  763. "No minions matched the target. No command was sent, no jid was assigned.\n"
  764. in stdout
  765. ):
  766. stdout = stdout.split("\n", 1)[1:][0]
  767. stdout, stderr, json_out = SaltScriptBase.process_output(
  768. self, stdout, stderr, cmdline
  769. )
  770. if json_out:
  771. try:
  772. return stdout, stderr, json_out[self._minion_tgt]
  773. except KeyError:
  774. return stdout, stderr, json_out
  775. return stdout, stderr, json_out
  776. class SaltKeyCLI(SaltScriptBase):
  777. """
  778. Simple subclass to the salt-key CLI script
  779. """
  780. _output_replace_re = re.compile(
  781. r"((The following keys are going to be.*:|Key for minion.*)\n)"
  782. )
  783. # As of Neon, salt-key still does not support --log-level
  784. # Only when we get the new logging merged in will we get that, so remove that CLI flag
  785. __cli_log_level_supported__ = SALT_KEY_LOG_LEVEL_SUPPORTED
  786. def get_minion_tgt(self, kwargs):
  787. return None
  788. def process_output(self, stdout, stderr, cmdline=None):
  789. # salt-key print()s to stdout regardless of output chosen
  790. stdout = self._output_replace_re.sub("", stdout)
  791. return super().process_output(stdout, stderr, cmdline=cmdline)
  792. SCRIPT_TEMPLATES = {
  793. "salt": textwrap.dedent(
  794. """
  795. import atexit
  796. from salt.scripts import salt_main
  797. if __name__ == '__main__':
  798. exitcode = 0
  799. try:
  800. salt_main()
  801. except SystemExit as exc:
  802. exitcode = exc.code
  803. sys.stdout.flush()
  804. sys.stderr.flush()
  805. atexit._run_exitfuncs()
  806. os._exit(exitcode)
  807. """
  808. ),
  809. "salt-api": textwrap.dedent(
  810. """
  811. import atexit
  812. import salt.cli
  813. def main():
  814. sapi = salt.cli.SaltAPI()
  815. sapi.start()
  816. if __name__ == '__main__':
  817. exitcode = 0
  818. try:
  819. main()
  820. except SystemExit as exc:
  821. exitcode = exc.code
  822. sys.stdout.flush()
  823. sys.stderr.flush()
  824. atexit._run_exitfuncs()
  825. os._exit(exitcode)
  826. """
  827. ),
  828. "common": textwrap.dedent(
  829. """
  830. import atexit
  831. from salt.scripts import salt_{0}
  832. import salt.utils.platform
  833. def main():
  834. if salt.utils.platform.is_windows():
  835. import os.path
  836. import py_compile
  837. cfile = os.path.splitext(__file__)[0] + '.pyc'
  838. if not os.path.exists(cfile):
  839. py_compile.compile(__file__, cfile)
  840. salt_{0}()
  841. if __name__ == '__main__':
  842. exitcode = 0
  843. try:
  844. main()
  845. except SystemExit as exc:
  846. exitcode = exc.code
  847. sys.stdout.flush()
  848. sys.stderr.flush()
  849. atexit._run_exitfuncs()
  850. os._exit(exitcode)
  851. """
  852. ),
  853. "coverage": textwrap.dedent(
  854. """
  855. # Setup coverage environment variables
  856. COVERAGE_FILE = os.path.join(CODE_DIR, '.coverage')
  857. COVERAGE_PROCESS_START = os.path.join(CODE_DIR, '.coveragerc')
  858. os.environ[str('COVERAGE_FILE')] = str(COVERAGE_FILE)
  859. os.environ[str('COVERAGE_PROCESS_START')] = str(COVERAGE_PROCESS_START)
  860. """
  861. ),
  862. "sitecustomize": textwrap.dedent(
  863. """
  864. # Allow sitecustomize.py to be importable for test coverage purposes
  865. SITECUSTOMIZE_DIR = r'{sitecustomize_dir}'
  866. PYTHONPATH = os.environ.get('PYTHONPATH') or None
  867. if PYTHONPATH is None:
  868. PYTHONPATH_ENV_VAR = SITECUSTOMIZE_DIR
  869. else:
  870. PYTHON_PATH_ENTRIES = PYTHONPATH.split(os.pathsep)
  871. if SITECUSTOMIZE_DIR in PYTHON_PATH_ENTRIES:
  872. PYTHON_PATH_ENTRIES.remove(SITECUSTOMIZE_DIR)
  873. PYTHON_PATH_ENTRIES.insert(0, SITECUSTOMIZE_DIR)
  874. PYTHONPATH_ENV_VAR = os.pathsep.join(PYTHON_PATH_ENTRIES)
  875. os.environ[str('PYTHONPATH')] = str(PYTHONPATH_ENV_VAR)
  876. if SITECUSTOMIZE_DIR in sys.path:
  877. sys.path.remove(SITECUSTOMIZE_DIR)
  878. sys.path.insert(0, SITECUSTOMIZE_DIR)
  879. """
  880. ),
  881. }
  882. def generate_script(
  883. bin_dir,
  884. script_name,
  885. executable=None,
  886. code_dir=None,
  887. inject_coverage=False,
  888. inject_sitecustomize=False,
  889. ):
  890. """
  891. Generate script
  892. """
  893. if not os.path.isdir(bin_dir):
  894. os.makedirs(bin_dir)
  895. cli_script_name = "cli_{}.py".format(script_name.replace("-", "_"))
  896. script_path = os.path.join(bin_dir, cli_script_name)
  897. if not os.path.isfile(script_path):
  898. log.info("Generating %s", script_path)
  899. with salt.utils.files.fopen(script_path, "w") as sfh:
  900. script_template = SCRIPT_TEMPLATES.get(script_name, None)
  901. if script_template is None:
  902. script_template = SCRIPT_TEMPLATES.get("common", None)
  903. if executable and len(executable) > 128:
  904. # Too long for a shebang, let's use /usr/bin/env and hope
  905. # the right python is picked up
  906. executable = "/usr/bin/env python"
  907. script_contents = (
  908. textwrap.dedent(
  909. """
  910. #!{executable}
  911. from __future__ import absolute_import
  912. import os
  913. import sys
  914. # We really do not want buffered output
  915. os.environ[str("PYTHONUNBUFFERED")] = str("1")
  916. # Don't write .pyc files or create them in __pycache__ directories
  917. os.environ[str("PYTHONDONTWRITEBYTECODE")] = str("1")
  918. """.format(
  919. executable=executable or sys.executable
  920. )
  921. ).strip()
  922. + "\n\n"
  923. )
  924. if code_dir:
  925. script_contents += (
  926. textwrap.dedent(
  927. """
  928. CODE_DIR = r'{code_dir}'
  929. if CODE_DIR in sys.path:
  930. sys.path.remove(CODE_DIR)
  931. sys.path.insert(0, CODE_DIR)""".format(
  932. code_dir=code_dir
  933. )
  934. ).strip()
  935. + "\n\n"
  936. )
  937. if inject_coverage and not code_dir:
  938. raise RuntimeError(
  939. "The inject coverage code needs to know the code root to find the "
  940. "path to the '.coveragerc' file. Please pass 'code_dir'."
  941. )
  942. if inject_coverage:
  943. script_contents += SCRIPT_TEMPLATES["coverage"].strip() + "\n\n"
  944. if inject_sitecustomize:
  945. script_contents += (
  946. SCRIPT_TEMPLATES["sitecustomize"]
  947. .format(
  948. sitecustomize_dir=os.path.join(
  949. os.path.dirname(__file__), "coverage"
  950. )
  951. )
  952. .strip()
  953. + "\n\n"
  954. )
  955. script_contents += (
  956. script_template.format(
  957. script_name.replace("salt-", "").replace("-", "_")
  958. ).strip()
  959. + "\n"
  960. )
  961. sfh.write(script_contents)
  962. log.debug(
  963. "Wrote the following contents to temp script %s:\n%s",
  964. script_path,
  965. script_contents,
  966. )
  967. fst = os.stat(script_path)
  968. os.chmod(script_path, fst.st_mode | stat.S_IEXEC)
  969. log.info("Returning script path %r", script_path)
  970. return script_path