Salt Module Interface (SMI) concept introduction for virtual modules.
Any salt module has couple of specific properties we dealing every day with:
This is true but for virtual modules. The virtual module covering several "fixed" or "physical" modules and behaves like that would be one module. But differences between those physical modules on different platforms makes such virtual module a moving target and unpredictable.
Virtual modules concept is missing crucial part in the design: interfaces. The interface should define how module looks like and what APIs can be called to it. Interface should move module that is called differently on heterogeneous environments to a module that reports differently on heterogeneous environments.
DISCLAIMER: The SMI is not that classic understanding of typical interface one may find in languages like Java. It is also not as same as Zope Interface package or Python Abstract Base Classes (ABC).
The SMI should describe the following properties of the module:
SMI only describes functions of the module and is there to make sure that any virtual module is always called exactly the same way, regardless what operating system minion is running on.
SMI are declared just as regular Python classes. Salt's "module to
functions" map is Salt's "interface to methods" map. Therefore self
parameter in the SMI class is not a part of a function signature.
Example of SMI definition for module pkg
:
from salt.interfaces import Interface
class PkgInterface(Interface):
__modulename__ = 'pkg'
def list_installed(self, *names, **kwargs):
'''
List installed packages.
'''
return {}
def upgrade_available(self, name, **kwargs):
'''
List available upgrades.
'''
return {}
@Interface.supported(os=['weirdlinux', 'beos', 'frogbsd'], os_family=['linux'])
def salute_fireworks(self, name):
'''
Launch some fireworks
'''
return {}
In above incomplete interface example, the list of methods should
reflect exact names and signatures as in the module, except self
parameter. Rules apply:
If a method is not in the SMI class, but function is implemented in the module, then such function is marked as "deprecated".
If a method is in the SMI class but not in the module, then such function is marked as "not implemented".
If a method has a decorator @Interface.supported
, only on specified
systems unimplemented method will be reported as "not
implemented", otherwise "not supported". This decorator accepts
any grains possible. It then matches them if any specified grain
is in proposed lists. From the example above, missing
salute_fireworks
will be reported as "not implemented" if
os_family
grain equals linux
or os
grain equals
weirdlinux
or beos
or frogbsd
.
Once SMI class defined, the usage should be very simple:
from salt.interfaces.pkg_module import PkgInterface
__virtualname__ = PkgInterface(__name__)()
The code above does the following:
__virtualname__
is properly set according to the
interface.Essentially, the SMI works as automatic checker/corrector for the module on the moment it is lazy-loaded.
What PkgInterface does in the example above, it takes the current
module and examines if the exported functions are there. Once nothing
found, a stub is placed. That means, if module pkg
requires,
e.g. function lock
and there is implemented hold
, then function
lock
will be also added as "not implemented" (or "unsupported",
depends on decorator in the Interface declaration).
SMI will also mark existing functions that are not inside the
interface as subject to retirement, by automatically placing a warning
decorator to them. That said, if an interface class does not describes
hold
function, but that function is still physically implemented,
calling that function will also raise a warning in the log file that
this function is deprecated and is subject to be removed in a future.
On some operating systems certain functions aren't applicable. In this case they should be decorated with the proposed function decorator:
class SomeModuleInterface(Interface):
@Interface.not_applicable(osfamily=['Windows', 'NetBSD'])
def foo(self, name, *args):
return {}
The decorator would support any kind of grains keys with any of the values to compare with. Once certain grain matches in the list of the given values, decorator is triggered.
In this case method foo
will be still added on Windows and NetBSD
minions, despite the fact that the code below adds it only on RedHat
Linux. However it will only return specified structure and debug log
will inform that not applicable function has been called.
Such decorator deals with the cases, where function is being added to the module only on certain conditions, e.g.:
if __grains__['osfamily'] == 'RedHat':
def foo(name, *args):
return {}
Return structure in virtual modules is another pitfall. Dynamically replaced module suddenly renders virtual module to return "something else" than is usually expected. This is widely affects API and integration. To the only way to avoid this, is to know what kind of platform minion is dealing with. In this case integration code usually looks like this (pseudo-code):
if this_is_debian {
function_call({'disabled': False})
else {
function_call({'enabled': True})
}
There is a catch: some operating systems/platforms must return specific properties that aren't available on other systems. Therefore return structure should be always defined from two blocks:
SMI class should define return structure from the defined method. This
structure is very similar to config/__init__.py::_validate_opts()
function.
SMI also should take care of return structure definition so all virtual modules returns by default the same structure.
However, the migration and adoption of the same structure from different physical modules is not easy. Modules are also called through the states and there is already specific structure is used. The usage would not change, but the implementation would be to wrap all functions with a decorator, which would validate the default output.
This RFC is not to cover the detailed output structure part, but only foresee a placeholder for it the in current design of the Interface concept.
If some function happens to be an alien to the interface, question is how to react on this. Muting and do not report function is obsolete is still asking for a problem. Because if we know that in N years/releases function is going to be retired, simply just do not use it or move away from it. But if this is configured and can be muted, such option will bring more harm than help.
One of the possibility is to expect Interface class instance in
__virtualname__
variable, instead of a string. In this case
__call__
is not performed right in the module, but LazyLoader
instead gets the __modulename__
variable content.
Another possibility is to adjust PyLint to it and make sure each
__virtualname__
has Interface assigned instead of a string.
Alternatively, not to force Interface usage. But this has drawback of setting the interface overall optional, which will eventually be optional everywhere, unfortunately.
To generate an interface out of the signatures of some package, it is just enough to take a reference package and do something like this:
cat zypper.py | grep '^def [a-z]' | sed -e 's/(/(self, /g' | sed -e 's/def/ def/g'
It will create ready to copy signatures, based on zypper.py
as a reference.
Implementation of this concept must be done in two phases:
On the second phrase corner cases might force the implementation details to be minor changed. The result, however should be the same: modules should just work as they worked before while used in real systems.
The structure definition and migration should be done as well gradually. This should be covered in a separate RFC.