saltstack/salt

View on GitHub
rfcs/sls-alternative-syntax.md

Summary

Maintainability
Test Coverage
- Feature Name: Batch mode SLS syntax
- Start Date: November 17, 2018.

# Summary
[summary]: #summary

Enhance SLS syntax with simpler way of writing "things".

# Motivation
[motivation]: #motivation

Writing SLS can be not always easy and friendly. Especially when you
want to do various operations on the same object. Main complain is
heard that in order to do something _again_ or run the same function
e.g. on the same file, one would need to create another ID for it.

The following example will get content of `/etc/<something>-release`,
based on `os_family` grain, if it is e.g. `RedHat`:

```yaml
rhelrelease:
  cmd.run:
    - name: cat /etc/redhat-release
    - onlyif: test -f /etc/redhat-release

centosrelease:
  cmd.run:
    - name: cat /etc/centos-release
    - onlyif: test -f /etc/centos-release
```

This will return a structure with an empty `rhelrelease` and content
of the release file under `centosrelease`. From API point of view, one
would need to check both keys in the structure.

Of course, it is possible to write it a bit better then that,
something like:

```jinja
{% if grains['os_family'] == 'RedHat' %}
{% set releasefile = 'centos' if grains['os'] == 'CentOS' else 'redhat' %}

release:
  cmd.run:
    - name: cat /etc/{{ releasefile }}-release
    - onlyif: test -f /etc/{{ releasefile }}-release
{%endif%}
```

It is still gets more complicated if older opensuse or SLES machines
also needs to be looked up. And yet this SLS is not finished as even
more careful logic needed to be added in order to handle cases where
no OS is detected. At this point SLS is no longer simple and
declarative, but looks more like programming.

We can make it simpler. For example as follows:


```jinja
release:
  cmd:
    {% for releasefile in ['redhat', 'centos', 'SuSE', 'sles'] %}
    - run:
      - name: cat /etc/{{releasefile}}-release
      - onlyif: test -f /etc/{{releasefile}}-release
    {% endfor %}
```

In the example above, the for-loop will just generate four times same
statement to call `cmd.run` function, reusing already built-in check
on `onlyif`. That said, no more need to figure out what OS name
belongs to what family etc.

The example above only made for show-case to let you get the idea.

# Design
[design]: #detailed-design

## How batch-mode syntax is used?

As simple as _not_ specifying particular function. Typically, in Salt
it is done this way:

```yaml
/etc/some.conf:
  somemodule.some_function:  # <--- here!
    - params...
```

The simple syntax will be invoked as so:

```yaml
/etc/some.conf:
  somemodule:  # <--- just like that, no function
    - some_function:
      - params ...
```

Of course, in single call this dosn't make much sense and is actually
one line more to write. However, if there is more one operation that
needed to be done, this makes a big difference. Since it is impossible
to have more than one same ID, you will be required to write different
IDs for the _same group_ of tasks, if we grouping them by the targeted
object. This results into something like this:


```yaml
do_something_with_my_config:
  somemodule.some_function:
    - name: /etc/some.conf
    - params ....

do_something_with_my_config_again:
  somemodule.some_other_function:
    - name: /etc/some.conf
    - params ....

do_something_with_my_config_and_over_again:
  somemodule.some_third_function:
    - name: /etc/some.conf
    - params ....
```

With the batch-mode, the same above can be re-written in much more
efficient way as follows:

```yaml
/etc/some.conf:
  somemodule:
    - some_function
      - params ....
    - some_other_function
      - params ....
    - some_third_function
      - params ....
```

## How does it work?

It works in three steps:

1. During state compile, compiler finds that there is no end function
   defined, then it injects `__call__` function name into the
   state. Essentially, referring to the example above, state will be
   simply altered to `somemodule.__call__`, but transparently for the
   user. LazyLoader does not allow any functions that starts with `_`
   as they are considered private/hidden. The `__call__` is an
   exception but it should be still impossible to invoke it from the
   module directly.

2. When LazyLoader loads a module, it will inject a generic function
   named `__call__` at the module level. If such function is already
   defined inside the module, LazyLoader will not inject that function
   therefore.

3. The `__call__` function takes a list of sets structure, as shown above,
   assuming that the key of the set is the function name of the parent
   element `somemodule`, which is a module name, and essentially
   performs the following:

```python
ret = []
for function_name in functions:
    args, kwargs = functions[function_name]['args'], functions[function_name]['kwargs']
    ret.append(getattr(this_module_instance, function_name)(*args, **kwargs))
```

## What needs to be changed in existing modules?

Nothing. Also no changes to any future modules, yet to be written. If
module has non-traditional way of exposing methods, e.g. implemented
as class or functions made dynamically, in this case `__call__` should
be made separately for that. However, since this is advanced way of
making modules, developer already knows what he is doing in this case.

Or "lazy" implementation, if one wants to just turn this feature off:

```python

__call__ = lambda (*a, **k): raise SaltNotSupportedError()
```

## Impact on existing ecosystem

No impact. Performance and default syntax should stay unchanged.