diff --git a/doc/ref/states/requisites.rst b/doc/ref/states/requisites.rst index c879e85f910e..9916e421af13 100644 --- a/doc/ref/states/requisites.rst +++ b/doc/ref/states/requisites.rst @@ -232,6 +232,17 @@ if any of the watched states changes. In the example above, ``cmd.run`` will run only if there are changes in the ``file.managed`` state. +.. note:: + + When multiple state declarations share the same ID, ``onchanges`` still + resolves by the referenced state type and name. If a requisite resolves + back to the same state (self-reference), Salt ignores it to avoid + recursive requisites and logs a warning. Use distinct IDs if you need to + make ordering explicit or if name-based matching is ambiguous. If you + prefer ID-based matching, use the ``id`` requisite key explicitly to + avoid ambiguity between IDs and names. Running ``state.show_lowstate`` + can help verify how a requisite resolves during compilation. + An easy mistake to make is using ``onchanges_in`` when ``onchanges`` is the correct choice, as seen in this next example. diff --git a/salt/modules/cmdmod.py b/salt/modules/cmdmod.py index 2c51c45e0b6e..0efc94762d7b 100644 --- a/salt/modules/cmdmod.py +++ b/salt/modules/cmdmod.py @@ -57,7 +57,7 @@ import salt.platform.win from salt.utils.win_functions import escape_argument as _cmd_quote - from salt.utils.win_runas import runas as win_runas + import salt.utils.win_runas as win_runas HAS_WIN_RUNAS = True else: @@ -788,7 +788,7 @@ def _run( if change_windows_codepage: salt.utils.win_chcp.set_codepage_id(windows_codepage) try: - proc = win_runas(cmd, runas, password, **new_kwargs) + proc = win_runas.runas(cmd, runas, password, **new_kwargs) except (OSError, pywintypes.error) as exc: msg = "Unable to run command '{}' with the context '{}', reason: {}".format( cmd if output_loglevel is not None else "REDACTED", @@ -3023,16 +3023,21 @@ def _cleanup_tempfile(path): win_cwd = False if salt.utils.platform.is_windows() and runas: - # Let's make sure the user exists first - if not __salt__["user.info"](runas): + # Resolve the user for domain/UPN support before creating the temp dir + try: + resolved_runas = win_runas.resolve_logon_credentials(runas) + except CommandExecutionError as exc: msg = f"Invalid user: {runas}" - raise CommandExecutionError(msg) + raise CommandExecutionError(msg) from exc + if cwd is None: # Create a temp working directory cwd = tempfile.mkdtemp(dir=__opts__["cachedir"]) win_cwd = True salt.utils.win_dacl.set_permissions( - obj_name=cwd, principal=runas, permissions="full_control" + obj_name=cwd, + principal=resolved_runas.get("sam_name") or runas, + permissions="full_control", ) (_, ext) = os.path.splitext(salt.utils.url.split_env(source)[0]) diff --git a/salt/modules/cp.py b/salt/modules/cp.py index 2d878a308b67..d20ab08bf6a8 100644 --- a/salt/modules/cp.py +++ b/salt/modules/cp.py @@ -230,6 +230,39 @@ def _render(contents): return (path, dest) +def _normalize_template_context_overrides(context): + """ + Normalize a user-supplied template context without adding missing keys. + """ + normalized = dict(context) + for key in ("salt", "opts", "grains", "pillar"): + if key not in normalized: + continue + value = normalized.get(key) + if isinstance(value, NamedLoaderContext): + value = value.value() + if value is None: + value = {} + normalized[key] = value + return normalized + + +def _prepare_template_kwargs(kwargs): + """ + Ensure template rendering kwargs include the standard context keys. + """ + prepared = {} if not kwargs else dict(kwargs) + prepared.setdefault("salt", __salt__) + prepared.setdefault("pillar", __pillar__) + prepared.setdefault("grains", __grains__) + prepared.setdefault("opts", __opts__) + prepared = salt.utils.templates.normalize_render_context(prepared) + context = prepared.get("context") + if isinstance(context, dict): + prepared["context"] = _normalize_template_context_overrides(context) + return prepared + + def get_file( path, dest, saltenv=None, makedirs=False, template=None, gzip=None, **kwargs ): @@ -329,14 +362,7 @@ def get_template(path, dest, template="jinja", saltenv=None, makedirs=False, **k if not saltenv: saltenv = __opts__["saltenv"] or "base" - if "salt" not in kwargs: - kwargs["salt"] = __salt__ - if "pillar" not in kwargs: - kwargs["pillar"] = __pillar__ - if "grains" not in kwargs: - kwargs["grains"] = __grains__ - if "opts" not in kwargs: - kwargs["opts"] = __opts__ + kwargs = _prepare_template_kwargs(kwargs) with _client() as client: return client.get_template(path, dest, template, makedirs, saltenv, **kwargs) diff --git a/salt/modules/pip.py b/salt/modules/pip.py index 208a9be52560..d83afc034bcc 100644 --- a/salt/modules/pip.py +++ b/salt/modules/pip.py @@ -1616,6 +1616,7 @@ def list_all_versions( include_alpha=False, include_beta=False, include_rc=False, + pre_releases=False, user=None, cwd=None, index_url=None, @@ -1644,6 +1645,12 @@ def list_all_versions( include_rc Include release candidates versions in the list + pre_releases + Include all pre-release versions (alpha, beta, and release candidates) + in the list. When set to True, this overrides individual + ``include_alpha``, ``include_beta``, and ``include_rc`` settings. + .. versionadded:: 3007.2 + user The user under which to run pip @@ -1667,6 +1674,12 @@ def list_all_versions( cwd = _pip_bin_env(cwd, bin_env) cmd = _get_pip_bin(bin_env) + # If pre_releases is True, include all pre-release types + if pre_releases: + include_alpha = True + include_beta = True + include_rc = True + # Is the `pip index` command available pip_version = version(bin_env=bin_env, cwd=cwd, user=user) if salt.utils.versions.compare(ver1=pip_version, oper=">=", ver2="21.2"): diff --git a/salt/renderers/jinja.py b/salt/renderers/jinja.py index f238bd281dea..b3c1726b181b 100644 --- a/salt/renderers/jinja.py +++ b/salt/renderers/jinja.py @@ -14,6 +14,20 @@ log = logging.getLogger(__name__) +def _resolve_salt_context(): + """ + Resolve __salt__ to a dict for template rendering when loader context is missing. + """ + funcs = __salt__ + if isinstance(__salt__, NamedLoaderContext): + resolved = __salt__.value() + if isinstance(resolved, dict): + funcs = resolved + elif resolved is None: + funcs = {} + return funcs + + def _split_module_dicts(): """ Create a copy of __salt__ dictionary with module.function and module[function] @@ -24,9 +38,7 @@ def _split_module_dicts(): {{ salt.cmd.run('uptime') }} """ - funcs = __salt__ - if isinstance(__salt__, NamedLoaderContext) and isinstance(__salt__.value(), dict): - funcs = __salt__.value() + funcs = _resolve_salt_context() if not isinstance(funcs, dict): return funcs mod_dict = dict(funcs) diff --git a/salt/state.py b/salt/state.py index b3432215eb5f..7338f9368bd1 100644 --- a/salt/state.py +++ b/salt/state.py @@ -2686,6 +2686,7 @@ def _check_requisites(self, low: LowChunk, running: dict[str, dict[str, Any]]): """ reqs = {} pending = False + prereq_run_dict = self.pre for req_type, chunk in self.dependency_dag.get_dependencies(low): reqs.setdefault(req_type, []).append(chunk) fun_stats = set() @@ -2701,6 +2702,14 @@ def _check_requisites(self, low: LowChunk, running: dict[str, dict[str, Any]]): for chunk in chunks: tag = _gen_tag(chunk) run_dict_chunk = run_dict.get(tag) + if ( + run_dict_chunk is None + and r_type_base == RequisiteType.ONCHANGES.value + and chunk.get("__prereq__") + ): + # If the requisite only ran in prereq (test) mode, use that + # result for onchanges to avoid recursive unmet requisites. + run_dict_chunk = prereq_run_dict.get(tag) if run_dict_chunk: filtered_run_dict[tag] = run_dict_chunk run_dict = filtered_run_dict diff --git a/salt/states/pip_state.py b/salt/states/pip_state.py index 04b5326eb951..955576334ace 100644 --- a/salt/states/pip_state.py +++ b/salt/states/pip_state.py @@ -238,6 +238,7 @@ def _check_if_installed( index_url, extra_index_url, pip_list=False, + pre_releases=False, **kwargs, ): """ @@ -281,23 +282,32 @@ def _check_if_installed( return ret if force_reinstall is False and upgrade: # Check desired version (if any) against currently-installed - include_alpha = False - include_beta = False - include_rc = False - if any(version_spec): - for spec in version_spec: - if "a" in spec[1]: - include_alpha = True - if "b" in spec[1]: - include_beta = True - if "rc" in spec[1]: - include_rc = True + # If pre_releases is True, include all pre-release types + if pre_releases: + include_alpha = True + include_beta = True + include_rc = True + else: + include_alpha = False + include_beta = False + include_rc = False + # Only check version spec for pre-release indicators if pre_releases is False + if any(version_spec): + for spec in version_spec: + if "a" in spec[1]: + include_alpha = True + if "b" in spec[1]: + include_beta = True + if "rc" in spec[1]: + include_rc = True + # Use pre_releases parameter for cleaner API when available available_versions = __salt__["pip.list_all_versions"]( prefix, bin_env=bin_env, - include_alpha=include_alpha, - include_beta=include_beta, - include_rc=include_rc, + pre_releases=pre_releases, + include_alpha=include_alpha if not pre_releases else True, + include_beta=include_beta if not pre_releases else True, + include_rc=include_rc if not pre_releases else True, user=user, cwd=cwd, index_url=index_url, @@ -320,7 +330,19 @@ def _check_if_installed( "requirements".format(prefix) ) return ret - if _pep440_version_cmp(pip_list[prefix], desired_version) == 0: + # Compare installed version with desired version + # When pre_releases=True, desired_version may include pre-releases + version_cmp = _pep440_version_cmp(pip_list[prefix], desired_version) + if version_cmp == 0: + # Installed version matches desired version exactly + ret["result"] = True + ret["comment"] = "Python package {} was already installed".format( + state_pkg_name + ) + return ret + elif version_cmp == 1: + # Installed version is newer than desired version + # This can happen with pre-releases - keep the newer installed version ret["result"] = True ret["comment"] = "Python package {} was already installed".format( state_pkg_name @@ -554,7 +576,9 @@ def installed( Current working directory to run pip from pre_releases - Include pre-releases in the available versions + Include pre-releases in the available versions. When used with + ``upgrade=True``, this allows upgrading to pre-release versions even + if they are not explicitly specified in the version requirements. cert Provide a path to an alternate CA bundle @@ -889,6 +913,7 @@ def prepro(pkg): index_url, extra_index_url, pip_list, + pre_releases=pre_releases, **kwargs, ) # If _check_if_installed result is None, something went wrong with diff --git a/salt/utils/requisite.py b/salt/utils/requisite.py index e4f399b6aac7..80e0d42275da 100644 --- a/salt/utils/requisite.py +++ b/salt/utils/requisite.py @@ -237,6 +237,26 @@ def _chunk_str(self, chunk: LowChunk) -> str: node_dict["NAME"] = chunk["name"] return str(node_dict) + def _filter_self_reqs( + self, + node_tag: str, + req_tags: Iterable[str], + req_type: RequisiteType, + low: LowChunk, + req_key: str, + req_val: str, + ) -> set[str]: + filtered = {tag for tag in req_tags if tag != node_tag} + if len(filtered) != len(req_tags): + log.warning( + "Ignoring %s requisite on %s that points to itself (%s: %s)", + req_type.value, + self._chunk_str(low), + req_key, + req_val, + ) + return filtered + def add_chunk(self, low: LowChunk, allow_aggregate: bool) -> None: node_id = _gen_tag(low) self.dag.add_node( @@ -269,12 +289,22 @@ def add_dependency( for sls, req_tags in self.sls_to_nodes.items(): if fnmatch.fnmatch(sls, req_val): found = True - self._add_reqs(node_tag, has_prereq_node, req_type, req_tags) + req_tags = self._filter_self_reqs( + node_tag, req_tags, req_type, low, req_key, req_val + ) + if req_tags: + self._add_reqs( + node_tag, has_prereq_node, req_type, req_tags + ) else: node_tag = _gen_tag(low) if req_tags := self.sls_to_nodes.get(req_val, []): found = True - self._add_reqs(node_tag, has_prereq_node, req_type, req_tags) + req_tags = self._filter_self_reqs( + node_tag, req_tags, req_type, low, req_key, req_val + ) + if req_tags: + self._add_reqs(node_tag, has_prereq_node, req_type, req_tags) elif self._is_fnmatch_pattern(req_val): # This iterates over every chunk to check # if any match instead of doing a look up since @@ -283,11 +313,21 @@ def add_dependency( for (state_type, name_or_id), req_tags in self.nodes_lookup_map.items(): if req_key == state_type and (fnmatch.fnmatch(name_or_id, req_val)): found = True - self._add_reqs(node_tag, has_prereq_node, req_type, req_tags) + req_tags = self._filter_self_reqs( + node_tag, req_tags, req_type, low, req_key, req_val + ) + if req_tags: + self._add_reqs( + node_tag, has_prereq_node, req_type, req_tags + ) elif req_tags := self.nodes_lookup_map.get((req_key, req_val)): found = True node_tag = _gen_tag(low) - self._add_reqs(node_tag, has_prereq_node, req_type, req_tags) + req_tags = self._filter_self_reqs( + node_tag, req_tags, req_type, low, req_key, req_val + ) + if req_tags: + self._add_reqs(node_tag, has_prereq_node, req_type, req_tags) return found def add_requisites(self, low: LowChunk, disabled_reqs: Sequence[str]) -> str | None: diff --git a/salt/utils/templates.py b/salt/utils/templates.py index 4ef96a2378dd..18da7f5afd7e 100644 --- a/salt/utils/templates.py +++ b/salt/utils/templates.py @@ -68,6 +68,14 @@ def __getattr__(self, name): def __contains__(self, name): return name in self.wrapped + def get(self, name, default=None): + """ + Provide dict-like access for templates which call salt.get(...). + """ + if self.wrapped is None: + return default + return self.wrapped.get(name, default) + class AliasedModule: """ @@ -86,6 +94,27 @@ def __getattr__(self, name): return getattr(self.wrapped, name) +def normalize_render_context(context, defaults=True): + """ + Ensure common template context keys are present and dict-like. + """ + if context is None: + return {} + if not isinstance(context, dict): + return context + normalized = dict(context) + for key in ("salt", "opts", "grains", "pillar"): + if not defaults and key not in normalized: + continue + value = normalized.get(key) + if isinstance(value, NamedLoaderContext): + value = value.value() + if value is None: + value = {} + normalized[key] = value + return normalized + + def generate_sls_context(tmplpath, sls): """ Generate SLS/Template Context Items @@ -169,14 +198,13 @@ def render_tmpl( if context is None: context = {} - # Alias cmd.run to cmd.shell to make python_shell=True the default for - # templated calls - if "salt" in kws: - kws["salt"] = AliasedLoader(kws["salt"]) - # We want explicit context to overwrite the **kws kws.update(context) - context = kws + context = normalize_render_context(kws) + # Alias cmd.run to cmd.shell to make python_shell=True the default for + # templated calls + if "salt" in context and not isinstance(context["salt"], AliasedLoader): + context["salt"] = AliasedLoader(context["salt"]) assert "opts" in context assert "saltenv" in context @@ -340,6 +368,7 @@ def render_jinja_tmpl(tmplstr, context, tmplpath=None): :returns str: The string rendered by the template. """ + context = normalize_render_context(context) opts = context["opts"] saltenv = context["saltenv"] loader = None @@ -464,6 +493,7 @@ def opt_jinja_env_helper(opts, optname): ) decoded_context[key] = salt.utils.data.decode(value) + decoded_context = normalize_render_context(decoded_context) jinja_env.globals.update(decoded_context) try: template = jinja_env.from_string(tmplstr) diff --git a/salt/utils/win_functions.py b/salt/utils/win_functions.py index eb13f357b27d..53b938631db1 100644 --- a/salt/utils/win_functions.py +++ b/salt/utils/win_functions.py @@ -210,6 +210,75 @@ def get_sam_name(username): return "\\".join([domain, username]) +def _candidate_account_names(username): + """ + Build candidate account names to resolve, including UPN and DOMAIN/user forms. + """ + if username is None: + return [] + + candidate_names = [str(username)] + name = candidate_names[0] + + if "/" in name and "\\" not in name: + candidate_names.append(name.replace("/", "\\")) + + if ( + "@" in name + and hasattr(win32security, "TranslateName") + and hasattr(win32security, "NameUserPrincipal") + and hasattr(win32security, "NameSamCompatible") + ): + try: + candidate_names.append( + win32security.TranslateName( + name, + win32security.NameUserPrincipal, + win32security.NameSamCompatible, + ) + ) + except pywintypes.error: + pass + + # Preserve order but remove duplicates + return list(dict.fromkeys(candidate_names)) + + +def resolve_username(username): + """ + Resolve a username into account details usable for validation and logon. + + Returns a dict with account_name, domain, sid, sam_name, and lookup_name. + """ + if not username: + raise CommandExecutionError("Username is required") + + last_error = None + for candidate in _candidate_account_names(username): + try: + sid, domain, _ = win32security.LookupAccountName(None, candidate) + account_name, lookup_domain, _ = win32security.LookupAccountSid(None, sid) + resolved_domain = lookup_domain or domain + sam_name = ( + f"{resolved_domain}\\{account_name}" + if resolved_domain + else account_name + ) + return { + "account_name": account_name, + "domain": resolved_domain, + "sid": sid, + "sam_name": sam_name, + "lookup_name": candidate, + } + except pywintypes.error as exc: + last_error = exc + continue + + detail = f": {last_error}" if last_error else "" + raise CommandExecutionError(f"User {username} not found{detail}") + + def enable_ctrl_logoff_handler(): """ Set the control handler on the console diff --git a/salt/utils/win_runas.py b/salt/utils/win_runas.py index ea426e8a51bf..e9343232f047 100644 --- a/salt/utils/win_runas.py +++ b/salt/utils/win_runas.py @@ -32,6 +32,7 @@ import winerror import salt.platform.win + import salt.utils.win_functions HAS_WIN32 = True except ImportError: @@ -78,9 +79,51 @@ def split_username(username): # Domain users with Down-Level Logon Name: DOMAIN\user if "\\" in user_name: domain, user_name = user_name.split("\\", maxsplit=1) + elif "/" in user_name: + domain, user_name = user_name.split("/", maxsplit=1) return str(user_name), str(domain) +def resolve_logon_credentials(username): + """ + Resolve a username into values suitable for Windows logon APIs. + """ + if not isinstance(username, str): + username = str(username) + + if not HAS_WIN32: + raise CommandExecutionError("win_runas requires pywin32 to resolve users") + + resolved = salt.utils.win_functions.resolve_username(username) + sam_name = resolved.get("sam_name") or username + logon_name, logon_domain = split_username(sam_name) + + return { + "input_name": username, + "account_name": resolved["account_name"], + "domain_name": resolved["domain"], + "sid": resolved["sid"], + "sam_name": sam_name, + "lookup_name": resolved["lookup_name"], + "logon_name": logon_name, + "logon_domain": logon_domain, + } + + +def validate_username(username, raise_on_error=False): + """ + Validate that a username can be resolved on the system. + """ + try: + resolve_logon_credentials(username) + except CommandExecutionError as exc: + if raise_on_error: + raise + log.error("Invalid user '%s': %s", username, exc) + return False + return True + + def create_env(user_token, inherit, timeout=1): """ CreateEnvironmentBlock might fail when we close a login session and then @@ -169,17 +212,10 @@ def runas(cmd, username, password=None, **kwargs): Commands are run in with the highest level privileges possible for the account provided. """ - # Sometimes this comes in as an int. LookupAccountName can't handle an int - # Let's make it a string if it's anything other than a string - if not isinstance(username, str): - username = str(username) - # Validate the domain and sid exist for the username - try: - _, domain, _ = win32security.LookupAccountName(None, username) - username, _ = split_username(username) - except pywintypes.error as exc: - message = win32api.FormatMessage(exc.winerror).rstrip("\n") - raise CommandExecutionError(message) + resolved = resolve_logon_credentials(username) + username = resolved["account_name"] + logon_name = resolved["logon_name"] + logon_domain = resolved["logon_domain"] # Elevate the token from the current process access = win32security.TOKEN_QUERY | win32security.TOKEN_ADJUST_PRIVILEGES @@ -212,12 +248,12 @@ def runas(cmd, username, password=None, **kwargs): log.debug("No impersonation token, using unprivileged runas") return runas_unpriv(cmd, username, password, **kwargs) - if domain == "NT AUTHORITY": + if logon_domain == "NT AUTHORITY": # Logon as a system level account, SYSTEM, LOCAL SERVICE, or NETWORK # SERVICE. user_token = win32security.LogonUser( - username, - domain, + logon_name, + logon_domain, "", win32con.LOGON32_LOGON_SERVICE, win32con.LOGON32_PROVIDER_DEFAULT, @@ -225,15 +261,15 @@ def runas(cmd, username, password=None, **kwargs): elif password: # Login with a password. user_token = win32security.LogonUser( - username, - domain, + logon_name, + logon_domain, password, win32con.LOGON32_LOGON_INTERACTIVE, win32con.LOGON32_PROVIDER_DEFAULT, ) else: # Login without a password. This always returns an elevated token. - user_token = salt.platform.win.logon_msv1_s4u(username).Token + user_token = salt.platform.win.logon_msv1_s4u(logon_name).Token # Get a linked user token to elevate if needed elevation_type = win32security.GetTokenInformation( @@ -370,17 +406,10 @@ def runas_unpriv(cmd, username, password, **kwargs): """ Runas that works for non-privileged users """ - # Sometimes this comes in as an int. LookupAccountName can't handle an int - # Let's make it a string if it's anything other than a string - if not isinstance(username, str): - username = str(username) - # Validate the domain and sid exist for the username - try: - _, domain, _ = win32security.LookupAccountName(None, username) - username, _ = split_username(username) - except pywintypes.error as exc: - message = win32api.FormatMessage(exc.winerror).rstrip("\n") - raise CommandExecutionError(message) + resolved = resolve_logon_credentials(username) + username = resolved["account_name"] + logon_name = resolved["logon_name"] + logon_domain = resolved["logon_domain"] # Create inheritable copy of the stdin stdin = salt.platform.win.kernel32.GetStdHandle( @@ -445,8 +474,8 @@ def runas_unpriv(cmd, username, password, **kwargs): try: # Run command and return process info structure process_info = salt.platform.win.CreateProcessWithLogonW( - username=username, - domain=domain, + username=logon_name, + domain=logon_domain, password=password, logonflags=salt.platform.win.LOGON_WITH_PROFILE, applicationname=None, diff --git a/tests/pytests/functional/modules/state/requisites/test_onchanges.py b/tests/pytests/functional/modules/state/requisites/test_onchanges.py index c55792e7a3f0..7e1c6f63e039 100644 --- a/tests/pytests/functional/modules/state/requisites/test_onchanges.py +++ b/tests/pytests/functional/modules/state/requisites/test_onchanges.py @@ -1,6 +1,7 @@ import pytest from . import normalize_ret +from salt.state import _gen_tag pytestmark = [ pytest.mark.windows_whitelisted, @@ -129,6 +130,49 @@ def test_onchanges_requisite(state, state_tree): ) +def test_onchanges_same_id_no_recursive_requisite(state, state_tree, tmp_path): + """ + Ensure onchanges works when multiple states share the same ID. + """ + target = tmp_path / "myservice.conf" + target_str = target.as_posix() + sls_contents = f""" + myservice: + file.managed: + - name: {target_str} + - source: salt://myservice.conf + test.succeed_with_changes: + - name: onchanges-run + - onchanges: + - file: {target_str} + """ + with pytest.helpers.temp_file( + "requisite.sls", sls_contents, state_tree + ), pytest.helpers.temp_file("myservice.conf", "config\n", state_tree): + ret = state.sls("requisite") + assert not ret.failed + + file_tag = _gen_tag( + { + "state": "file", + "__id__": "myservice", + "name": target_str, + "fun": "managed", + } + ) + onchanges_tag = _gen_tag( + { + "state": "test", + "__id__": "myservice", + "name": "onchanges-run", + "fun": "succeed_with_changes", + } + ) + assert ret[file_tag].changes + assert ret[onchanges_tag].changes + assert "Recursive requisite found" not in ret[onchanges_tag].comment + + def test_onchanges_requisite_multiple(state, state_tree): """ Tests a simple state using the onchanges requisite diff --git a/tests/pytests/unit/states/test_pip.py b/tests/pytests/unit/states/test_pip.py index 92061b0263b1..932a9e30a6a1 100644 --- a/tests/pytests/unit/states/test_pip.py +++ b/tests/pytests/unit/states/test_pip.py @@ -71,3 +71,96 @@ def test_issue_64169(caplog): # Confirm that the state continued to install the package as expected. # Only check the 'pkgs' parameter of pip.install assert mock_pip_install.call_args.kwargs["pkgs"] == pkg_to_install + + +def test_pre_releases_upgrade_with_prerelease_available(): + """ + Test that pip.installed with upgrade=True and pre_releases=True + will upgrade to pre-release versions when available. + This test verifies the fix for issue #68525. + """ + pkg_name = "test-package" + installed_version = "1.0.0" + available_prerelease = "1.0.1rc1" + + mock_pip_list = MagicMock( + side_effect=[ + {pkg_name: installed_version}, # Pre-cache pip list + {pkg_name: installed_version}, # Check if installed + ] + ) + mock_pip_version = MagicMock(return_value="21.0.0") + mock_pip_list_all_versions = MagicMock( + return_value=["1.0.0", "1.0.1rc1", "1.0.1"] + ) + mock_pip_install = MagicMock(return_value={"retcode": 0, "stdout": ""}) + + with patch.dict( + pip_state.__salt__, + { + "pip.list": mock_pip_list, + "pip.version": mock_pip_version, + "pip.list_all_versions": mock_pip_list_all_versions, + "pip.install": mock_pip_install, + "pip.normalize": pip_module.normalize, + }, + ): + result = pip_state.installed( + name=pkg_name, + upgrade=True, + pre_releases=True, + ) + + # Verify that list_all_versions was called with pre_releases=True + # This ensures pre-releases are included in available versions + call_kwargs = mock_pip_list_all_versions.call_args.kwargs + assert call_kwargs.get("pre_releases") is True + assert call_kwargs.get("include_alpha") is True + assert call_kwargs.get("include_beta") is True + assert call_kwargs.get("include_rc") is True + + # Verify that pip.install was called to upgrade the package + assert mock_pip_install.called + assert mock_pip_install.call_args.kwargs["upgrade"] is True + assert mock_pip_install.call_args.kwargs["pre_releases"] is True + + +def test_pre_releases_upgrade_without_prerelease_flag(): + """ + Test that pip.installed with upgrade=True but pre_releases=False + does not include pre-releases in version checking. + """ + pkg_name = "test-package" + installed_version = "1.0.0" + + mock_pip_list = MagicMock( + side_effect=[ + {pkg_name: installed_version}, # Pre-cache pip list + {pkg_name: installed_version}, # Check if installed + ] + ) + mock_pip_version = MagicMock(return_value="21.0.0") + mock_pip_list_all_versions = MagicMock( + return_value=["1.0.0", "1.0.1rc1", "1.0.1"] + ) + mock_pip_install = MagicMock(return_value={"retcode": 0, "stdout": ""}) + + with patch.dict( + pip_state.__salt__, + { + "pip.list": mock_pip_list, + "pip.version": mock_pip_version, + "pip.list_all_versions": mock_pip_list_all_versions, + "pip.install": mock_pip_install, + "pip.normalize": pip_module.normalize, + }, + ): + result = pip_state.installed( + name=pkg_name, + upgrade=True, + pre_releases=False, + ) + + # Verify that list_all_versions was called with pre_releases=False + call_kwargs = mock_pip_list_all_versions.call_args.kwargs + assert call_kwargs.get("pre_releases") is False \ No newline at end of file diff --git a/tests/pytests/unit/utils/templates/test_wrap_tmpl_func.py b/tests/pytests/unit/utils/templates/test_wrap_tmpl_func.py index c92d4cbff8e4..81f96e34e990 100644 --- a/tests/pytests/unit/utils/templates/test_wrap_tmpl_func.py +++ b/tests/pytests/unit/utils/templates/test_wrap_tmpl_func.py @@ -7,6 +7,8 @@ import pytest +import salt.loader.context +import salt.utils.templates from salt.utils.templates import wrap_tmpl_func, generate_sls_context from tests.support.mock import patch @@ -61,6 +63,30 @@ def test_sls_context_no_call(tmp_path): generate_sls_context.assert_not_called() +def test_jinja_import_with_context_handles_empty_salt(tmp_path): + map_file = tmp_path / "map.jinja" + map_file.write_text( + "{%- set defaults = {'foo': salt.get('missing', 'bar')} -%}" + ) + template = ( + "{%- from 'map.jinja' import defaults with context -%}" + "{{ defaults['foo'] }}" + ) + loader_context = salt.loader.context.LoaderContext() + named_salt = loader_context.named_context("__salt__") + result = salt.utils.templates.JINJA( + template, + from_str=True, + to_str=True, + salt=named_salt, + opts={"cachedir": str(tmp_path)}, + saltenv=None, + tmplpath=str(tmp_path / "file.j2"), + ) + assert result["result"] is True + assert result["data"] == "bar" + + def test_generate_sls_context__top_level(): """generate_sls_context - top_level Use case""" _test_generated_sls_context( diff --git a/tests/pytests/unit/utils/test_win_runas.py b/tests/pytests/unit/utils/test_win_runas.py index 95443691920c..1e4ba79b54ca 100644 --- a/tests/pytests/unit/utils/test_win_runas.py +++ b/tests/pytests/unit/utils/test_win_runas.py @@ -9,6 +9,7 @@ ("test_user", ("test_user", ".")), # Simple system name ("domain\\test_user", ("test_user", "domain")), # Sam name ("domain.com\\test_user", ("test_user", "domain.com")), # Sam name with .com + ("domain/test_user", ("test_user", "domain")), # Domain/user variant ("test_user@domain", ("test_user", "domain")), # UPN Name ("test_user@domain.com", ("test_user", "domain.com")), # UPN Name with .com ("test_user@domain.local", ("test_user", "domain")), # UPN Name with .local