By Gabriel Radanne - 2016-02-29
For the last few months, I've been working with Thomas on improving the mirage
tool and
I'm happy to present Functoria, a library to create arbitrary MirageOS-like DSLs. Functoria is independent from mirage
and will replace the core engine, which was somewhat bolted on to the tool until now.
This introduces a few breaking changes so please consult the breaking changes page to see what is different and how to fix things if needed. The good news is that it will be much more simple to use, much more flexible, and will even produce pretty pictures!
For people unfamiliar with MirageOS, the mirage
tool handles configuration of mirage unikernels by reading an OCaml file describing the various pieces and dependencies of the project.
Based on this configuration it will use opam to install the dependencies, handle various configuration tasks and emit a build script.
A very simple configuration file looks like this:
open Mirage
let main = foreign "Unikernel.Main" (console @-> job)
let () = register "console" [main $ default_console]
It declares a new functor, Unikernel.Main
, which take a console as an argument and instantiates it on the default_console
. For more details about unikernel configuration, please read the hello-world tutorial.
A much demanded feature has been the ability to define so-called bootvars. Bootvars are variables whose value is set either at configure time or at startup time.
A good example of a bootvar would be the IP address of the HTTP stack. For example, you may wish to:
config.ml
All of this is now possible using keys. A key is composed of:
Imagine we are building a multilingual unikernel and we want to pass the
default language as a parameter. The language parameter is an optional string, so we use the opt
and string
combinators. We want to be able to define it both
at configure and run time, so we use the stage `Both
. This gives us the following code:
let lang_key =
let doc = Key.Arg.info
~doc:"The default language for the unikernel." [ "l" ; "lang" ]
in
Key.(create "language" Arg.(opt ~stage:`Both string "en" doc))
Here, we defined both a long option --lang
, and a short one -l
, (the format is similar to the one used by Cmdliner).
In the unikernel, the value is retrieved with Key_gen.language ()
.
The option is also documented in the --help
option for both mirage configure
(at configure time) and ./my_unikernel
(at startup time).
-l VAL, --lang=VAL (absent=en)
The default language for the unikernel.
A simple example of a unikernel with a key is available in mirage-skeleton in the hello
directory.
We can do much more with keys, for example we can use them to switch devices at configure time. To illustrate, let us take the example of dynamic storage, where we want to choose between a block device and a crunch device with a command line option. In order to do that, we must first define a boolean key:
let fat_key =
let doc = Key.Arg.info
~doc:"Use a fat device if true, crunch otherwise." [ "fat" ]
in
Key.(create "fat" Arg.(opt ~stage:`Configure bool false doc))
We can use the if_impl
combinator to choose between two devices depending on the value of the key.
let dynamic_storage =
if_impl (Key.value fat_key)
(kv_ro_of_fs my_fat_device)
(my_crunch_device)
We can now use this device as a normal storage device of type kv_ro impl
! The key is also documented in mirage configure --help
:
--fat=VAL (absent=false)
Use a fat device if true, crunch otherwise.
It is also possible to compute on keys before giving them to if_impl
, combining multiple keys in order to compute a value, and so on. For more details, see the API and the various examples available in mirage and mirage-skeleton.
Switching keys opens various possibilities, for example a generic_stack
combinator is now implemented in mirage
that will switch between socket stack, direct stack with DHCP, and direct stack with static IP, depending on command line arguments.
All these keys and dynamic implementations make for complicated unikernels. In order to clarify what is going on and help to configure our unikernels, we have a new command: describe
.
Let us consider the console
example in mirage-skeleton:
open Mirage
let main = foreign "Unikernel.Main" (console @-> job)
let () = register "console" [main $ default_console]
This is fairly straightforward: we define a Unikernel.Main
functor using a console and we
instantiate it with the default console. If we execute mirage describe --dot
in this directory, we will get the following output.
As you can see, there are already quite a few things going on!
Rectangles are the various devices and you'll notice that
the default_console
is actually two consoles: the one on Unix and the one on Xen. We use the if_impl
construction — represented as a circular node — to choose between the two during configuration.
The key
device handles the runtime key handling. It relies on an argv
device, which is similar to console
. Those devices are present in all unikernels.
The mirage
device is the device that brings all the jobs together (and on the hypervisor binds them).
You may have noticed dashed lines in the previous diagram, in particular from mirage
to Unikernel.Main
. Those lines are data dependencies. For example, the bootvar
device has a dependency on the argv
device. It means that argv
is configured and run first, returns some data — an array of string — then bootvar
is configured and run.
If your unikernel has a data dependency — say, initializing the entropy — you can use the ~deps
argument on Mirage.foreign
. The start
function of the unikernel will receive one extra argument for each dependency.
As an example, let us look at the app_info
device. This device makes the configuration information available at runtime. We can declare a dependency on it:
let main =
foreign "Unikernel.Main" ~deps:[abstract app_info] (console @-> job)
The only difference with the previous unikernel is the data dependency — represented by a dashed arrow — going from Unikernel.Main
to Info_gen
. This means that Unikernel.Main.start
will take an extra argument of type Mirage_info.t
which we can, for example, print:
name: console
libraries: [functoria.runtime; lwt.syntax; mirage-console.unix;
mirage-types.lwt; mirage.runtime; sexplib]
packages: [functoria.0.1; lwt.2.5.0; mirage-console.2.1.3; mirage-unix.2.3.1;
sexplib.113.00.00]
The complete example is available in mirage-skeleton in the app_info
directory.
Since we have a way to draw unikernels, we can now observe the sharing between various pieces. For example, the direct stack with static IP yields this diagram:
You can see that all the sub-parts of the stack have been properly shared. To be merged, two devices must have the same name, keys, dependencies and functor arguments. To force non-sharing of two devices, it is enough to give them different names.
This sharing also works up to switching keys. The generic stack gives us this diagram:
If you look closely, you'll notice that there are actually three stacks in the last example: the socket stack, the direct stack with DHCP, and the direct stack with IP. All controlled by switching keys.
There is more to be said about the new capabilities offered by functoria, in particular on how to define new devices. You can discover them by looking at the mirage implementation.
However, to wrap up this blog post, I offer you a visualization of the MirageOS website itself (brace yourself). Enjoy!
Thanks to Mort, Mindy, Amir and Jeremy for their comments on earlier drafts.