Welcome to mgmt config!
This guide is designed to teach you the concepts and syntax needed to use mgmt successfully on your first day. Each section introduces concepts and related syntax that build on the previous sections, so we recommend following this guide from start to finish. The runnable examples are intended to be run by you, so ensure you have mgmt installed.
Examples in this document were tested on Fedora 44, so for best results, we recommend having a fresh Fedora installation available. It is OK if you do not have one, though, and most of the examples should still be useful.
Table of Contents
Configuration with Resources
In mgmt, configuration is described using elements called resources. Most resource kinds will be familiar and align with your mental model of your infrastructure. For example, a resource can ensure that a package is installed, a service is disabled, or a user exists.
Resources have three parts: a kind, a name, and parameters (mgmt calls these
params). The following example shows a single file resource written in
mgmt’s programming language, mcl:
file "/tmp/hello.txt" {
state => "exists",
content => "Greetings from mgmt!\n",
}
This file resource is named /tmp/hello.txt. It describes a state where you
want the file to exist and contain exactly:
Greetings from mgmt!
With this programming model, you express the desired state for each resource, and mgmt makes it real by observing the current state and making any necessary changes. The language, mcl, is a strongly-typed, reactive, functional language, and if those terms are unfamiliar to you, do not worry! Each of those terms will be discussed further in this guide.
Running mgmt
You can execute mcl code with mgmt. First, place the sample code above in a
file named hello.mcl, then run mgmt run lang hello.mcl. Mgmt normally writes
its logs to stderr, so the output from our file resource will look like this:
$ mgmt run lang hello.mcl
... (other output omitted for brevity) ...
10:28:48 engine: file[/tmp/hello.txt]: copy 21 bytes
When you run this, notice the following:
- Mgmt did not exit after converging. More on this in the next section.
- Mgmt can run as any user, but that user will need the correct permissions to execute some resources.
- The first part,
10:28:48, is a timestamp. Your timestamps will be different.
When all resources are in their desired state, mgmt considers the system converged.
Let’s verify that our file was created correctly. Run the following in another terminal to view its contents:
$ cat /tmp/hello.txt
Greetings from mgmt!
Continuous Convergence
In the example above, mgmt stayed running even after converging. This is because mgmt continuously watches for changes (aka divergence) and will react immediately and as needed.
Let’s explore that! While mgmt is still running, we can modify the file in another terminal and observe mgmt’s immediate responses:
# Remove the file.
$ rm /tmp/hello.txt
# mgmt notices and responds:
10:36:34 engine: file[/tmp/hello.txt]: copy 21 bytes
# Change the file's contents.
$ echo "mgmt is fast" > /tmp/hello.txt
# mgmt notices and responds:
10:37:18 engine: file[/tmp/hello.txt]: copy 21 bytes
You can terminate mgmt in your terminal by pressing ctrl+c.
Each time we remove or modify the file, mgmt notices that the file has diverged from its desired state, and it reacts immediately to bring the resource to the desired state. We will see more examples of this further in the guide, including a case where the desired state itself changes!
Resource Syntax
Resource definition syntax in mcl looks like this. This schematic uses placeholder resource kinds and parameters, so it is not intended to be run:
# This is a comment.
# Define a single resource.
kind "name" {
# All params must end with a comma.
param1 => "value1",
# Including the final param.
param2 => "value2",
}
# Define multiple resources of the same kind with the same params:
kind ["name1", "name2", "name3"] {
param1 => "value1",
}
# Did you know? Mgmt has some internal resources like
# a web server and it can serve files over HTTP? :)
http:server "127.0.0.1:8080" { }
What’s in a name? Many resources use the name as a default value for a
param. The file resource uses name as the default value for the path param.
Check out the resource reference for details on how each
resource may use the name.
Syntax caution: In a resource, every param must have a trailing comma, including the last one.
The example above can also be written with a different name and setting the path explicitly:
file "a greeting" {
path => "/tmp/hello.txt",
state => "exists",
content => "Greetings from mgmt!",
}
Mgmt has many kinds of resources, such as:
Check out the resources reference for more.
Can resources be remote or only local? Mgmt resources do not have a concept of
local or remote. While file and pkg execute locally on a machine, some
resources will involve remote services, such as an aws:ec2 instance resource.
You can even export resource definitions from one mgmt to be executed by mgmt on
another machine.
More Familiar Resources
Mgmt supports a wide variety of resources. For your first day, we’ll keep focusing on familiar and common resources, such as packages and services.
Packages
Let’s dive into the pkg resource for managing system packages with an example that ensures two packages are installed:
# Ensure two different packages are installed.
pkg "screen" {
state => "installed",
}
pkg "cowsay" {
state => "installed",
}
Note: Package management may require root privileges, so we can invoke mgmt
with sudo to run this example.
After saving the example as first-package.mcl, we run it:
$ sudo mgmt run lang first-package.mcl
... (other output omitted for brevity) ...
19:00:19 engine: autogroup: pkg[cowsay] into pkg[screen]
19:00:19 engine: pkg[screen]: Check: pkg[autogroup:(screen,cowsay)]
19:00:19 engine: pkg[screen]: Apply: pkg[autogroup:(screen,cowsay)]
19:00:19 engine: pkg[screen]: Set(installed): pkg[autogroup:(screen,cowsay)]...
19:00:25 engine: pkg[screen]: Set(installed) success: pkg[autogroup:(screen,cowsay)]
This installed our packages, and mgmt did some extra optimization for us! In this case, mgmt knows that multiple packages can be installed in a single step, so it grouped both screen and cowsay together. mgmt calls this autogrouping.
You can test mgmt’s reflexes by removing the cowsay package and watching mgmt
respond to this divergence by reinstalling it.
This example has one last important lesson! You may find yourself configuring multiple resources of the same kind and all with the same params. In cases like this, you can provide a list of names in the name part of the resource description:
pkg ["screen", "cowsay"] {
state => "installed",
}
Types of values like strings and lists will be covered in a later section.
Services
To finish introducing resources, here is an example that instructs mgmt to ensure the SSH service is running and enabled on boot.
svc "sshd" {
state => "running",
startup => "enabled",
}
Running this (as root), we can see mgmt again acting swiftly as it notices that sshd was not enabled at boot nor was it running:
$ sudo mgmt run lang first-service.mcl
19:08:50 engine: svc[sshd]: service enabled
19:08:50 engine: svc[sshd]: service started
Resource Relationships
We’ve seen that resources in mcl instruct mgmt on what to observe and what the desired state is for each resource. In practice, your desired state may include hundreds or thousands of resources, and it’s very likely that one resource depends on another, such as a service depending on a package that provides necessary systemd unit files.
A common example manages a service with three resources executed in exactly this order: install a package, change the configuration file, and ensure the service is running. Additionally, if the service configuration changes, you want to notify the service of that change, right?
In mgmt, resources without ordering relationships can execute concurrently.
This allows mgmt to resolve as much as possible in the shortest amount of time.
There is no order unless you define one, and in the absence of order, we might
see mgmt attempt to converge a svc before its pkg. Here’s a real example
that installs the nginx package and starts the nginx system service:
svc "nginx" {
state => "running",
startup => "enabled",
}
pkg "nginx" {
state => "installed",
}
Without expressing any order, mgmt will execute all resources simultaneously,
and the result will sometimes apply the svc before the pkg (which provides
the service files) is installed!
17:48:52 engine: svc[nginx]: Error: failed to find svc: nginx.service
17:48:52 engine: pkg[nginx]: Apply: pkg[nginx]
17:48:52 engine: pkg[nginx]: Set(installed): pkg[nginx]...
17:49:01 engine: pkg[nginx]: Set(installed) success: pkg[nginx]
You can impose order on resources by defining relationships. Relationships can be expressed in two ways, shown below. Both ways are equivalent. Use whichever is most convenient for you.
For the examples below, we will consider two resources and their relationship: an SSH package and an SSH service. The OS-provided package for SSH includes a systemd service, which means there’s no service to start until the package is installed. Therefore, we need to establish an order. We can imagine them visually:
Relationships using the arrow -> operator
The following mcl implements the above description:
pkg "openssh-server" {
state => "installed",
}
svc "sshd" {
state => "running",
startup => "enabled",
}
# Tell mgmt that the package needs
# to execute before the service.
Pkg["openssh-server"] -> Svc["sshd"]
Note: These examples use Fedora Linux. Other Linux distributions may use different package and service names.
The syntax for arrow (->) relationships is:
Kind["name"] -> Kind2["name2"]
The kind is a capitalized version of the resource name, and the "string"
inside the [brackets] is the resource’s name.
Relationship Params: Depend and Before
If it is more convenient, you may express a relationship inside any resource
definition using the params Before or Depend (capitalization is important).
To express “openssh-server package must be executed before its service” we can
use either of these:
pkg "openssh-server" {
state => "installed",
Before => Svc["sshd"],
}
Alternatively:
svc "sshd" {
state => "running",
startup => "enabled",
Depend => Pkg["openssh-server"],
}
Each relationship requires only one definition. That is, you can use an arrow,
one Before, or one Depend for a single relationship.
Relationship Params: Listen and Notify
Some relationships need more than just an order.
One example is a pair of resources: a file and a svc. You will want
mgmt to apply a file before applying a service change, and you also want the
service to be notified any time that file changes. That is, whenever the
service’s configuration files change, tell the service about it.
This notification relationship is expressed with either Notify or Listen
params. When you want to notify a resource, you’ll use Notify instead of
Before, and Listen instead of Depend.
A resource responds to a notification by refreshing. Exactly what a refresh does depends on the resource. For services, a refresh causes the service to reload if possible and restart otherwise.
Here’s what that looks like in mgmt, building on our previous nginx service and package example:
svc "nginx" {
state => "running",
startup => "enabled",
}
file "/etc/nginx/nginx.conf" {
content => "# this is my nginx config",
Notify => Svc["nginx"],
Depend => Pkg["nginx"],
}
pkg "nginx" {
state => "installed",
}
The example above ensures that the package is applied before the config file and
the file before the svc. Further, if mgmt changes the file contents in the
future, it notifies the svc, which causes nginx to reload or restart.
Relationship Rejected: Cycles
All relationships have a direction, as in “A before B” or “B after A”.
Mgmt will not allow relationships to create a loop, such as A before B, B before C, and C before A. A relationship loop is called a “cycle”, and mgmt reports an error. Here’s a simple example of a cycle:
file "/tmp/hello.txt" {}
file "/tmp/world.txt" {}
File["/tmp/hello.txt"] -> File["/tmp/world.txt"]
File["/tmp/world.txt"] -> File["/tmp/hello.txt"]
Visually, we can imagine it with two arrows (edges) in each direction between two resources:
Because both files want to be “before” each other, we have a loop with no beginning or end, and mgmt will report this error:
16:53:02 gapi exited with error: not a dag
resource graph has cycles
Not a DAG? Does the graph have cycles?
The visuals above are small examples of a structure called a graph, which is how mgmt represents and executes your infrastructure. Broadly, a graph is a network of objects. An object is usually called a vertex, while links or relationships between objects are called edges. Graphs are a well-studied structure with a body of research that provides mgmt with a nice selection of efficient algorithms.
Here’s how these graph terms map to what we’ve learned about mgmt:
- Vertex: A resource, like a file or pkg.
- Edge: A relationship between two resources.
- Direction: The arrow
->operator andBefore/Dependparams.
A special kind of graph called a DAG is used inside mgmt. A DAG, or directed acyclic graph, is a graph where all edges have a single direction and where edges are not allowed to form a loop, also called a cycle.
You have already seen a DAG before in mgmt’s logo :)
Programming in mgmt
This section introduces built-in functions, variables, and conditionals. We will use those features to program mgmt to handle differences between Linux distributions.
So far, we’ve been describing a single desired state. In essence, the resource graph has been static, or unchanging, throughout mgmt’s life and remains the same no matter where it runs. Let’s do more!
mcl allows decision-making that changes the resource graph. An earlier example hinted at the need for this: “other Linux distributions may use different names” for packages and services.
For our SSH service, Fedora calls it sshd, and Debian calls it ssh.
To solve our problem, we will import and use a built-in function,
os.release, to determine our Linux distribution
and use that information to decide which service name to use.
# Tell mgmt to let us use `os` functions.
import "os"
# Store the os release information in the $release variable.
# This information is a "struct" type that has an "id" field.
# The "id" field is a string containing an OS identifier.
$release = os.release()
if $release->id == "fedora" {
# Fedora calls this service "sshd".
svc "sshd" {
state => "running",
startup => "enabled",
}
}
# We can handle both Debian and Ubuntu together.
if $release->id == "debian" or $release->id == "ubuntu" {
# Debian calls this service "ssh".
svc "ssh" {
state => "running",
startup => "enabled",
}
}
Our mcl above will produce a different resource graph depending on what machine it runs on.
Variables
We used the variable $release to store the result of the os.release()
function. Variable bindings are immutable, meaning they may only be assigned
once, and they are block scoped. A bound expression can still produce new
values over time. Here are some examples that use variables:
In a bind statement, also known as an assignment:
$name = "James".In a resource name:
user [$name] { ... }.In a resource param:
file "/var/cache/james" { owner => $name, }In a string:
"Hello, ${name}".In a format function:
fmt.printf("Hello, %s", $name).
Syntax caution: When using a variable for a resource name, we recommend using a list of strings, as in either of the following:
# Bind a variable to a string value.
$ssh_service = "ssh"
# Use a list containing that variable.
svc [$ssh_service] {
state => "running",
}
Alternatively:
# Bind a variable to a list of strings.
$ssh_service = ["ssh"]
# Use that variable as the list of resource names.
svc $ssh_service {
state => "running",
}
mcl is a strongly typed language, and it rejects attempts to use a value of the wrong type. A resource has a string name, while a resource declaration can also accept a list of strings to create multiple resources. For convenience, a single string is accepted when the compiler can determine its value statically. An explanation of this follows further down in this document.
Formatting Text with Variables
mcl is a strongly-typed language. All values have a type, and parameters are
typed. You may find that if you assign a number to a variable, it can’t be used
where mgmt expects a string. In these cases, you’ll want to use the fmt.printf
function to format the number in a string:
import "fmt"
$port = 8000
file "/etc/nginx/nginx.conf" {
state => "exists",
content => fmt.printf("server {\n listen %d;\n}", $port),
}
We needed fmt.printf() here because $port is a number and cannot be used
as a string. For example, trying to use ${port} inside the content string
causes mgmt to report a type error:
content => "server {\n listen ${port};\n}".
17:44:59 error: cli parse error: could not unify types: type error: str != int
Only string values are allowed in "${variable}" string interpolation, and
$port above is a number. Resource and function params accept specific types.
The documentation for each resource and function describes the required type
for each param.
Scope of Variables
Variables are block scoped, meaning they are not accessible outside the block
where they are bound, or assigned. A block is the code between { and }. Mgmt
reports an error if a variable doesn’t exist.
import "os"
$release = os.release()
if $release->id == "fedora" {
$ssh_service = "sshd"
}
if $release->id == "debian" {
$ssh_service = "ssh"
}
# This will be an error:
# '$ssh_service' variable does not exist in this scope
svc $ssh_service {
state => "running",
startup => "enabled",
}
Mgmt will report that the variable doesn’t exist:
17:54:57 cli: lang: ast: var `$ssh_service` does not exist in this scope: variable-scope-error.mcl @ 13:5-13:17
svc $ssh_service {
^^^^^^^^^^^^
With a small change, we can fix the above example. In mcl, an if can also be
an expression, and expressions can be assigned to variables:
import "os"
$release = os.release()
$ssh_service = if $release->id == "fedora" {
"sshd"
} else {
# Assume all other distributions call it "ssh".
"ssh"
}
svc [$ssh_service] {
state => "running",
startup => "enabled",
}
Previously, you learned that resource definitions can accept a list of names or,
for convenience, a single string literal. In the above example, we used an if
expression to store a string in the $ssh_service variable, and because the
name parameter accepts a list of strings, we need to provide a list:
[$ssh_service].
Before we move on, you might wonder, what happens if we use the wrong type? Let’s try that. If we forget the brackets for the resource name and use:
svc $ssh_service {}
Mgmt reports the following because the single-string shorthand requires a value
that can be determined statically. The value of $ssh_service is computed by
the if expression, so this form expects a list instead:
18:40:06 cli: lang: unification: type error: str != list: variable-scope-if-expression.mcl @ 12:1-15:2
svc $ssh_service {
^ from here ...
}
^ ... to here
This is mgmt explaining that we provided a str where a list was required.
For system operators, strongly typed languages offer a significant benefit by moving small errors into the compilation phase rather than encountering them at runtime, potentially in production. Another way to think of this is that mgmt rejects all the code when there’s a type error. Mgmt rejected our mcl because we used the wrong value type, and this happens before the mcl is executed!
Functions
Functions are a way to perform computation and also a way to observe parts of your system without making changes. If you’ve done programming in other languages, mgmt functions may surprise you! In mgmt, functions may produce many results over time. Think of them more like a stream of data rather than a one-time computation.
The simplest example is time: mgmt’s datetime functions observe the clock and
report the time. Ever marching forward, time functions will produce new values
as the clock changes. Let’s try a small example using the print resource to
have mgmt log a message with the current time:
import "fmt"
import "datetime"
$now = datetime.now()
print "time check" {
msg => fmt.printf("the current time is %s", datetime.format($now, "2006-01-02 15:04:05")),
}
This is the first example where mgmt truly begins to shine and you can see the “reactive” part of mgmt for yourself:
The variable $now is bound to the result of the datetime.now() function,
which periodically provides new values. The binding does not change, but its
reactive value does. A new function result causes a chain reaction: a new
datetime.now() result transitively changes the msg setting for our print
resource.
$nowreceives a new value whendatetime.now()updates, every second in our example.- That causes the
datetime.format()result to be re-evaluated with the new$nowvalue. - That causes
fmt.printf()to be re-evaluated. - That causes the
Print["time check"]resource to have itsmsgparam re-evaluated. - When the
msgparam changes, mgmt prints a new message from this resource.
Try this example yourself using a file resource instead of a print resource!
file "/tmp/clock.txt" {
state => "exists",
content => fmt.printf("the current time is %s\n", datetime.format($now, "2006-01-02 15:04:05")),
}
A Complete Demonstration
While the previous example demonstrates how information flows through mgmt’s graph, it isn’t exactly a realistic use case. It is unlikely that you will need a function to change a resource every second, so let’s use a more practical example of applying a desired state with functional reactive programming.
The Story
The story: You are a kind and collaborative systems operator who would like users to be able to choose their own shell without needing to file a ticket or ask for help. To solve this, you would like users to be able to set their own shells and ensure those shells are available. After talking to your users, you learn that the users who want a different shell also maintain their own shell configuration files.
The idea: What if a machine automatically configures a user’s shell based on the presence of the shell’s config file? It would be useful if this change were applied as soon as a user creates their shell config file.
In mgmt, we can do this, and this guide has prepared us for this challenge! What tools do we need?
We need to:
- Observe: Detect a file’s presence in a user’s home directory.
- Change: Configure the user’s login shell on the machine.
- Change: Ensure the shell package is installed.
Recall that an mgmt resource can apply changes, while functions only observe or compute and cannot make changes. This means we’ll want a function for the file observation and resources for managing the user and package.
The Implementation
We can implement our solution completely in mcl and run it with mgmt:
# Make the os.file_exists() function available.
import "os"
$home = "/home"
$user = "dev"
# If this user creates a ~/.zshrc, set their shell to zsh.
$shell = if os.file_exists("${home}/${user}/.zshrc") {
"zsh"
} else {
"bash"
}
user [$user] {
state => "exists",
homedir => "${home}/${user}/",
# The user's shell now depends on the value of $shell
# which depends on the presence of a .zshrc file.
shell => "/bin/${shell}",
}
# Ensure the user's shell is installed.
# This assumes the shell executable is the same as the package name.
pkg [$shell] {
state => "installed",
# Ensure the package is applied
# before we try to modify the user's shell.
Before => User[$user],
}
This example demonstrates two very powerful capabilities of mgmt:
Your desired state is dynamic and computed, not static! The actual resource graph will be different depending on the presence of that
.zshrcfile. More specifically, the user’s shell and package installation depend on the value of$shell, which is based on the presence or absence of a.zshrcfile in the user’s home directory.Because mgmt is a reactive programming system, the
os.file_exists()function watches for changes and produces a new value when the file is created or deleted. This new value causes mgmt to recompute the desired state!
The Result
Here’s what happens when we run this:
First, no changes are needed
For this walkthrough, assume that the dev user already exists with a home
directory, uses bash, and has no .zshrc. If the user does not exist, the first
convergence creates it and your initial output will differ. With these
prerequisites in place, mgmt computes our desired state, observes that the
current state matches it, and doesn’t make any changes.
Second, create a .zshrc
If this user creates a .zshrc file, for example by running
sudo -u dev touch /home/dev/.zshrc, mgmt springs into action! Our
os.file_exists() function produces a new value, and mgmt computes and applies
a new graph for the new desired state:
16:16:40 gapi: generating new graph...
... (other output omitted for brevity) ...
16:16:40 engine: pkg[zsh]: Check: pkg[zsh]
16:16:40 engine: pkg[zsh]: Apply: pkg[zsh]
16:16:40 engine: pkg[zsh]: Set(installed): pkg[zsh]...
16:16:47 engine: pkg[zsh]: Set(installed) success: pkg[zsh]
16:16:47 engine: pkg[zsh]: Check: pkg[zsh]
16:16:47 engine: user[dev]: modifying user: dev
And the user’s shell is now zsh:
$ getent passwd dev | awk -F: '{print $NF}'
/bin/zsh
Third, remove the .zshrc
If the user deletes their .zshrc with
sudo -u dev rm /home/dev/.zshrc, mgmt should set the shell back to bash:
16:40:42 gapi: generating new graph...
...
16:40:44 engine: pkg[bash]: Check: pkg[bash]
16:40:44 engine: user[dev]: modifying user: dev
And we can check that bash is now the user’s shell:
$ getent passwd dev | awk -F: '{print $NF}'
/bin/bash
Further Reading
Congratulations! You should now be able to apply these lessons when using mgmt on your own systems. This guide is only the beginning. There are capabilities and details beyond the scope of this document that you will find useful as your mgmt expertise grows:
- Reusable code with classes and modules.
- Sending values from one resource to another.
- Cooperation between many mgmt instances, including exporting resources, passing values around, and deploying mcl to a fleet.
- Internal mgmt resources to handle DHCP requests or serve content over HTTP and TFTP.
- More detail on programming in mgmt:
- mcl language syntax and features
- values and types in mcl, including writing functions in mcl.