SHA256
1
0
forked from pool/salt
salt/early-feature-support-config.patch

2193 lines
65 KiB
Diff

From 550db5157741b0a252bfc684f3496a7fd6d674ad Mon Sep 17 00:00:00 2001
From: Bo Maryniuk <bo@suse.de>
Date: Tue, 10 Jul 2018 12:06:33 +0200
Subject: [PATCH] early feature: support-config
Add support script function
Add salt-support starter
Initial support wrapper
Add data collector skeleton
Add default scenario of the support configuration
Add main flow for the collector.
Move support library to its own package
Add default support collection scenario
Add logging
Handle CLI error.
Update format of the default support scenario
Default archive name
Finalise local data collection
Write archive from memory objects.
Add colored console outputter for salt-support.
Use colored outputter
Add message output class
Remove try/except capture from the scripts and move to the runner directly
Implement output highlighter methods for CLI output
Move scenarios to profiles
Get return section from the output. Tolerate raw data.
Implement internal data collector
Add network stack examination to the default profile
Add an internal filetree function
Add a method to discard current session
Add a method to link a static file to the resulting archive
Implement internal function caller
Add internal functions
Add default root for the one-file support data
Set output device
Separate dynamic data and static files on the fs
Update color theme
Add ident to the error message
Report rejected files with the ident
Reuse system error exceptions and reduce stat on the file check
Use socket name of the host machine
Add options for profile and archive settings
Use archive name from options.
Get profile by config/options
Cleanup broken archive on crash/exception
Use profile from the options/configuration
Add more colored messages :-)
Initial implementation of get static profiles
Update docstring
Move PostgreSQL profile to its own
Handle profile listing, do not yield sys.exit on specific module
Add network profile
Add Salt's profile
Uncomment package profile
Allow several profiles to be specified
Remove comments, add parameter to get more profiles
Implement existing configuration finder
Add options to handle unit configurations
Pre-parse options prior run() to choose proper configuration target
Handle arg parse generic errors, unit mis-choose
Let cleanup be aware of pre-config state
Fix imports
Handle exit codes properly
Allow to overwrite existing archive
Use py2/3 exceptions equally
Include exit exception on debugging
Render profiles as Jinja2, add basic recursive caller to the template of the profile
Add "users" profile
Implement basic caller for the profile template
Add table output renderer
Fix typo
Remove table outputter
Allow default outputters and specify outputters inside the profile
Remove group.getent from the loop per each user
Add table outputter to network profile
Add text outputter to hostname/fqdn data
Remove network part from the default profile. Add text/table outputters.
Fix Py3 compat
Collect status (initial)
Avoid irrelevant to profile files
Add job profiles
Add profile template trace
Add inspection through the runners
Allow parameters in callers and runners
Handle non-dict iterables
Highlight template content in the trace log
Add return extractor from the local call returns
Move local runner to its own namespace
Lintfix: PEP8
Remove duplicate code
Fix caller return
Add description tag to the scenario
Add generic colored message
Add wrapping function. NOTE: it should be refactored with the other similar functions
Print description while processing the scenario
Turn off default profile and print help instead
Move command-line check before collector
Do not verify archive if help needs to be printed
Add console output unit test for indent output
Fix docstring
Rename test class
Refactor test to add setup/teardown
Add unit test to verify indent
Use direct constants instead of encoded strings
Add unit test for color indent rotation check
Add a test case for Collector class
Add unit test for closing the archive
Add unit test for add/write sections on the collector object
Add test for linking an external file
Cleanup tests on tear-down method
Add call count check
Add unit test for support collection section discard
Add unittest for SaltSupport's function config preparation
Fix docstring
Add unit test for local caller
Add unit test for local runner
Add unit test for internal function call
Add unit test for getting an action description from the action meta
Add unit test for internal function call
Add unit test for return extration
Add unit test for determine action type from the action meta
Add unit test for cleanup routine
Fix typo of method name
Add unit test for check existing archive
Add test suite for profile testing
Add unit test for default profile is YAML-parseable
Add unit test for user template profile rendering
Update unit test for all non-template profiles parse check
Add function to render a Jinja2 template by name
Use template rendering function
Add unit test on jobs-trace template for runner
Move function above the tests
Add current logfile, if defined in configuration
Bugfix: ignore logfile, if path was not found or not defined or is None
Lintfix: iteration over .keys()
Remove template "salt" from non-template checks
Lintfix: use salt.utils.files.fopen for resource leak prevention
Lintfix: PEP8 E302: expected 2 blank lines, found 0
Lintfix: use salt.utils.files.fopen instead of open
Lintfix: PEP8 E303: too many blank lines (3)
Lintfix: Uses of an external blacklisted import 'six': Please use 'import salt.ext.six as six'
Lintfix: use salt.utils.files.fopen instead of open
Fix unit tests
Fix six import
Mute pylint: file handler explicitly needed
Lintfix: explicitly close filehandle
Lintfix: mute fopen warning
Remove development stub. Ughh...
Removed blacklist of pkg_resources
---
salt/cli/support/__init__.py | 76 +++
salt/cli/support/collector.py | 538 +++++++++++++++++++++
salt/cli/support/console.py | 184 +++++++
salt/cli/support/intfunc.py | 40 ++
salt/cli/support/localrunner.py | 33 ++
salt/cli/support/profiles/default.yml | 71 +++
salt/cli/support/profiles/jobs-active.yml | 3 +
salt/cli/support/profiles/jobs-last.yml | 3 +
salt/cli/support/profiles/jobs-trace.yml | 7 +
salt/cli/support/profiles/network.yml | 27 ++
salt/cli/support/profiles/postgres.yml | 11 +
salt/cli/support/profiles/salt.yml | 9 +
salt/cli/support/profiles/users.yml | 22 +
salt/scripts.py | 15 +
salt/utils/parsers.py | 114 +++++
scripts/salt-support | 11 +
setup.py | 2 +
tests/unit/cli/test_support.py | 553 ++++++++++++++++++++++
18 files changed, 1719 insertions(+)
create mode 100644 salt/cli/support/__init__.py
create mode 100644 salt/cli/support/collector.py
create mode 100644 salt/cli/support/console.py
create mode 100644 salt/cli/support/intfunc.py
create mode 100644 salt/cli/support/localrunner.py
create mode 100644 salt/cli/support/profiles/default.yml
create mode 100644 salt/cli/support/profiles/jobs-active.yml
create mode 100644 salt/cli/support/profiles/jobs-last.yml
create mode 100644 salt/cli/support/profiles/jobs-trace.yml
create mode 100644 salt/cli/support/profiles/network.yml
create mode 100644 salt/cli/support/profiles/postgres.yml
create mode 100644 salt/cli/support/profiles/salt.yml
create mode 100644 salt/cli/support/profiles/users.yml
create mode 100755 scripts/salt-support
create mode 100644 tests/unit/cli/test_support.py
diff --git a/salt/cli/support/__init__.py b/salt/cli/support/__init__.py
new file mode 100644
index 0000000000..4fdf44186f
--- /dev/null
+++ b/salt/cli/support/__init__.py
@@ -0,0 +1,76 @@
+"""
+Get default scenario of the support.
+"""
+import logging
+import os
+
+import jinja2
+import salt.exceptions
+import yaml
+
+log = logging.getLogger(__name__)
+
+
+def _render_profile(path, caller, runner):
+ """
+ Render profile as Jinja2.
+ :param path:
+ :return:
+ """
+ env = jinja2.Environment(
+ loader=jinja2.FileSystemLoader(os.path.dirname(path)), trim_blocks=False
+ )
+ return (
+ env.get_template(os.path.basename(path))
+ .render(salt=caller, runners=runner)
+ .strip()
+ )
+
+
+def get_profile(profile, caller, runner):
+ """
+ Get profile.
+
+ :param profile:
+ :return:
+ """
+ profiles = profile.split(",")
+ data = {}
+ for profile in profiles:
+ if os.path.basename(profile) == profile:
+ profile = profile.split(".")[0] # Trim extension if someone added it
+ profile_path = os.path.join(
+ os.path.dirname(__file__), "profiles", profile + ".yml"
+ )
+ else:
+ profile_path = profile
+ if os.path.exists(profile_path):
+ try:
+ rendered_template = _render_profile(profile_path, caller, runner)
+ log.trace("\n{d}\n{t}\n{d}\n".format(d="-" * 80, t=rendered_template))
+ data.update(yaml.load(rendered_template))
+ except Exception as ex:
+ log.debug(ex, exc_info=True)
+ raise salt.exceptions.SaltException(
+ "Rendering profile failed: {}".format(ex)
+ )
+ else:
+ raise salt.exceptions.SaltException(
+ 'Profile "{}" is not found.'.format(profile)
+ )
+
+ return data
+
+
+def get_profiles(config):
+ """
+ Get available profiles.
+
+ :return:
+ """
+ profiles = []
+ for profile_name in os.listdir(os.path.join(os.path.dirname(__file__), "profiles")):
+ if profile_name.endswith(".yml"):
+ profiles.append(profile_name.split(".")[0])
+
+ return sorted(profiles)
diff --git a/salt/cli/support/collector.py b/salt/cli/support/collector.py
new file mode 100644
index 0000000000..bfbf491f5b
--- /dev/null
+++ b/salt/cli/support/collector.py
@@ -0,0 +1,538 @@
+import builtins as exceptions
+import copy
+import json
+import logging
+import os
+import sys
+import tarfile
+import time
+from io import BytesIO
+from io import IOBase as file
+
+import salt.cli.caller
+import salt.cli.support
+import salt.cli.support.console
+import salt.cli.support.intfunc
+import salt.cli.support.localrunner
+import salt.defaults.exitcodes
+import salt.exceptions
+import salt.ext.six as six
+import salt.output.table_out
+import salt.runner
+import salt.utils.files
+import salt.utils.parsers
+import salt.utils.platform
+import salt.utils.process
+import salt.utils.stringutils
+import salt.utils.verify
+import yaml
+
+salt.output.table_out.__opts__ = {}
+log = logging.getLogger(__name__)
+
+
+class SupportDataCollector:
+ """
+ Data collector. It behaves just like another outputter,
+ except it grabs the data to the archive files.
+ """
+
+ def __init__(self, name, output):
+ """
+ constructor of the data collector
+ :param name:
+ :param path:
+ :param format:
+ """
+ self.archive_path = name
+ self.__default_outputter = output
+ self.__format = format
+ self.__arch = None
+ self.__current_section = None
+ self.__current_section_name = None
+ self.__default_root = time.strftime("%Y.%m.%d-%H.%M.%S-snapshot")
+ self.out = salt.cli.support.console.MessagesOutput()
+
+ def open(self):
+ """
+ Opens archive.
+ :return:
+ """
+ if self.__arch is not None:
+ raise salt.exceptions.SaltException("Archive already opened.")
+ self.__arch = tarfile.TarFile.bz2open(self.archive_path, "w")
+
+ def close(self):
+ """
+ Closes the archive.
+ :return:
+ """
+ if self.__arch is None:
+ raise salt.exceptions.SaltException("Archive already closed")
+ self._flush_content()
+ self.__arch.close()
+ self.__arch = None
+
+ def _flush_content(self):
+ """
+ Flush content to the archive
+ :return:
+ """
+ if self.__current_section is not None:
+ buff = BytesIO()
+ buff._dirty = False
+ for action_return in self.__current_section:
+ for title, ret_data in action_return.items():
+ if isinstance(ret_data, file):
+ self.out.put(ret_data.name, indent=4)
+ self.__arch.add(ret_data.name, arcname=ret_data.name)
+ else:
+ buff.write(salt.utils.stringutils.to_bytes(title + "\n"))
+ buff.write(
+ salt.utils.stringutils.to_bytes(("-" * len(title)) + "\n\n")
+ )
+ buff.write(salt.utils.stringutils.to_bytes(ret_data))
+ buff.write(salt.utils.stringutils.to_bytes("\n\n\n"))
+ buff._dirty = True
+ if buff._dirty:
+ buff.seek(0)
+ tar_info = tarfile.TarInfo(
+ name="{}/{}".format(
+ self.__default_root, self.__current_section_name
+ )
+ )
+ if not hasattr(buff, "getbuffer"): # Py2's BytesIO is older
+ buff.getbuffer = buff.getvalue
+ tar_info.size = len(buff.getbuffer())
+ self.__arch.addfile(tarinfo=tar_info, fileobj=buff)
+
+ def add(self, name):
+ """
+ Start a new section.
+ :param name:
+ :return:
+ """
+ if self.__current_section:
+ self._flush_content()
+ self.discard_current(name)
+
+ def discard_current(self, name=None):
+ """
+ Discard current section
+ :return:
+ """
+ self.__current_section = []
+ self.__current_section_name = name
+
+ def write(self, title, data, output=None):
+ """
+ Add a data to the current opened section.
+ :return:
+ """
+ if not isinstance(data, (dict, list, tuple)):
+ data = {"raw-content": str(data)}
+ output = output or self.__default_outputter
+
+ if output != "null":
+ try:
+ if isinstance(data, dict) and "return" in data:
+ data = data["return"]
+ content = salt.output.try_printout(
+ data, output, {"extension_modules": "", "color": False}
+ )
+ except Exception: # Fall-back to just raw YAML
+ content = None
+ else:
+ content = None
+
+ if content is None:
+ data = json.loads(json.dumps(data))
+ if isinstance(data, dict) and data.get("return"):
+ data = data.get("return")
+ content = yaml.safe_dump(data, default_flow_style=False, indent=4)
+
+ self.__current_section.append({title: content})
+
+ def link(self, title, path):
+ """
+ Add a static file on the file system.
+
+ :param title:
+ :param path:
+ :return:
+ """
+ # The filehandler needs to be explicitly passed here, so PyLint needs to accept that.
+ # pylint: disable=W8470
+ if not isinstance(path, file):
+ path = salt.utils.files.fopen(path)
+ self.__current_section.append({title: path})
+ # pylint: enable=W8470
+
+
+class SaltSupport(salt.utils.parsers.SaltSupportOptionParser):
+ """
+ Class to run Salt Support subsystem.
+ """
+
+ RUNNER_TYPE = "run"
+ CALL_TYPE = "call"
+
+ def _setup_fun_config(self, fun_conf):
+ """
+ Setup function configuration.
+
+ :param conf:
+ :return:
+ """
+ conf = copy.deepcopy(self.config)
+ conf["file_client"] = "local"
+ conf["fun"] = ""
+ conf["arg"] = []
+ conf["kwarg"] = {}
+ conf["cache_jobs"] = False
+ conf["print_metadata"] = False
+ conf.update(fun_conf)
+ conf["fun"] = conf["fun"].split(":")[-1] # Discard typing prefix
+
+ return conf
+
+ def _get_runner(self, conf):
+ """
+ Get & setup runner.
+
+ :param conf:
+ :return:
+ """
+ conf = self._setup_fun_config(copy.deepcopy(conf))
+ if not getattr(self, "_runner", None):
+ self._runner = salt.cli.support.localrunner.LocalRunner(conf)
+ else:
+ self._runner.opts = conf
+ return self._runner
+
+ def _get_caller(self, conf):
+ """
+ Get & setup caller from the factory.
+
+ :param conf:
+ :return:
+ """
+ conf = self._setup_fun_config(copy.deepcopy(conf))
+ if not getattr(self, "_caller", None):
+ self._caller = salt.cli.caller.Caller.factory(conf)
+ else:
+ self._caller.opts = conf
+ return self._caller
+
+ def _local_call(self, call_conf):
+ """
+ Execute local call
+ """
+ try:
+ ret = self._get_caller(call_conf).call()
+ except SystemExit:
+ ret = "Data is not available at this moment"
+ self.out.error(ret)
+ except Exception as ex:
+ ret = "Unhandled exception occurred: {}".format(ex)
+ log.debug(ex, exc_info=True)
+ self.out.error(ret)
+
+ return ret
+
+ def _local_run(self, run_conf):
+ """
+ Execute local runner
+
+ :param run_conf:
+ :return:
+ """
+ try:
+ ret = self._get_runner(run_conf).run()
+ except SystemExit:
+ ret = "Runner is not available at this moment"
+ self.out.error(ret)
+ except Exception as ex:
+ ret = "Unhandled exception occurred: {}".format(ex)
+ log.debug(ex, exc_info=True)
+
+ return ret
+
+ def _internal_function_call(self, call_conf):
+ """
+ Call internal function.
+
+ :param call_conf:
+ :return:
+ """
+
+ def stub(*args, **kwargs):
+ message = "Function {} is not available".format(call_conf["fun"])
+ self.out.error(message)
+ log.debug(
+ 'Attempt to run "{fun}" with {arg} arguments and {kwargs} parameters.'.format(
+ **call_conf
+ )
+ )
+ return message
+
+ return getattr(salt.cli.support.intfunc, call_conf["fun"], stub)(
+ self.collector, *call_conf["arg"], **call_conf["kwargs"]
+ )
+
+ def _get_action(self, action_meta):
+ """
+ Parse action and turn into a calling point.
+ :param action_meta:
+ :return:
+ """
+ conf = {
+ "fun": list(action_meta.keys())[0],
+ "arg": [],
+ "kwargs": {},
+ }
+ if not len(conf["fun"].split(".")) - 1:
+ conf["salt.int.intfunc"] = True
+
+ action_meta = action_meta[conf["fun"]]
+ info = action_meta.get("info", "Action for {}".format(conf["fun"]))
+ for arg in action_meta.get("args") or []:
+ if not isinstance(arg, dict):
+ conf["arg"].append(arg)
+ else:
+ conf["kwargs"].update(arg)
+
+ return info, action_meta.get("output"), conf
+
+ def collect_internal_data(self):
+ """
+ Dumps current running pillars, configuration etc.
+ :return:
+ """
+ section = "configuration"
+ self.out.put(section)
+ self.collector.add(section)
+ self.out.put("Saving config", indent=2)
+ self.collector.write("General Configuration", self.config)
+ self.out.put("Saving pillars", indent=2)
+ self.collector.write(
+ "Active Pillars", self._local_call({"fun": "pillar.items"})
+ )
+
+ section = "highstate"
+ self.out.put(section)
+ self.collector.add(section)
+ self.out.put("Saving highstate", indent=2)
+ self.collector.write(
+ "Rendered highstate", self._local_call({"fun": "state.show_highstate"})
+ )
+
+ def _extract_return(self, data):
+ """
+ Extracts return data from the results.
+
+ :param data:
+ :return:
+ """
+ if isinstance(data, dict):
+ data = data.get("return", data)
+
+ return data
+
+ def collect_local_data(self):
+ """
+ Collects master system data.
+ :return:
+ """
+
+ def call(func, *args, **kwargs):
+ """
+ Call wrapper for templates
+ :param func:
+ :return:
+ """
+ return self._extract_return(
+ self._local_call({"fun": func, "arg": args, "kwarg": kwargs})
+ )
+
+ def run(func, *args, **kwargs):
+ """
+ Runner wrapper for templates
+ :param func:
+ :return:
+ """
+ return self._extract_return(
+ self._local_run({"fun": func, "arg": args, "kwarg": kwargs})
+ )
+
+ scenario = salt.cli.support.get_profile(
+ self.config["support_profile"], call, run
+ )
+ for category_name in scenario:
+ self.out.put(category_name)
+ self.collector.add(category_name)
+ for action in scenario[category_name]:
+ if not action:
+ continue
+ action_name = next(iter(action))
+ if not isinstance(action[action_name], str):
+ info, output, conf = self._get_action(action)
+ action_type = self._get_action_type(
+ action
+ ) # run:<something> for runners
+ if action_type == self.RUNNER_TYPE:
+ self.out.put("Running {}".format(info.lower()), indent=2)
+ self.collector.write(info, self._local_run(conf), output=output)
+ elif action_type == self.CALL_TYPE:
+ if not conf.get("salt.int.intfunc"):
+ self.out.put("Collecting {}".format(info.lower()), indent=2)
+ self.collector.write(
+ info, self._local_call(conf), output=output
+ )
+ else:
+ self.collector.discard_current()
+ self._internal_function_call(conf)
+ else:
+ self.out.error(
+ 'Unknown action type "{}" for action: {}'.format(
+ action_type, action
+ )
+ )
+ else:
+ # TODO: This needs to be moved then to the utils.
+ # But the code is not yet there (other PRs)
+ self.out.msg(
+ "\n".join(salt.cli.support.console.wrap(action[action_name])),
+ ident=2,
+ )
+
+ def _get_action_type(self, action):
+ """
+ Get action type.
+ :param action:
+ :return:
+ """
+ action_name = next(iter(action or {"": None}))
+ if ":" not in action_name:
+ action_name = "{}:{}".format(self.CALL_TYPE, action_name)
+
+ return action_name.split(":")[0] or None
+
+ def collect_targets_data(self):
+ """
+ Collects minion targets data
+ :return:
+ """
+ # TODO: remote collector?
+
+ def _cleanup(self):
+ """
+ Cleanup if crash/exception
+ :return:
+ """
+ if (
+ hasattr(self, "config")
+ and self.config.get("support_archive")
+ and os.path.exists(self.config["support_archive"])
+ ):
+ self.out.warning("Terminated earlier, cleaning up")
+ os.unlink(self.config["support_archive"])
+
+ def _check_existing_archive(self):
+ """
+ Check if archive exists or not. If exists and --force was not specified,
+ bail out. Otherwise remove it and move on.
+
+ :return:
+ """
+ if os.path.exists(self.config["support_archive"]):
+ if self.config["support_archive_force_overwrite"]:
+ self.out.warning(
+ "Overwriting existing archive: {}".format(
+ self.config["support_archive"]
+ )
+ )
+ os.unlink(self.config["support_archive"])
+ ret = True
+ else:
+ self.out.warning(
+ "File {} already exists.".format(self.config["support_archive"])
+ )
+ ret = False
+ else:
+ ret = True
+
+ return ret
+
+ def run(self):
+ exit_code = salt.defaults.exitcodes.EX_OK
+ self.out = salt.cli.support.console.MessagesOutput()
+ try:
+ self.parse_args()
+ except (Exception, SystemExit) as ex:
+ if not isinstance(ex, exceptions.SystemExit):
+ exit_code = salt.defaults.exitcodes.EX_GENERIC
+ self.out.error(ex)
+ elif isinstance(ex, exceptions.SystemExit):
+ exit_code = ex.code
+ else:
+ exit_code = salt.defaults.exitcodes.EX_GENERIC
+ self.out.error(ex)
+ else:
+ if self.config["log_level"] not in ("quiet",):
+ self.setup_logfile_logger()
+ salt.utils.verify.verify_log(self.config)
+ salt.cli.support.log = log # Pass update logger so trace is available
+
+ if self.config["support_profile_list"]:
+ self.out.put("List of available profiles:")
+ for idx, profile in enumerate(
+ salt.cli.support.get_profiles(self.config)
+ ):
+ msg_template = " {}. ".format(idx + 1) + "{}"
+ self.out.highlight(msg_template, profile)
+ exit_code = salt.defaults.exitcodes.EX_OK
+ elif self.config["support_show_units"]:
+ self.out.put("List of available units:")
+ for idx, unit in enumerate(self.find_existing_configs(None)):
+ msg_template = " {}. ".format(idx + 1) + "{}"
+ self.out.highlight(msg_template, unit)
+ exit_code = salt.defaults.exitcodes.EX_OK
+ else:
+ if not self.config["support_profile"]:
+ self.print_help()
+ raise SystemExit()
+
+ if self._check_existing_archive():
+ try:
+ self.collector = SupportDataCollector(
+ self.config["support_archive"],
+ output=self.config["support_output_format"],
+ )
+ except Exception as ex:
+ self.out.error(ex)
+ exit_code = salt.defaults.exitcodes.EX_GENERIC
+ log.debug(ex, exc_info=True)
+ else:
+ try:
+ self.collector.open()
+ self.collect_local_data()
+ self.collect_internal_data()
+ self.collect_targets_data()
+ self.collector.close()
+
+ archive_path = self.collector.archive_path
+ self.out.highlight(
+ '\nSupport data has been written to "{}" file.\n',
+ archive_path,
+ _main="YELLOW",
+ )
+ except Exception as ex:
+ self.out.error(ex)
+ log.debug(ex, exc_info=True)
+ exit_code = salt.defaults.exitcodes.EX_SOFTWARE
+
+ if exit_code:
+ self._cleanup()
+
+ sys.exit(exit_code)
diff --git a/salt/cli/support/console.py b/salt/cli/support/console.py
new file mode 100644
index 0000000000..266b645479
--- /dev/null
+++ b/salt/cli/support/console.py
@@ -0,0 +1,184 @@
+"""
+Collection of tools to report messages to console.
+
+NOTE: This is subject to incorporate other formatting bits
+ from all around everywhere and then to be moved to utils.
+"""
+
+
+import os
+import sys
+import textwrap
+
+import salt.utils.color
+
+
+class IndentOutput:
+ """
+ Paint different indends in different output.
+ """
+
+ def __init__(self, conf=None, device=sys.stdout):
+ if conf is None:
+ conf = {0: "CYAN", 2: "GREEN", 4: "LIGHT_BLUE", 6: "BLUE"}
+ self._colors_conf = conf
+ self._device = device
+ self._colors = salt.utils.color.get_colors()
+ self._default_color = "GREEN"
+ self._default_hl_color = "LIGHT_GREEN"
+
+ def put(self, message, indent=0):
+ """
+ Print message with an indent.
+
+ :param message:
+ :param indent:
+ :return:
+ """
+ color = self._colors_conf.get(
+ indent + indent % 2, self._colors_conf.get(0, self._default_color)
+ )
+
+ for chunk in [" " * indent, self._colors[color], message, self._colors["ENDC"]]:
+ self._device.write(str(chunk))
+ self._device.write(os.linesep)
+ self._device.flush()
+
+
+class MessagesOutput(IndentOutput):
+ """
+ Messages output to the CLI.
+ """
+
+ def msg(self, message, title=None, title_color=None, color="BLUE", ident=0):
+ """
+ Hint message.
+
+ :param message:
+ :param title:
+ :param title_color:
+ :param color:
+ :param ident:
+ :return:
+ """
+ if title and not title_color:
+ title_color = color
+ if title_color and not title:
+ title_color = None
+
+ self.__colored_output(title, message, title_color, color, ident=ident)
+
+ def info(self, message, ident=0):
+ """
+ Write an info message to the CLI.
+
+ :param message:
+ :param ident:
+ :return:
+ """
+ self.__colored_output("Info", message, "GREEN", "LIGHT_GREEN", ident=ident)
+
+ def warning(self, message, ident=0):
+ """
+ Write a warning message to the CLI.
+
+ :param message:
+ :param ident:
+ :return:
+ """
+ self.__colored_output("Warning", message, "YELLOW", "LIGHT_YELLOW", ident=ident)
+
+ def error(self, message, ident=0):
+ """
+ Write an error message to the CLI.
+
+ :param message:
+ :param ident
+ :return:
+ """
+ self.__colored_output("Error", message, "RED", "LIGHT_RED", ident=ident)
+
+ def __colored_output(self, title, message, title_color, message_color, ident=0):
+ if title and not title.endswith(":"):
+ _linesep = title.endswith(os.linesep)
+ title = "{}:{}".format(title.strip(), _linesep and os.linesep or " ")
+
+ for chunk in [
+ title_color and self._colors[title_color] or None,
+ " " * ident,
+ title,
+ self._colors[message_color],
+ message,
+ self._colors["ENDC"],
+ ]:
+ if chunk:
+ self._device.write(str(chunk))
+ self._device.write(os.linesep)
+ self._device.flush()
+
+ def highlight(self, message, *values, **colors):
+ """
+ Highlighter works the way that message parameter is a template,
+ the "values" is a list of arguments going one after another as values there.
+ And so the "colors" should designate either highlight color or alternate for each.
+
+ Example:
+
+ highlight('Hello {}, there! It is {}.', 'user', 'daytime', _main='GREEN', _highlight='RED')
+ highlight('Hello {}, there! It is {}.', 'user', 'daytime', _main='GREEN', _highlight='RED', 'daytime'='YELLOW')
+
+ First example will highlight all the values in the template with the red color.
+ Second example will highlight the second value with the yellow color.
+
+ Usage:
+
+ colors:
+ _main: Sets the main color (or default is used)
+ _highlight: Sets the alternative color for everything
+ 'any phrase' that is the same in the "values" can override color.
+
+ :param message:
+ :param formatted:
+ :param colors:
+ :return:
+ """
+
+ m_color = colors.get("_main", self._default_color)
+ h_color = colors.get("_highlight", self._default_hl_color)
+
+ _values = []
+ for value in values:
+ _values.append(
+ "{p}{c}{r}".format(
+ p=self._colors[colors.get(value, h_color)],
+ c=value,
+ r=self._colors[m_color],
+ )
+ )
+ self._device.write(
+ "{s}{m}{e}".format(
+ s=self._colors[m_color],
+ m=message.format(*_values),
+ e=self._colors["ENDC"],
+ )
+ )
+ self._device.write(os.linesep)
+ self._device.flush()
+
+
+def wrap(txt, width=80, ident=0):
+ """
+ Wrap text to the required dimensions and clean it up, prepare for display.
+
+ :param txt:
+ :param width:
+ :return:
+ """
+ ident = " " * ident
+ txt = (txt or "").replace(os.linesep, " ").strip()
+
+ wrapper = textwrap.TextWrapper()
+ wrapper.fix_sentence_endings = False
+ wrapper.initial_indent = wrapper.subsequent_indent = ident
+
+ return wrapper.wrap(txt)
diff --git a/salt/cli/support/intfunc.py b/salt/cli/support/intfunc.py
new file mode 100644
index 0000000000..d3d8f83cb8
--- /dev/null
+++ b/salt/cli/support/intfunc.py
@@ -0,0 +1,40 @@
+"""
+Internal functions.
+"""
+# Maybe this needs to be a modules in a future?
+
+import os
+
+import salt.utils.files
+from salt.cli.support.console import MessagesOutput
+
+out = MessagesOutput()
+
+
+def filetree(collector, path):
+ """
+ Add all files in the tree. If the "path" is a file,
+ only that file will be added.
+
+ :param path: File or directory
+ :return:
+ """
+ if not path:
+ out.error("Path not defined", ident=2)
+ else:
+ # The filehandler needs to be explicitly passed here, so PyLint needs to accept that.
+ # pylint: disable=W8470
+ if os.path.isfile(path):
+ filename = os.path.basename(path)
+ try:
+ file_ref = salt.utils.files.fopen(path) # pylint: disable=W
+ out.put("Add {}".format(filename), indent=2)
+ collector.add(filename)
+ collector.link(title=path, path=file_ref)
+ except Exception as err:
+ out.error(err, ident=4)
+ # pylint: enable=W8470
+ else:
+ for fname in os.listdir(path):
+ fname = os.path.join(path, fname)
+ filetree(collector, fname)
diff --git a/salt/cli/support/localrunner.py b/salt/cli/support/localrunner.py
new file mode 100644
index 0000000000..ad10eda0b0
--- /dev/null
+++ b/salt/cli/support/localrunner.py
@@ -0,0 +1,33 @@
+"""
+Local Runner
+"""
+
+import logging
+
+import salt.runner
+import salt.utils.platform
+import salt.utils.process
+
+log = logging.getLogger(__name__)
+
+
+class LocalRunner(salt.runner.Runner):
+ """
+ Runner class that changes its default behaviour.
+ """
+
+ def _proc_function(self, fun, low, user, tag, jid, daemonize=True):
+ """
+ Same as original _proc_function in AsyncClientMixin,
+ except it calls "low" without firing a print event.
+ """
+ if daemonize and not salt.utils.platform.is_windows():
+ salt.log.setup.shutdown_multiprocessing_logging()
+ salt.utils.process.daemonize()
+ salt.log.setup.setup_multiprocessing_logging()
+
+ low["__jid__"] = jid
+ low["__user__"] = user
+ low["__tag__"] = tag
+
+ return self.low(fun, low, print_event=False, full_return=False)
diff --git a/salt/cli/support/profiles/default.yml b/salt/cli/support/profiles/default.yml
new file mode 100644
index 0000000000..01d9a26193
--- /dev/null
+++ b/salt/cli/support/profiles/default.yml
@@ -0,0 +1,71 @@
+sysinfo:
+ - description: |
+ Get the Salt grains of the current system.
+ - grains.items:
+ info: System grains
+
+packages:
+ - description: |
+ Fetch list of all the installed packages.
+ - pkg.list_pkgs:
+ info: Installed packages
+
+repositories:
+ - pkg.list_repos:
+ info: Available repositories
+
+upgrades:
+ - pkg.list_upgrades:
+ info: Possible upgrades
+
+## TODO: Some data here belongs elsewhere and also is duplicated
+status:
+ - status.version:
+ info: Status version
+ - status.cpuinfo:
+ info: CPU information
+ - status.cpustats:
+ info: CPU stats
+ - status.diskstats:
+ info: Disk stats
+ - status.loadavg:
+ info: Average load of the current system
+ - status.uptime:
+ info: Uptime of the machine
+ - status.meminfo:
+ info: Information about memory
+ - status.vmstats:
+ info: Virtual memory stats
+ - status.netdev:
+ info: Network device stats
+ - status.nproc:
+ info: Number of processing units available on this system
+ - status.procs:
+ info: Process data
+
+general-health:
+ - ps.boot_time:
+ info: System Boot Time
+ - ps.swap_memory:
+ info: Swap Memory
+ output: txt
+ - ps.cpu_times:
+ info: CPU times
+ - ps.disk_io_counters:
+ info: Disk IO counters
+ - ps.disk_partition_usage:
+ info: Disk partition usage
+ output: table
+ - ps.disk_partitions:
+ info: Disk partitions
+ output: table
+ - ps.top:
+ info: Top CPU consuming processes
+
+system.log:
+ # This works on any file system object.
+ - filetree:
+ info: Add system log
+ args:
+ - /var/log/syslog
+
diff --git a/salt/cli/support/profiles/jobs-active.yml b/salt/cli/support/profiles/jobs-active.yml
new file mode 100644
index 0000000000..508c54ece7
--- /dev/null
+++ b/salt/cli/support/profiles/jobs-active.yml
@@ -0,0 +1,3 @@
+jobs-active:
+ - run:jobs.active:
+ info: List of all actively running jobs
diff --git a/salt/cli/support/profiles/jobs-last.yml b/salt/cli/support/profiles/jobs-last.yml
new file mode 100644
index 0000000000..e3b719f552
--- /dev/null
+++ b/salt/cli/support/profiles/jobs-last.yml
@@ -0,0 +1,3 @@
+jobs-last:
+ - run:jobs.last_run:
+ info: List all detectable jobs and associated functions
diff --git a/salt/cli/support/profiles/jobs-trace.yml b/salt/cli/support/profiles/jobs-trace.yml
new file mode 100644
index 0000000000..00b28e0502
--- /dev/null
+++ b/salt/cli/support/profiles/jobs-trace.yml
@@ -0,0 +1,7 @@
+jobs-details:
+ {% for job in runners('jobs.list_jobs') %}
+ - run:jobs.list_job:
+ info: Details on JID {{job}}
+ args:
+ - {{job}}
+ {% endfor %}
diff --git a/salt/cli/support/profiles/network.yml b/salt/cli/support/profiles/network.yml
new file mode 100644
index 0000000000..268f02e61f
--- /dev/null
+++ b/salt/cli/support/profiles/network.yml
@@ -0,0 +1,27 @@
+network:
+ - network.get_hostname:
+ info: Hostname
+ output: txt
+ - network.get_fqdn:
+ info: FQDN
+ output: txt
+ - network.default_route:
+ info: Default route
+ output: table
+ - network.interfaces:
+ info: All the available interfaces
+ output: table
+ - network.subnets:
+ info: List of IPv4 subnets
+ - network.subnets6:
+ info: List of IPv6 subnets
+ - network.routes:
+ info: Network configured routes from routing tables
+ output: table
+ - network.netstat:
+ info: Information on open ports and states
+ output: table
+ - network.active_tcp:
+ info: All running TCP connections
+ - network.arp:
+ info: ARP table
diff --git a/salt/cli/support/profiles/postgres.yml b/salt/cli/support/profiles/postgres.yml
new file mode 100644
index 0000000000..2238752c7a
--- /dev/null
+++ b/salt/cli/support/profiles/postgres.yml
@@ -0,0 +1,11 @@
+system.log:
+ - filetree:
+ info: Add system log
+ args:
+ - /var/log/syslog
+
+etc/postgres:
+ - filetree:
+ info: Pick entire /etc/postgresql
+ args:
+ - /etc/postgresql
diff --git a/salt/cli/support/profiles/salt.yml b/salt/cli/support/profiles/salt.yml
new file mode 100644
index 0000000000..4b18d98870
--- /dev/null
+++ b/salt/cli/support/profiles/salt.yml
@@ -0,0 +1,9 @@
+sysinfo:
+ - grains.items:
+ info: System grains
+
+logfile:
+ - filetree:
+ info: Add current logfile
+ args:
+ - {{salt('config.get', 'log_file')}}
diff --git a/salt/cli/support/profiles/users.yml b/salt/cli/support/profiles/users.yml
new file mode 100644
index 0000000000..391acdb606
--- /dev/null
+++ b/salt/cli/support/profiles/users.yml
@@ -0,0 +1,22 @@
+all-users:
+ {%for uname in salt('user.list_users') %}
+ - user.info:
+ info: Information about "{{uname}}"
+ args:
+ - {{uname}}
+ - user.list_groups:
+ info: List groups for user "{{uname}}"
+ args:
+ - {{uname}}
+ - shadow.info:
+ info: Shadow information about user "{{uname}}"
+ args:
+ - {{uname}}
+ - cron.raw_cron:
+ info: Cron for user "{{uname}}"
+ args:
+ - {{uname}}
+ {%endfor%}
+ - group.getent:
+ info: List of all available groups
+ output: table
diff --git a/salt/scripts.py b/salt/scripts.py
index 8f3cde8477..e5c248f011 100644
--- a/salt/scripts.py
+++ b/salt/scripts.py
@@ -592,3 +592,18 @@ def salt_unity():
sys.argv.pop(1)
s_fun = getattr(sys.modules[__name__], "salt_{}".format(cmd))
s_fun()
+
+
+def salt_support():
+ """
+ Run Salt Support that collects system data, logs etc for debug and support purposes.
+ :return:
+ """
+
+ import salt.cli.support.collector
+
+ if "" in sys.path:
+ sys.path.remove("")
+ client = salt.cli.support.collector.SaltSupport()
+ _install_signal_handlers(client)
+ client.run()
diff --git a/salt/utils/parsers.py b/salt/utils/parsers.py
index 952f9aebc5..c1422a9556 100644
--- a/salt/utils/parsers.py
+++ b/salt/utils/parsers.py
@@ -17,6 +17,7 @@ import optparse
import os
import signal
import sys
+import tempfile
import traceback
import types
from functools import partial
@@ -31,6 +32,7 @@ import salt.utils.args
import salt.utils.data
import salt.utils.files
import salt.utils.jid
+import salt.utils.network
import salt.utils.platform
import salt.utils.process
import salt.utils.stringutils
@@ -2049,6 +2051,118 @@ class SyndicOptionParser(
return opts
+class SaltSupportOptionParser(
+ OptionParser,
+ ConfigDirMixIn,
+ MergeConfigMixIn,
+ LogLevelMixIn,
+ TimeoutMixIn,
+ metaclass=OptionParserMeta,
+):
+ default_timeout = 5
+ description = "Salt Support is a program to collect all support data: logs, system configuration etc."
+ usage = "%prog [options] '<target>' <function> [arguments]"
+ # ConfigDirMixIn config filename attribute
+ _config_filename_ = "master"
+
+ # LogLevelMixIn attributes
+ _default_logging_level_ = config.DEFAULT_MASTER_OPTS["log_level"]
+ _default_logging_logfile_ = config.DEFAULT_MASTER_OPTS["log_file"]
+
+ def _mixin_setup(self):
+ self.add_option(
+ "-P",
+ "--show-profiles",
+ default=False,
+ action="store_true",
+ dest="support_profile_list",
+ help="Show available profiles",
+ )
+ self.add_option(
+ "-p",
+ "--profile",
+ default="",
+ dest="support_profile",
+ help='Specify support profile or comma-separated profiles, e.g.: "salt,network"',
+ )
+ support_archive = "{t}/{h}-support.tar.bz2".format(
+ t=tempfile.gettempdir(), h=salt.utils.network.get_fqhostname()
+ )
+ self.add_option(
+ "-a",
+ "--archive",
+ default=support_archive,
+ dest="support_archive",
+ help=(
+ "Specify name of the resulting support archive. "
+ 'Default is "{f}".'.format(f=support_archive)
+ ),
+ )
+ self.add_option(
+ "-u",
+ "--unit",
+ default="",
+ dest="support_unit",
+ help='Specify examined unit (default "master").',
+ )
+ self.add_option(
+ "-U",
+ "--show-units",
+ default=False,
+ action="store_true",
+ dest="support_show_units",
+ help="Show available units",
+ )
+ self.add_option(
+ "-f",
+ "--force",
+ default=False,
+ action="store_true",
+ dest="support_archive_force_overwrite",
+ help="Force overwrite existing archive, if exists",
+ )
+ self.add_option(
+ "-o",
+ "--out",
+ default="null",
+ dest="support_output_format",
+ help=(
+ "Set the default output using the specified outputter, "
+ 'unless profile does not overrides this. Default: "yaml".'
+ ),
+ )
+
+ def find_existing_configs(self, default):
+ """
+ Find configuration files on the system.
+ :return:
+ """
+ configs = []
+ for cfg in [default, self._config_filename_, "minion", "proxy", "cloud", "spm"]:
+ if not cfg:
+ continue
+ config_path = self.get_config_file_path(cfg)
+ if os.path.exists(config_path):
+ configs.append(cfg)
+
+ if default and default not in configs:
+ raise SystemExit("Unknown configuration unit: {}".format(default))
+
+ return configs
+
+ def setup_config(self, cfg=None):
+ """
+ Open suitable config file.
+ :return:
+ """
+ _opts, _args = optparse.OptionParser.parse_args(self)
+ configs = self.find_existing_configs(_opts.support_unit)
+ if cfg not in configs:
+ cfg = configs[0]
+
+ return config.master_config(self.get_config_file_path(cfg))
+
+
class SaltCMDOptionParser(
OptionParser,
ConfigDirMixIn,
diff --git a/scripts/salt-support b/scripts/salt-support
new file mode 100755
index 0000000000..4e0e79f3ea
--- /dev/null
+++ b/scripts/salt-support
@@ -0,0 +1,11 @@
+#!/usr/bin/env python
+"""
+Salt support is to collect logs,
+debug data and system information
+for support purposes.
+"""
+
+from salt.scripts import salt_support
+
+if __name__ == "__main__":
+ salt_support()
diff --git a/setup.py b/setup.py
index 08c84344ea..39a66fefba 100755
--- a/setup.py
+++ b/setup.py
@@ -1253,6 +1253,7 @@ class SaltDistribution(distutils.dist.Distribution):
"scripts/salt-master",
"scripts/salt-minion",
"scripts/salt-proxy",
+ "scripts/salt-support",
"scripts/salt-ssh",
"scripts/salt-syndic",
"scripts/salt-unity",
@@ -1299,6 +1300,7 @@ class SaltDistribution(distutils.dist.Distribution):
"salt-key = salt.scripts:salt_key",
"salt-master = salt.scripts:salt_master",
"salt-minion = salt.scripts:salt_minion",
+ "salt-support = salt.scripts:salt_support",
"salt-ssh = salt.scripts:salt_ssh",
"salt-syndic = salt.scripts:salt_syndic",
"salt-unity = salt.scripts:salt_unity",
diff --git a/tests/unit/cli/test_support.py b/tests/unit/cli/test_support.py
new file mode 100644
index 0000000000..dc0e99bb3d
--- /dev/null
+++ b/tests/unit/cli/test_support.py
@@ -0,0 +1,553 @@
+"""
+ :codeauthor: Bo Maryniuk <bo@suse.de>
+"""
+
+
+import os
+
+import jinja2
+import salt.cli.support.collector
+import salt.exceptions
+import salt.utils.files
+import yaml
+from salt.cli.support.collector import SaltSupport, SupportDataCollector
+from salt.cli.support.console import IndentOutput
+from salt.utils.color import get_colors
+from salt.utils.stringutils import to_bytes
+from tests.support.mock import NO_MOCK, NO_MOCK_REASON, MagicMock, patch
+from tests.support.unit import TestCase, skipIf
+
+try:
+ import pytest
+except ImportError:
+ pytest = None
+
+
+@skipIf(not bool(pytest), "Pytest needs to be installed")
+@skipIf(NO_MOCK, NO_MOCK_REASON)
+class SaltSupportIndentOutputTestCase(TestCase):
+ """
+ Unit Tests for the salt-support indent output.
+ """
+
+ def setUp(self):
+ """
+ Setup test
+ :return:
+ """
+
+ self.message = "Stubborn processes on dumb terminal"
+ self.device = MagicMock()
+ self.iout = IndentOutput(device=self.device)
+ self.colors = get_colors()
+
+ def tearDown(self):
+ """
+ Remove instances after test run
+ :return:
+ """
+ del self.message
+ del self.device
+ del self.iout
+ del self.colors
+
+ def test_standard_output(self):
+ """
+ Test console standard output.
+ """
+ self.iout.put(self.message)
+ assert self.device.write.called
+ assert self.device.write.call_count == 5
+ for idx, data in enumerate(
+ ["", str(self.colors["CYAN"]), self.message, str(self.colors["ENDC"]), "\n"]
+ ):
+ assert self.device.write.call_args_list[idx][0][0] == data
+
+ def test_indent_output(self):
+ """
+ Test indent distance.
+ :return:
+ """
+ self.iout.put(self.message, indent=10)
+ for idx, data in enumerate(
+ [
+ " " * 10,
+ str(self.colors["CYAN"]),
+ self.message,
+ str(self.colors["ENDC"]),
+ "\n",
+ ]
+ ):
+ assert self.device.write.call_args_list[idx][0][0] == data
+
+ def test_color_config(self):
+ """
+ Test color config changes on each ident.
+ :return:
+ """
+
+ conf = {0: "MAGENTA", 2: "RED", 4: "WHITE", 6: "YELLOW"}
+ self.iout = IndentOutput(conf=conf, device=self.device)
+ for indent in sorted(list(conf)):
+ self.iout.put(self.message, indent=indent)
+
+ step = 1
+ for ident_key in sorted(list(conf)):
+ assert str(self.device.write.call_args_list[step][0][0]) == str(
+ self.colors[conf[ident_key]]
+ )
+ step += 5
+
+
+@skipIf(not bool(pytest), "Pytest needs to be installed")
+@skipIf(NO_MOCK, NO_MOCK_REASON)
+class SaltSupportCollectorTestCase(TestCase):
+ """
+ Collector tests.
+ """
+
+ def setUp(self):
+ """
+ Setup the test case
+ :return:
+ """
+ self.archive_path = "/highway/to/hell"
+ self.output_device = MagicMock()
+ self.collector = SupportDataCollector(self.archive_path, self.output_device)
+
+ def tearDown(self):
+ """
+ Tear down the test case elements
+ :return:
+ """
+ del self.collector
+ del self.archive_path
+ del self.output_device
+
+ @patch("salt.cli.support.collector.tarfile.TarFile", MagicMock())
+ def test_archive_open(self):
+ """
+ Test archive is opened.
+
+ :return:
+ """
+ self.collector.open()
+ assert self.collector.archive_path == self.archive_path
+ with pytest.raises(salt.exceptions.SaltException) as err:
+ self.collector.open()
+ assert "Archive already opened" in str(err)
+
+ @patch("salt.cli.support.collector.tarfile.TarFile", MagicMock())
+ def test_archive_close(self):
+ """
+ Test archive is opened.
+
+ :return:
+ """
+ self.collector.open()
+ self.collector._flush_content = lambda: None
+ self.collector.close()
+ assert self.collector.archive_path == self.archive_path
+ with pytest.raises(salt.exceptions.SaltException) as err:
+ self.collector.close()
+ assert "Archive already closed" in str(err)
+
+ def test_archive_addwrite(self):
+ """
+ Test add to the archive a section and write to it.
+
+ :return:
+ """
+ archive = MagicMock()
+ with patch("salt.cli.support.collector.tarfile.TarFile", archive):
+ self.collector.open()
+ self.collector.add("foo")
+ self.collector.write(title="title", data="data", output="null")
+ self.collector._flush_content()
+
+ assert archive.bz2open().addfile.call_args[1]["fileobj"].read() == to_bytes(
+ "title\n-----\n\nraw-content: data\n\n\n\n"
+ )
+
+ @patch("salt.utils.files.fopen", MagicMock(return_value="path=/dev/null"))
+ def test_archive_addlink(self):
+ """
+ Test add to the archive a section and link an external file or directory to it.
+
+ :return:
+ """
+ archive = MagicMock()
+ with patch("salt.cli.support.collector.tarfile.TarFile", archive):
+ self.collector.open()
+ self.collector.add("foo")
+ self.collector.link(title="Backup Path", path="/path/to/backup.config")
+ self.collector._flush_content()
+
+ assert archive.bz2open().addfile.call_count == 1
+ assert archive.bz2open().addfile.call_args[1]["fileobj"].read() == to_bytes(
+ "Backup Path\n-----------\n\npath=/dev/null\n\n\n"
+ )
+
+ @patch("salt.utils.files.fopen", MagicMock(return_value="path=/dev/null"))
+ def test_archive_discard_section(self):
+ """
+ Test discard a section from the archive.
+
+ :return:
+ """
+ archive = MagicMock()
+ with patch("salt.cli.support.collector.tarfile.TarFile", archive):
+ self.collector.open()
+ self.collector.add("solar-interference")
+ self.collector.link(
+ title="Thermal anomaly", path="/path/to/another/great.config"
+ )
+ self.collector.add("foo")
+ self.collector.link(title="Backup Path", path="/path/to/backup.config")
+ self.collector._flush_content()
+ assert archive.bz2open().addfile.call_count == 2
+ assert archive.bz2open().addfile.mock_calls[0][2][
+ "fileobj"
+ ].read() == to_bytes(
+ "Thermal anomaly\n---------------\n\npath=/dev/null\n\n\n"
+ )
+ self.collector.close()
+
+ archive = MagicMock()
+ with patch("salt.cli.support.collector.tarfile.TarFile", archive):
+ self.collector.open()
+ self.collector.add("solar-interference")
+ self.collector.link(
+ title="Thermal anomaly", path="/path/to/another/great.config"
+ )
+ self.collector.discard_current()
+ self.collector.add("foo")
+ self.collector.link(title="Backup Path", path="/path/to/backup.config")
+ self.collector._flush_content()
+ assert archive.bz2open().addfile.call_count == 2
+ assert archive.bz2open().addfile.mock_calls[0][2][
+ "fileobj"
+ ].read() == to_bytes("Backup Path\n-----------\n\npath=/dev/null\n\n\n")
+ self.collector.close()
+
+
+@skipIf(not bool(pytest), "Pytest needs to be installed")
+@skipIf(NO_MOCK, NO_MOCK_REASON)
+class SaltSupportRunnerTestCase(TestCase):
+ """
+ Test runner class.
+ """
+
+ def setUp(self):
+ """
+ Set up test suite.
+ :return:
+ """
+ self.archive_path = "/dev/null"
+ self.output_device = MagicMock()
+ self.runner = SaltSupport()
+ self.runner.collector = SupportDataCollector(
+ self.archive_path, self.output_device
+ )
+
+ def tearDown(self):
+ """
+ Tear down.
+
+ :return:
+ """
+ del self.archive_path
+ del self.output_device
+ del self.runner
+
+ def test_function_config(self):
+ """
+ Test function config formation.
+
+ :return:
+ """
+ self.runner.config = {}
+ msg = "Electromagnetic energy loss"
+ assert self.runner._setup_fun_config({"description": msg}) == {
+ "print_metadata": False,
+ "file_client": "local",
+ "fun": "",
+ "kwarg": {},
+ "description": msg,
+ "cache_jobs": False,
+ "arg": [],
+ }
+
+ def test_local_caller(self):
+ """
+ Test local caller.
+
+ :return:
+ """
+ msg = "Because of network lag due to too many people playing deathmatch"
+ caller = MagicMock()
+ caller().call = MagicMock(return_value=msg)
+
+ self.runner._get_caller = caller
+ self.runner.out = MagicMock()
+ assert self.runner._local_call({}) == msg
+
+ caller().call = MagicMock(side_effect=SystemExit)
+ assert self.runner._local_call({}) == "Data is not available at this moment"
+
+ err_msg = "The UPS doesn't have a battery backup."
+ caller().call = MagicMock(side_effect=Exception(err_msg))
+ assert (
+ self.runner._local_call({})
+ == "Unhandled exception occurred: The UPS doesn't have a battery backup."
+ )
+
+ def test_local_runner(self):
+ """
+ Test local runner.
+
+ :return:
+ """
+ msg = "Big to little endian conversion error"
+ runner = MagicMock()
+ runner().run = MagicMock(return_value=msg)
+
+ self.runner._get_runner = runner
+ self.runner.out = MagicMock()
+ assert self.runner._local_run({}) == msg
+
+ runner().run = MagicMock(side_effect=SystemExit)
+ assert self.runner._local_run({}) == "Runner is not available at this moment"
+
+ err_msg = "Trojan horse ran out of hay"
+ runner().run = MagicMock(side_effect=Exception(err_msg))
+ assert (
+ self.runner._local_run({})
+ == "Unhandled exception occurred: Trojan horse ran out of hay"
+ )
+
+ @patch("salt.cli.support.intfunc", MagicMock(spec=[]))
+ def test_internal_function_call_stub(self):
+ """
+ Test missing internal function call is handled accordingly.
+
+ :return:
+ """
+ self.runner.out = MagicMock()
+ out = self.runner._internal_function_call(
+ {"fun": "everythingisawesome", "arg": [], "kwargs": {}}
+ )
+ assert out == "Function everythingisawesome is not available"
+
+ def test_internal_function_call(self):
+ """
+ Test missing internal function call is handled accordingly.
+
+ :return:
+ """
+ msg = "Internet outage"
+ intfunc = MagicMock()
+ intfunc.everythingisawesome = MagicMock(return_value=msg)
+ self.runner.out = MagicMock()
+ with patch("salt.cli.support.intfunc", intfunc):
+ out = self.runner._internal_function_call(
+ {"fun": "everythingisawesome", "arg": [], "kwargs": {}}
+ )
+ assert out == msg
+
+ def test_get_action(self):
+ """
+ Test action meta gets parsed.
+
+ :return:
+ """
+ action_meta = {
+ "run:jobs.list_jobs_filter": {"info": "List jobs filter", "args": [1]}
+ }
+ assert self.runner._get_action(action_meta) == (
+ "List jobs filter",
+ None,
+ {"fun": "run:jobs.list_jobs_filter", "kwargs": {}, "arg": [1]},
+ )
+ action_meta = {
+ "user.info": {"info": 'Information about "usbmux"', "args": ["usbmux"]}
+ }
+ assert self.runner._get_action(action_meta) == (
+ 'Information about "usbmux"',
+ None,
+ {"fun": "user.info", "kwargs": {}, "arg": ["usbmux"]},
+ )
+
+ def test_extract_return(self):
+ """
+ Test extract return from the output.
+
+ :return:
+ """
+ out = {"key": "value"}
+ assert self.runner._extract_return(out) == out
+ assert self.runner._extract_return({"return": out}) == out
+
+ def test_get_action_type(self):
+ """
+ Test action meta determines action type.
+
+ :return:
+ """
+ action_meta = {
+ "run:jobs.list_jobs_filter": {"info": "List jobs filter", "args": [1]}
+ }
+ assert self.runner._get_action_type(action_meta) == "run"
+
+ action_meta = {
+ "user.info": {"info": 'Information about "usbmux"', "args": ["usbmux"]}
+ }
+ assert self.runner._get_action_type(action_meta) == "call"
+
+ @patch("os.path.exists", MagicMock(return_value=True))
+ def test_cleanup(self):
+ """
+ Test cleanup routine.
+
+ :return:
+ """
+ arch = "/tmp/killme.zip"
+ unlink = MagicMock()
+ with patch("os.unlink", unlink):
+ self.runner.config = {"support_archive": arch}
+ self.runner.out = MagicMock()
+ self.runner._cleanup()
+
+ assert (
+ self.runner.out.warning.call_args[0][0]
+ == "Terminated earlier, cleaning up"
+ )
+ unlink.assert_called_once_with(arch)
+
+ @patch("os.path.exists", MagicMock(return_value=True))
+ def test_check_existing_archive(self):
+ """
+ Test check existing archive.
+
+ :return:
+ """
+ arch = "/tmp/endothermal-recalibration.zip"
+ unlink = MagicMock()
+ with patch("os.unlink", unlink), patch(
+ "os.path.exists", MagicMock(return_value=False)
+ ):
+ self.runner.config = {
+ "support_archive": "",
+ "support_archive_force_overwrite": True,
+ }
+ self.runner.out = MagicMock()
+ assert self.runner._check_existing_archive()
+ assert self.runner.out.warning.call_count == 0
+
+ with patch("os.unlink", unlink):
+ self.runner.config = {
+ "support_archive": arch,
+ "support_archive_force_overwrite": False,
+ }
+ self.runner.out = MagicMock()
+ assert not self.runner._check_existing_archive()
+ assert self.runner.out.warning.call_args[0][
+ 0
+ ] == "File {} already exists.".format(arch)
+
+ with patch("os.unlink", unlink):
+ self.runner.config = {
+ "support_archive": arch,
+ "support_archive_force_overwrite": True,
+ }
+ self.runner.out = MagicMock()
+ assert self.runner._check_existing_archive()
+ assert self.runner.out.warning.call_args[0][
+ 0
+ ] == "Overwriting existing archive: {}".format(arch)
+
+
+@skipIf(not bool(pytest), "Pytest needs to be installed")
+@skipIf(NO_MOCK, NO_MOCK_REASON)
+class ProfileIntegrityTestCase(TestCase):
+ """
+ Default profile integrity
+ """
+
+ def setUp(self):
+ """
+ Set up test suite.
+
+ :return:
+ """
+ self.profiles = {}
+ profiles = os.path.join(
+ os.path.dirname(salt.cli.support.collector.__file__), "profiles"
+ )
+ for profile in os.listdir(profiles):
+ self.profiles[profile.split(".")[0]] = os.path.join(profiles, profile)
+
+ def tearDown(self):
+ """
+ Tear down test suite.
+
+ :return:
+ """
+ del self.profiles
+
+ def _render_template_to_yaml(self, name, *args, **kwargs):
+ """
+ Get template referene for rendering.
+ :return:
+ """
+ with salt.utils.files.fopen(self.profiles[name]) as t_fh:
+ template = t_fh.read()
+ return yaml.load(
+ jinja2.Environment().from_string(template).render(*args, **kwargs)
+ )
+
+ def test_non_template_profiles_parseable(self):
+ """
+ Test shipped default profile is YAML parse-able.
+
+ :return:
+ """
+ for t_name in ["default", "jobs-active", "jobs-last", "network", "postgres"]:
+ with salt.utils.files.fopen(self.profiles[t_name]) as ref:
+ try:
+ yaml.load(ref)
+ parsed = True
+ except Exception:
+ parsed = False
+ assert parsed
+
+ def test_users_template_profile(self):
+ """
+ Test users template profile.
+
+ :return:
+ """
+ users_data = self._render_template_to_yaml(
+ "users", salt=MagicMock(return_value=["pokemon"])
+ )
+ assert len(users_data["all-users"]) == 5
+ for user_data in users_data["all-users"]:
+ for tgt in ["user.list_groups", "shadow.info", "cron.raw_cron"]:
+ if tgt in user_data:
+ assert user_data[tgt]["args"] == ["pokemon"]
+
+ def test_jobs_trace_template_profile(self):
+ """
+ Test jobs-trace template profile.
+
+ :return:
+ """
+ jobs_trace = self._render_template_to_yaml(
+ "jobs-trace", runners=MagicMock(return_value=["0000"])
+ )
+ assert len(jobs_trace["jobs-details"]) == 1
+ assert (
+ jobs_trace["jobs-details"][0]["run:jobs.list_job"]["info"]
+ == "Details on JID 0000"
+ )
+ assert jobs_trace["jobs-details"][0]["run:jobs.list_job"]["args"] == [0]
--
2.29.2