Language Reference¶
The Inmanta language is a declarative language to model the configuration of an infrastructure.
The evaluation order of statements is determined by their dependencies on other statements and not based on the lexical order. i.e. The code is not necessarily executed top to bottom.
Modules¶
The source is organized in modules. Each module is a git repository with the following structure:
module/
+-- files/
+-- model/
| +-- _init.cf
+-- plugins/
+-- templates/
+-- module.yaml
The module.yaml
file, the model
directory and the model/_init.cf
are required.
For example:
test/
+-- files/
+-- model/
| +-- _init.cf
| +-- services.cf
| +-- policy
| | +-- _init.cf
| | +-- other.cf
+-- plugins/
+-- templates/
+-- module.yaml
The model code is in the .cf
files. Each file forms a namespace. The namespaces for the files are the following.
File | Namespace |
---|---|
test/model/_init.cf | test |
test/model/services.cf | test::services |
test/model/policy/_init.cf | test::policy |
test/model/policy/other.cf | test::policy::other |
Modules are only loaded when they are imported by a loaded module or the main.cf
file of the project.
To access members from another namespace, it must be imported into the current namespace.:
import test::services
Imports can also define an alias, to shorten long names:
import test::services as services
Variables¶
Variables can be defined in any lexical scope. They are visible in their defining scope and its children.
A lexical scope is either a namespaces or a code block (area between :
and end
).
Variable names must start with a lower case character and can consist of the characters: a-zA-Z_0-9-
A value can be assigned to a variable exactly once. The type of the variable is the type of the value. Assigning a value to the same variable twice will produce a compiler error, unless the values are identical.
Variables from other modules can be referenced by prefixing them with the module name (or alias):
import redhat
os = redhat::fedora23
import ubuntu as ubnt
os2 = ubnt::ubuntu1204
Literals values¶
Literal values can be assigned to variables:
var1 = 1 # assign an integer, var1 contains now a number
var2 = 3.14 # assign a float, var2 also contains a number
var3 = "This is a string" # var3 contains a string
# var 4 and 5 are both booleans
var4 = true
var5 = false
# var6 is a list of values
var6 = ["fedora", "ubuntu", "rhel"]
# a dictionary with string keys and any type of values is also a primitive
var7 = { "foo":"bar", "baz": 1}
# var8 contains the same value as var2
var8 = var2
# next assignment will not return an error because var1 already contains this value
var1 = 1
# next assignment would return an error because var1 already has a different value
#var1 = "test"
#ref to a variable from another namespace
import ip::services
sshservice = ip::services::ssh
Primitive types¶
The basic primitive types are string
, number
or bool
.
Constrained primitive types can be derived from the basic primitive type with a typedef statement. Constrained primitive types add additional constraints to the basic primitive type with either a regex or a logical condition. The name of the constrained primitive type must not collide with the name of a variable or type in the same lexical scope.
typedef : 'typedef' ID 'as' PRIMITIVE 'matching' condition|regex;
For example:
typedef tcp_port as number matching self > 0 and self < 65565
typedef mac_addr as string matching /([0-9a-fA-F]{2})(:[0-9a-fA-F]{2}){5}$/
Lists of primitive types are also primitive types: string[]
, number[]
, bool[]
or mac_addr[]
dict
is the primitive type that represents a dictionary
Conditions¶
Conditions can have the following forms
condition : '(' condition ')'
| condition 'or' condition
| condition 'and' condition
| 'not' condition
| value ('>' | '>=' | '<' | '<=' | '==' | '!=') value
| value 'in' value
| 'true'
| 'false'
| functioncall
;
Function calls¶
Each module can define plugins. Plugins can contribute functions to the module’s namespace. The function call syntax is
functioncall : moduleref '.' ID '(' arglist? ')';
arglist : value
| arglist ',' value
For example:
std::familyof(host.os, "rhel")
a = param::one("region", "demo::forms::AWSForm")
Entities¶
Entities model configuration concepts. They are like classes in other object oriented languages: they can be instantiated and they define the structure of their instances.
Entity names must start with an upper case character and can consist of the characters: a-zA-Z_0-9-
Entities can have a number of attributes and relations to other entities. Entity attributes have primitive types, with an optional default value.
Entities can inherit from multiple other entities. Entities inherits attributes and relations from parent entities.
All entities inherit from std::Entity
.
It is not possible to override or rename attributes or relations. However, it is possible to
override defaults. Default values for attributes defined in the class take precedence over those in
the parent classes. When a class has multiple parents, the left parent takes precedence over the
others. A default value can be removed by setting its value to undef
.
The syntax for defining entities is:
entity: 'entity' ID ('extends' classlist)? ':' attribute* 'end';
classlist: class
| class ',' classlist;
attribute: primitve_type ID ('=' literal)?;
Defining entities in a configuration model:
entity File:
string path
string content
number mode = 640
string[] list = []
dict things = {}
end
Default values can also be set using a type alias:
typedef PublicFile as File(mode = 0644)
A constructor call using a type alias will result in an instance of the base type.
Relations¶
A Relation is a bi-direction relation between two entities. Consistency of the double binding is maintained by the compiler: assignment to one side of the relation is an implicit assignment of the reverse relation.
Relations are defined by specifying each end of the relation together with the multiplicity of each relation end. Each end of the relation is named and is maintained as a double binding by the compiler.
Defining relations between entities in the domain model:
# Each config file belongs to one service.
# Each service can have one or more config files
File file [1:] -- [1] Service service
cf = ConfigFile()
service = Service()
cf.service = service
# implies service.configfile == cf
Relation multiplicities are enforced by the compiler. If they are violated a compilation error is issued.
New Relation syntax¶
A new relation syntex is available, to give a more natural object oriented feeling.
relation: class '.' ID multi '--' class '.' ID multi
| class '.' ID multi annotation_list class '.' ID multi ;
annotation_list: value
| annotation_list ',' value
For example (as above):
File.service [1] -- Service.file [1:]
Warning
The names and multiplicities are on the other side in the old and new syntax!
In this new syntax, relations can also be unidirectional
uni_relation : class '.' ID multi '--' class
| class '.' ID multi annotation_list class;
For example):
Service.file [1:] -- File
Instantiation¶
Instances of an entity are created with a constructor statement:
File(path="/etc/motd")
A constructor can assign values to any of the properties (attributes or relations) of the entity. It can also leave the properties unassigned. For attributes with default values, the constructor is the only place where the defaults can be overridden.
Values can be assigned to the remaining properties as if they are variables. To relations with a higher arity, multiple values can be assigned:
Host host [1] -- [0:] File files
h1 = Host("test")
f1 = File(host=h1, path="/opt/1")
f2 = File(host=h1, path="/opt/2")
f3 = File(host=h1, path="/opt/3")
// h1.files equals [f1, f2, f3]
FileSet set [1] -- [0:] File files
s1 = FileSet()
s1.files = [f1,f2]
s1.files = f3
// s1.files equals [f1, f2, f3]
s1.files = f3
// adding a value twice does not affect the relation,
// s1.files still equals [f1, f2, f3]
Refinements¶
Entities define what should be deployed. Entities can either be deployed directly (such as files and packages) or they can be refined. Refinement expands an abstract entity into one or more more concrete entities.
For example, apache.Server
is refined as follows:
implementation apacheServerDEB for Server:
pkg = std::Package(host=host, name="apache2-mpm-worker", state="installed")
pkg2 = std::Package(host=host, name="apache2", state="installed")
svc = std::Service(host=host, name="apache2", state="running", onboot=true, reload=true, requires=[pkg, pkg2])
svc.requires = self.requires
# put an empty index.html in the default documentroot so health checks do not fail
index_html = std::ConfigFile(host=host, path="/var/www/html/index.html", content="",
requires=pkg)
self.user = "www-data"
self.group = "www-data"
end
implement Server using apacheServerDEB when std::familyof(host.os, "ubuntu")
For each entity one or more refinements can be defined with the implementation
statement.
Implementation are connected to entities using the implement
statement.
When an instance of an entity is constructed, the runtime searches for refinements.
One or more refinements are selected based on the associated conditions. When no implementation is found, an exception is raised.
Entities for which no implementation is required are implemented using std::none
.
In the implementation block, the entity instance itself can be accessed through the variable self.
implement
statements are not inherited.
The syntax for implements and implementation is:
implementation: 'implementation' ID 'for' class ':' statement* 'end';
implement: 'implement' class 'using' ID ('when' condition)?;
Indexes and queries¶
Index definitions make sure that an entity is unique. An index definition defines a list of properties that uniquely identify an instance of an entity. If a second instance is constructed with the same identifying properties, the first instance is returned instead.
All identifying properties must be set in the constructor.
Indices are inherited. i.e. all identifying properties of all parent types must be set in the constructor.
Defining an index:
entity Host:
string name
end
index Host(name)
Explicit index lookup is performed with a query statement:
testhost = Host[name="test"]
For loop¶
To iterate over the items of a list, a for loop can be used:
n_s = std::sequence(size, 1)
for i in n_s:
app_vm = Host(name="app{{i}}")
end
The syntax is:
for: 'for' ID 'in' value ':' statement* 'end';
Transformations¶
At the lowest level of abstraction the configuration of an infrastructure often consists of configuration files. To construct configuration files, templates and string interpolation can be used.
String interpolation¶
String interpolation allows variables to be include as parameters inside a string.
The included variables are resolved in the lexical scope of the string they are included in.
Interpolating strings:
hostname = "serv1.example.org"
motd = """Welcome to {{hostname}}\n"""
Templates¶
Inmanta integrates the Jinja2 template engine. A template is evaluated in the lexical
scope where the std::template
function is called. This function accepts as an argument the
path of a template file. The first part of the path is the module that contains the template and the remainder of the path is the path within the template
directory of the module.
The integrated Jinja2 engine supports to the entire Jinja feature set, except for subtemplates. During execution Jinja2 has access to all variables and plug-ins that are
available in the scope where the template is evaluated. However, the ::
in paths needs to be replaced with a
.
. The result of the template is returned by the template function.
Using a template to transform variables to a configuration file:
hostname = "wwwserv1.example.com"
admin = "joe@example.com"
motd_content = std::template("motd/message.tmpl")
The template used in the previous listing:
Welcome to {{ hostname }}
This machine is maintainted by {{ admin }}
Plug-ins¶
For more complex operations, python plugins can be used. Plugins are exposed in the Inmanta language as function calls, such as the template function call. A template accepts parameters and returns a value that it computed out of the variables.
Each module that is included can also provide plug-ins. These plug-ins are accessible within the namespace of the module.
To define a plugin, add a __init__.py
file to the plugins directory.
In this file, plugins can be define according to the following template:
from inmanta.plugins import plugin, Context
from inmanta.execute.util import Unknown
from inmanta.config import Config
@plugin
def example(ctx: Context, vm: "std::Host") -> "ip::ip":
# get compiler config
env = Config.get("config", "environment", None)
# use exceptions
if not env:
raise Exception("The environment of this model should be configured in config>environment")
# access compiler data via context
scrapspace = ctx.get_data_dir()
return "127.0.0.1"
Plugins have to be decorated with @plugin to work.
Arguments to the plugin have to be annotated with a type that is visible in the namespace of the module (or with any
).
An argument of the type inmanta.plugins.Context
can be used to get access to the internal state of the compiler.
The inmanta.config.Config
singleton can be used to get access to the configuration of the compiler.
Often, plugins are used to collect information from external systems, such as for example, the IP of virtual machine. When the virtual machine has not been created yet, the IP is not known yet. To indicate that situation (where information is not available yet), the type Unknown
is used.
i.e. When the plugin is used to collect information from external systems, but this information is not available yet (but will be when the model deployment advances) then the plugin should return an instance of the type inmanta.execute.util.Unknown
.
Resources¶
Resources are entities that can be deployed directly, such as std::File
or std::Package
.
- Resource deployment has the following flow:
- a model is compiled
- all resources are identified and converted in serializeable form (
Resource
object) - all resources (and their associated python files) are uploaded to the server
- deploy is triggered
- resources are deployed to the agents that are responsible for this resource
- agents download the associated python code
- agents deserialize the resources
- agent execute the relevant handlers for the resources
To create new types of resource, two python objects are required: the Resource
and the Handler
.
The resource convert a model object into a serializable form:
@resource("std::File", agent="host.name", id_attribute="path")
class File(Resource):
"""
A file on a filesystem
"""
fields = ("path", "owner", "hash", "group", "permissions", "purged", "reload")
map = {"hash": store_file, "permissions": lambda y, x: int(x.mode)}
- A resource is a subclass of
inmanta.resources.Resource
annotated withinmanta.resources.resource
. The annotation takes 3 parameters: name
: the name of the entity to convert into a resourceagent
: the name of the agent that will deploy this resource. Often the name of the host on which the resource will be deployed.id_attribute
: the attribute of the entity that uniquely distinguishes this instance from the others within its agent.
- The class has two class fields:
fields
: the list of fields to be serialized and sent to the agentmap
: a dict, providing functions to generate values for fields that do not directly correspond to a property of the entity.
The handler is responsible for the actual deployment. For this, we refer to the examples available in the std
module.