diff --git a/changelog/62508.added b/changelog/62508.added new file mode 100644 index 000000000000..7bf68b441d8f --- /dev/null +++ b/changelog/62508.added @@ -0,0 +1 @@ +Add ifelse Jinja function as found in CFEngine diff --git a/doc/topics/jinja/index.rst b/doc/topics/jinja/index.rst index a2cb65c657fe..6a7068589005 100644 --- a/doc/topics/jinja/index.rst +++ b/doc/topics/jinja/index.rst @@ -2299,6 +2299,41 @@ will be rendered as: unique = ['foo', 'bar'] +Global Functions +================ + +Salt Project extends `builtin global functions`_ with these custom global functions: + +.. jinja_ref:: ifelse + +``ifelse`` +---------- + +Evaluate each pair of arguments up to the last one as a (matcher, value) +tuple, returning ``value`` if matched. If none match, returns the last +argument. + +The ``ifelse`` function is like a multi-level if-else statement. It was +inspired by CFEngine's ``ifelse`` function which in turn was inspired by +Oracle's ``DECODE`` function. It must have an odd number of arguments (from +1 to N). The last argument is the default value, like the ``else`` clause in +standard programming languages. Every pair of arguments before the last one +are evaluated as a pair. If the first one evaluates true then the second one +is returned, as if you had used the first one in a compound match +expression. Boolean values can also be used as the first item in a pair, as it +will be translated to a match that will always match ("*") or never match +("SALT_IFELSE_MATCH_NOTHING") a target system. + +This is essentially another way to express the ``match.filter_by`` functionality +in way that's familiar to CFEngine or Oracle users. Consider using +``match.filter_by`` unless this function fits your workflow. + +.. code-block:: jinja + + {{ ifelse('foo*', 'fooval', 'bar*', 'barval', 'defaultval', minion_id='bar03') }} + +.. _`builtin global functions`: https://jinja.palletsprojects.com/en/2.11.x/templates/#builtin-globals + Jinja in Files ============== diff --git a/salt/modules/match.py b/salt/modules/match.py index 878ab354365e..34c2df813c75 100644 --- a/salt/modules/match.py +++ b/salt/modules/match.py @@ -11,6 +11,7 @@ import salt.loader from salt.defaults import DEFAULT_TARGET_DELIM from salt.exceptions import SaltException +from salt.utils.decorators.jinja import jinja_global __func_alias__ = {"list_": "list"} @@ -396,3 +397,66 @@ def search_by(lookup, tgt_type="compound", minion_id=None): matches.append(key) return matches or None + + +@jinja_global("ifelse") +def ifelse( + *args, + tgt_type="compound", + minion_id=None, + merge=None, + merge_lists=False, +): + """ + .. versionadded:: 3006 + + Evaluate each pair of arguments up to the last one as a (matcher, value) + tuple, returning ``value`` if matched. If none match, returns the last + argument. + + The ``ifelse`` function is like a multi-level if-else statement. It was + inspired by CFEngine's ``ifelse`` function which in turn was inspired by + Oracle's ``DECODE`` function. It must have an odd number of arguments (from + 1 to N). The last argument is the default value, like the ``else`` clause in + standard programming languages. Every pair of arguments before the last one + are evaluated as a pair. If the first one evaluates true then the second one + is returned, as if you had used the first one in a compound match + expression. Boolean values can also be used as the first item in a pair, + as it will be translated to a match that will always match ("*") or never + match ("SALT_IFELSE_MATCH_NOTHING") a target system. + + This is essentially another way to express the ``filter_by`` functionality + in way that's familiar to CFEngine or Oracle users. Consider using + ``filter_by`` unless this function fits your workflow. + + CLI Example: + + .. code-block:: bash + + salt '*' match.ifelse 'foo*' 'Foo!' 'bar*' 'Bar!' minion_id=bar03 + """ + if len(args) % 2 == 0: + raise SaltException("The ifelse function must have an odd number of arguments!") + elif len(args) == 1: + return args[0] + + default_key = "SALT_IFELSE_FUNCTION_DEFAULT" + + keys = list(args[::2]) + for idx, key in enumerate(keys): + if key is True: + keys[idx] = "*" + elif key is False: + keys[idx] = "SALT_IFELSE_MATCH_NOTHING" + + lookup = dict(zip(keys, args[1::2])) + lookup.update({default_key: args[-1]}) + + return filter_by( + lookup=lookup, + tgt_type=tgt_type, + minion_id=minion_id, + merge=merge, + merge_lists=merge_lists, + default=default_key, + ) diff --git a/salt/utils/templates.py b/salt/utils/templates.py index 1f613d46534e..33fa989252b0 100644 --- a/salt/utils/templates.py +++ b/salt/utils/templates.py @@ -13,6 +13,7 @@ import jinja2.ext import jinja2.sandbox +import salt.modules.match import salt.utils.data import salt.utils.dateutils import salt.utils.files diff --git a/tests/pytests/unit/modules/test_match.py b/tests/pytests/unit/modules/test_match.py index c5f0fc180dd1..521049547cef 100644 --- a/tests/pytests/unit/modules/test_match.py +++ b/tests/pytests/unit/modules/test_match.py @@ -253,3 +253,45 @@ def test_list_match_different_minion_id(): # passing minion_id, should return True assert match.list_("bar02,bar04", "bar04") + + +def test_ifelse(): + """ + Tests if ifelse returns the correct value. + """ + lookup = [ + "foo*", + {"key1": "fooval1", "key2": "fooval2"}, + "bar*", + {"key1": "barval1", "key2": "barval2"}, + ] + default = {"key1": "default1", "key2": "default2"} + + # even args + with pytest.raises(SaltException): + match.ifelse("matcher", "value") + # only default provided + assert match.ifelse(default, minion_id="foo03") == { + "key1": "default1", + "key2": "default2", + } + # match foo + assert match.ifelse(*lookup, default, minion_id="foo03") == { + "key1": "fooval1", + "key2": "fooval2", + } + # match bar + assert match.ifelse(*lookup, default, minion_id="bar03") == { + "key1": "barval1", + "key2": "barval2", + } + # no match + assert match.ifelse(*lookup, default, minion_id="baz03") == { + "key1": "default1", + "key2": "default2", + } + # boolean matchers + assert ( + match.ifelse(False, "nuh uhn", True, "this is true", "default value") + == "this is true" + ) diff --git a/tests/pytests/unit/utils/jinja/test_custom_extensions.py b/tests/pytests/unit/utils/jinja/test_custom_extensions.py index ff166e1a8dbf..08c8e2556be1 100644 --- a/tests/pytests/unit/utils/jinja/test_custom_extensions.py +++ b/tests/pytests/unit/utils/jinja/test_custom_extensions.py @@ -15,6 +15,7 @@ import salt.loader # dateutils is needed so that the strftime jinja filter is loaded +import salt.modules.match as match import salt.utils.dateutils # pylint: disable=unused-import import salt.utils.files import salt.utils.json @@ -56,6 +57,11 @@ def minion_opts(tmp_path): return _opts +@pytest.fixture() +def configure_loader_modules(minion_opts): + return {match: {"__opts__": minion_opts}} + + @pytest.fixture def local_salt(): return {} @@ -1234,3 +1240,18 @@ def test_random_shuffle(minion_opts, local_salt): dict(opts=minion_opts, saltenv="test", salt=local_salt), ) assert rendered == "['four', 'two', 'three', 'one']" + + +def test_ifelse(minion_opts, local_salt): + """ + Test the `ifelse` Jinja global function. + """ + rendered = render_jinja_tmpl( + "{{ ifelse('default') }}\n" + "{{ ifelse('foo*', 'fooval', 'bar*', 'barval', 'default', minion_id='foo03') }}\n" + "{{ ifelse('foo*', 'fooval', 'bar*', 'barval', 'default', minion_id='bar03') }}\n" + "{{ ifelse(False, 'fooval', True, 'barval', 'default', minion_id='foo03') }}\n" + "{{ ifelse('foo*', 'fooval', 'bar*', 'barval', 'default', minion_id='baz03') }}", + dict(opts=minion_opts, saltenv="test", salt=local_salt), + ) + assert rendered == ("default\n" "fooval\n" "barval\n" "barval\n" "default")