479 lines
21 KiB
HTML
479 lines
21 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>
|
|
Robur's blogRuntime arguments in MirageOS
|
|
</title>
|
|
<meta name="description" content="The history of runtime arguments to a MirageOS unikernel">
|
|
<link type="text/css" rel="stylesheet" href="https://blog.robur.coop/css/hl.css">
|
|
<link type="text/css" rel="stylesheet" href="https://blog.robur.coop/css/style.css">
|
|
<script src="https://blog.robur.coop/js/hl.js"></script>
|
|
<link rel="alternate" type="application/rss+xml" href="https://blog.robur.coop/feed.xml" title="blog.robur.coop">
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>blog.robur.coop</h1>
|
|
<blockquote>
|
|
The <strong>Robur</strong> cooperative blog.
|
|
</blockquote>
|
|
</header>
|
|
<main><a href="https://blog.robur.coop/index.html">Back to index</a>
|
|
|
|
<article>
|
|
<h1>Runtime arguments in MirageOS</h1>
|
|
<ul class="tags-list"><li><a href="https://blog.robur.coop/tags.html#tag-OCaml">OCaml</a></li><li><a href="https://blog.robur.coop/tags.html#tag-MirageOS">MirageOS</a></li></ul><p>TL;DR: Passing runtime arguments around is tricky, and prone to change every other month.</p>
|
|
<h2 id="motivation"><a class="anchor" aria-hidden="true" href="#motivation"></a>Motivation</h2>
|
|
<p>Sometimes, as an unikernel developer and also as operator, it's nice to have
|
|
some runtime arguments passed to an unikernel. Now, if you're into OCaml,
|
|
command-line parsing - together with error messages, man page generation, ... -
|
|
can be done by the amazing <a href="https://erratique.ch/software/cmdliner">cmdliner</a>
|
|
package from Daniel Bünzli.</p>
|
|
<p>MirageOS uses cmdliner for command line argument passing. This also enabled
|
|
us from the early days to have nice man pages for unikernels (see
|
|
<code>my-unikernel-binary --help</code>). There are two kinds
|
|
of arguments: those at configuration time (<code>mirage configure</code>), such as the
|
|
target to compile for, and those at runtime - when the unikernel is executed.</p>
|
|
<p>In Mirage 4.8.1 and 4.8.0 (released October 2024) there have been some changes
|
|
to command-line arguments, which were motivated by 4.5.0 (released April 2024)
|
|
and user feedback.</p>
|
|
<p>First of all, our current way to pass a custom runtime argument to a unikernel
|
|
(<code>unikernel.ml</code>):</p>
|
|
<pre><code class="language-OCaml">open Lwt.Infix
|
|
open Cmdliner
|
|
|
|
let hello =
|
|
let doc = Arg.info ~doc:"How to say hello." [ "hello" ] in
|
|
let term = Arg.(value & opt string "Hello World!" doc) in
|
|
Mirage_runtime.register_arg term
|
|
|
|
module Hello (Time : Mirage_time.S) = struct
|
|
let start _time =
|
|
let rec loop = function
|
|
| 0 -> Lwt.return_unit
|
|
| n ->
|
|
Logs.info (fun f -> f "%s" (hello ()));
|
|
Time.sleep_ns (Duration.of_sec 1) >>= fun () -> loop (n - 1)
|
|
in
|
|
loop 4
|
|
end
|
|
</code></pre>
|
|
<p>We define the <a href="https://erratique.ch/software/cmdliner/doc/Cmdliner/Term/index.html#type-t">Cmdliner.Term.t</a>
|
|
in line 6 (<code>let term = ..</code>) - which provides documentation ("How to say hello."), the option to
|
|
use (<code>["hello"]</code> - which is then translated to <code>--hello=</code>), that it is optional,
|
|
of type <code>string</code> (cmdliner allows you to convert the incoming strings to more
|
|
complex (or more narrow) data types, with decent error handling).</p>
|
|
<p>The defined argument is directly passed to <a href="https://ocaml.org/p/mirage-runtime/4.8.1/doc/Mirage_runtime/index.html#val-register_arg"><code>Mirage_runtime.register_arg</code></a>,
|
|
(in line 7) so our binding <code>hello</code> is of type <code>unit -> string</code>.
|
|
In line 14, the value of the runtime argument is used (<code>hello ()</code>) for printing
|
|
a log message.</p>
|
|
<p>The nice property is that it is all local in <code>unikernel.ml</code>, there are no other
|
|
parts involved. It is just a bunch of API calls. The downside is that <code>hello ()</code>
|
|
should only be evaluated after the function <code>start</code> was called - since the
|
|
<code>Mirage_runtime</code> needs to parse and fill in the command line arguments. If you
|
|
call <code>hello ()</code> earlier, you'll get an exception "Called too early. Please delay
|
|
this call to after the start function of the unikernel.". Also, since
|
|
Mirage_runtime needs to collect and evaluate the command line arguments, the
|
|
<code>Mirage_runtime.register_arg</code> may only be called at top-level, otherwise you'll
|
|
get another exception "The function register_arg was called to late. Please call
|
|
register_arg before the start function is executed (e.g. in a top-level binding).".</p>
|
|
<p>Another advantage is, having it all in unikernel.ml means adding and removing
|
|
arguments doesn't need another execution of <code>mirage configure</code>. Also, any
|
|
type can be used that the unikernel depends on - the config.ml is compiled only
|
|
with a small set of dependencies (mirage itself) - and we don't want to impose a
|
|
large dependency cone for mirage just because someone may like to use
|
|
X509.Key_type.t as argument type.</p>
|
|
<p>Earlier, before mirage 4.5.0, we had runtime and configure arguments mixed
|
|
together. And code was generated when <code>mirage configure</code> was executed to
|
|
deal with these arguments. The downsides included: we needed serialization for
|
|
all command-line arguments (at configure time you could fill the argument, which
|
|
was then serialized, and deserialized at runtime and used unless the argument
|
|
was provided explicitly), they had to appear in <code>config.ml</code> (which also means
|
|
changing any would need an execution of <code>mirage configure</code>), since they generated code
|
|
potential errors were in code that the developer didn't write (though we had
|
|
some <code>__POS__</code> arguments to provide error locations in the developer code).</p>
|
|
<p>Related recent changes are:</p>
|
|
<ul>
|
|
<li>in mirage 4.8.1, the runtime arguments to configure the OCaml runtime system
|
|
(such as GC settings, randomization of hashtables, recording of backtraces)
|
|
are now provided using the <a href="https://ocaml.org/p/cmdliner-stdlib">cmdliner-stdlib</a>
|
|
package.</li>
|
|
<li>in mirage 4.8.0, for git, dns-client, and happy-eyeballs devices the optional
|
|
arguments are generated by default - so they are always available and don't
|
|
need to be manually done by the unikernel developer.</li>
|
|
</ul>
|
|
<p>Let's dive a bit deeper into the history.</p>
|
|
<h2 id="history"><a class="anchor" aria-hidden="true" href="#history"></a>History</h2>
|
|
<p>In MirageOS, since the early stages (I'll go back to 2.7.0 (February 2016) where
|
|
functoria was introduced) used an embedded fork of <code>cmdliner</code> to handle command
|
|
line arguments.</p>
|
|
<p><a href="https://asciinema.org/a/ruHoadi2oZGOzgzMKk5ZYoFgf"><img src="https://asciinema.org/a/ruHoadi2oZGOzgzMKk5ZYoFgf.svg" alt="Animated changes to the hello world unikernel" ></a></p>
|
|
<h3 id="february-2016-mirage-270"><a class="anchor" aria-hidden="true" href="#february-2016-mirage-270"></a>February 2016 (Mirage 2.7.0)</h3>
|
|
<p>When looking into the MirageOS 2.x series, here's the code for our hello world
|
|
unikernel:</p>
|
|
<p><code>config.ml</code></p>
|
|
<pre><code class="language-OCaml">open Mirage
|
|
|
|
let hello =
|
|
let doc = Key.Arg.info ~doc:"How to say hello." ["hello"] in
|
|
Key.(create "hello" Arg.(opt string "Hello World!" doc))
|
|
|
|
let main =
|
|
foreign
|
|
~keys:[Key.abstract hello]
|
|
"Unikernel.Hello" (console @-> job)
|
|
|
|
let () = register "hello-key" [main $ default_console]
|
|
</code></pre>
|
|
<p>and <code>unikernel.ml</code></p>
|
|
<pre><code class="language-OCaml">open Lwt.Infix
|
|
|
|
module Hello (C: V1_LWT.CONSOLE) = struct
|
|
let start c =
|
|
let rec loop = function
|
|
| 0 -> Lwt.return_unit
|
|
| n ->
|
|
C.log c (Key_gen.hello ());
|
|
OS.Time.sleep 1.0 >>= fun () ->
|
|
loop (n-1)
|
|
in
|
|
loop 4
|
|
end
|
|
</code></pre>
|
|
<p>As you can see, the cmdliner term was provided in <code>config.ml</code>, and in
|
|
<code>unikernel.ml</code> the expression <code>Key_gen.hello ()</code> was used - <code>Key_gen</code> was
|
|
a module generated by the <code>mirage configure</code> invocation.</p>
|
|
<p>You can as well see that the term was wrapped in <code>Key.create "hello"</code> - where
|
|
this string was used as the identifier for the code generation.</p>
|
|
<p>As mentioned above, a change needed to be done in <code>config.ml</code> and a
|
|
<code>mirage configure</code> to take effect.</p>
|
|
<h3 id="july-2016-mirage-291"><a class="anchor" aria-hidden="true" href="#july-2016-mirage-291"></a>July 2016 (Mirage 2.9.1)</h3>
|
|
<p>The <code>OS.Time</code> was functorized with a <code>Time</code> functor:</p>
|
|
<p><code>config.ml</code></p>
|
|
<pre><code class="language-OCaml">open Mirage
|
|
|
|
let hello =
|
|
let doc = Key.Arg.info ~doc:"How to say hello." ["hello"] in
|
|
Key.(create "hello" Arg.(opt string "Hello World!" doc))
|
|
|
|
let main =
|
|
foreign
|
|
~keys:[Key.abstract hello]
|
|
"Unikernel.Hello" (console @-> time @-> job)
|
|
|
|
let () = register "hello-key" [main $ default_console $ default_time]
|
|
</code></pre>
|
|
<p>and <code>unikernel.ml</code></p>
|
|
<pre><code class="language-OCaml">open Lwt.Infix
|
|
|
|
module Hello (C: V1_LWT.CONSOLE) (Time : V1_LWT.TIME) = struct
|
|
let start c _time =
|
|
let rec loop = function
|
|
| 0 -> Lwt.return_unit
|
|
| n ->
|
|
C.log c (Key_gen.hello ());
|
|
Time.sleep 1.0 >>= fun () ->
|
|
loop (n-1)
|
|
in
|
|
loop 4
|
|
end
|
|
</code></pre>
|
|
<h3 id="february-2017-mirage-pre3"><a class="anchor" aria-hidden="true" href="#february-2017-mirage-pre3"></a>February 2017 (Mirage pre3)</h3>
|
|
<p>The <code>Time</code> signature changed, now the <code>sleep_ns</code> function sleeps in nanoseconds.
|
|
This avoids floating point numbers at the core of MirageOS. The helper package
|
|
<code>duration</code> is used to avoid manual conversions.</p>
|
|
<p>Also, the console signature changed - and <code>log</code> is now inside the Lwt monad.</p>
|
|
<p><code>config.ml</code></p>
|
|
<pre><code class="language-OCaml">open Mirage
|
|
|
|
let hello =
|
|
let doc = Key.Arg.info ~doc:"How to say hello." ["hello"] in
|
|
Key.(create "hello" Arg.(opt string "Hello World!" doc))
|
|
|
|
let main =
|
|
foreign
|
|
~keys:[Key.abstract hello]
|
|
~packages:[package "duration"]
|
|
"Unikernel.Hello" (console @-> time @-> job)
|
|
|
|
let () = register "hello-key" [main $ default_console $ default_time]
|
|
</code></pre>
|
|
<p>and <code>unikernel.ml</code></p>
|
|
<pre><code class="language-OCaml">open Lwt.Infix
|
|
|
|
module Hello (C: V1_LWT.CONSOLE) (Time : V1_LWT.TIME) = struct
|
|
let start c _time =
|
|
let rec loop = function
|
|
| 0 -> Lwt.return_unit
|
|
| n ->
|
|
C.log c (Key_gen.hello ()) >>= fun () ->
|
|
Time.sleep_ns (Duration.of_sec 1) >>= fun () ->
|
|
loop (n-1)
|
|
in
|
|
loop 4
|
|
end
|
|
</code></pre>
|
|
<h3 id="february-2017-mirage-3"><a class="anchor" aria-hidden="true" href="#february-2017-mirage-3"></a>February 2017 (Mirage 3)</h3>
|
|
<p>Another big change is that now console is not used anymore, but
|
|
<a href="https://erratique.ch/software/logs">logs</a>.</p>
|
|
<p><code>config.ml</code></p>
|
|
<pre><code class="language-OCaml">open Mirage
|
|
|
|
let hello =
|
|
let doc = Key.Arg.info ~doc:"How to say hello." ["hello"] in
|
|
Key.(create "hello" Arg.(opt string "Hello World!" doc))
|
|
|
|
let main =
|
|
foreign
|
|
~keys:[Key.abstract hello]
|
|
~packages:[package "duration"]
|
|
"Unikernel.Hello" (time @-> job)
|
|
|
|
let () = register "hello-key" [main $ default_time]
|
|
</code></pre>
|
|
<p>and <code>unikernel.ml</code></p>
|
|
<pre><code class="language-OCaml">open Lwt.Infix
|
|
|
|
module Hello (Time : Mirage_time_lwt.S) = struct
|
|
let start _time =
|
|
let rec loop = function
|
|
| 0 -> Lwt.return_unit
|
|
| n ->
|
|
Logs.info (fun f -> f "%s" (Key_gen.hello ()));
|
|
Time.sleep_ns (Duration.of_sec 1) >>= fun () ->
|
|
loop (n-1)
|
|
in
|
|
loop 4
|
|
end
|
|
</code></pre>
|
|
<h3 id="january-2020-mirage-370"><a class="anchor" aria-hidden="true" href="#january-2020-mirage-370"></a>January 2020 (Mirage 3.7.0)</h3>
|
|
<p>The <code>_lwt</code> is dropped from the interfaces (we used to have Mirage_time and
|
|
Mirage_time_lwt - where the latter was instantiating the former with concrete
|
|
types: <code>type 'a io = Lwt.t</code> and <code>type buffer = Cstruct.t</code> -- in a cleanup
|
|
session we dropped the <code>_lwt</code> interfaces and opam packages. The reasoning was
|
|
that when we'll get around to move to another IO system, we'll move everything
|
|
at once anyways. No need to have <code>lwt</code> and something else (<code>async</code>, or nowadays
|
|
<code>miou</code> or <code>eio</code>) in a single unikernel.</p>
|
|
<p><code>config.ml</code></p>
|
|
<pre><code class="language-OCaml">open Mirage
|
|
|
|
let hello =
|
|
let doc = Key.Arg.info ~doc:"How to say hello." ["hello"] in
|
|
Key.(create "hello" Arg.(opt string "Hello World!" doc))
|
|
|
|
let main =
|
|
foreign
|
|
~keys:[Key.abstract hello]
|
|
~packages:[package "duration"]
|
|
"Unikernel.Hello" (time @-> job)
|
|
|
|
let () = register "hello-key" [main $ default_time]
|
|
</code></pre>
|
|
<p>and <code>unikernel.ml</code></p>
|
|
<pre><code class="language-OCaml">open Lwt.Infix
|
|
|
|
module Hello (Time : Mirage_time.S) = struct
|
|
let start _time =
|
|
let rec loop = function
|
|
| 0 -> Lwt.return_unit
|
|
| n ->
|
|
Logs.info (fun f -> f "%s" (Key_gen.hello ()));
|
|
Time.sleep_ns (Duration.of_sec 1) >>= fun () ->
|
|
loop (n-1)
|
|
in
|
|
loop 4
|
|
end
|
|
</code></pre>
|
|
<h3 id="october-2021-mirage-310"><a class="anchor" aria-hidden="true" href="#october-2021-mirage-310"></a>October 2021 (Mirage 3.10)</h3>
|
|
<p>Some renamings to fix warnings. Only <code>config.ml</code> changed.</p>
|
|
<p><code>config.ml</code></p>
|
|
<pre><code class="language-OCaml">open Mirage
|
|
|
|
let hello =
|
|
let doc = Key.Arg.info ~doc:"How to say hello." ["hello"] in
|
|
Key.(create "hello" Arg.(opt string "Hello World!" doc))
|
|
|
|
let main =
|
|
main
|
|
~keys:[key hello]
|
|
~packages:[package "duration"]
|
|
"Unikernel.Hello" (time @-> job)
|
|
|
|
let () = register "hello-key" [main $ default_time]
|
|
</code></pre>
|
|
<p>and <code>unikernel.ml</code></p>
|
|
<pre><code class="language-OCaml">open Lwt.Infix
|
|
|
|
module Hello (Time : Mirage_time.S) = struct
|
|
let start _time =
|
|
let rec loop = function
|
|
| 0 -> Lwt.return_unit
|
|
| n ->
|
|
Logs.info (fun f -> f "%s" (Key_gen.hello ()));
|
|
Time.sleep_ns (Duration.of_sec 1) >>= fun () ->
|
|
loop (n-1)
|
|
in
|
|
loop 4
|
|
end
|
|
</code></pre>
|
|
<h3 id="june-2023-mirage-44"><a class="anchor" aria-hidden="true" href="#june-2023-mirage-44"></a>June 2023 (Mirage 4.4)</h3>
|
|
<p>The argument was moved to runtime.</p>
|
|
<p><code>config.ml</code></p>
|
|
<pre><code class="language-OCaml">open Mirage
|
|
|
|
let hello =
|
|
let doc = Key.Arg.info ~doc:"How to say hello." ["hello"] in
|
|
Key.(create "hello" Arg.(opt ~stage:`Run string "Hello World!" doc))
|
|
|
|
let main =
|
|
main
|
|
~keys:[key hello]
|
|
~packages:[package "duration"]
|
|
"Unikernel.Hello" (time @-> job)
|
|
|
|
let () = register "hello-key" [main $ default_time]
|
|
</code></pre>
|
|
<p>and <code>unikernel.ml</code></p>
|
|
<pre><code class="language-OCaml">open Lwt.Infix
|
|
|
|
module Hello (Time : Mirage_time.S) = struct
|
|
let start _time =
|
|
let rec loop = function
|
|
| 0 -> Lwt.return_unit
|
|
| n ->
|
|
Logs.info (fun f -> f "%s" (Key_gen.hello ());
|
|
Time.sleep_ns (Duration.of_sec 1) >>= fun () ->
|
|
loop (n-1)
|
|
in
|
|
loop 4
|
|
end
|
|
</code></pre>
|
|
<h3 id="march-2024-mirage-45"><a class="anchor" aria-hidden="true" href="#march-2024-mirage-45"></a>March 2024 (Mirage 4.5)</h3>
|
|
<p>The runtime argument is in <code>config.ml</code> refering to the argument as string
|
|
("Unikernel.hello"), and being passed to the <code>start</code> function as argument.</p>
|
|
<p><code>config.ml</code></p>
|
|
<pre><code class="language-OCaml">open Mirage
|
|
|
|
let runtime_args = [ runtime_arg ~pos:__POS__ "Unikernel.hello" ]
|
|
|
|
let main =
|
|
main
|
|
~runtime_args
|
|
~packages:[package "duration"]
|
|
"Unikernel.Hello" (time @-> job)
|
|
|
|
let () = register "hello-key" [main $ default_time]
|
|
</code></pre>
|
|
<p>and <code>unikernel.ml</code></p>
|
|
<pre><code class="language-OCaml">open Lwt.Infix
|
|
open Cmdliner
|
|
|
|
let hello =
|
|
let doc = Arg.info ~doc:"How to say hello." [ "hello" ] in
|
|
Arg.(value & opt string "Hello World!" doc)
|
|
|
|
module Hello (Time : Mirage_time.S) = struct
|
|
let start _time hello =
|
|
let rec loop = function
|
|
| 0 -> Lwt.return_unit
|
|
| n ->
|
|
Logs.info (fun f -> f "%s" hello);
|
|
Time.sleep_ns (Duration.of_sec 1) >>= fun () ->
|
|
loop (n-1)
|
|
in
|
|
loop 4
|
|
end
|
|
</code></pre>
|
|
<h3 id="october-2024-mirage-48"><a class="anchor" aria-hidden="true" href="#october-2024-mirage-48"></a>October 2024 (Mirage 4.8)</h3>
|
|
<p>Again, moved out of <code>config.ml</code>.</p>
|
|
<p><code>config.ml</code></p>
|
|
<pre><code class="language-OCaml">open Mirage
|
|
|
|
let main =
|
|
main
|
|
~packages:[package "duration"]
|
|
"Unikernel.Hello" (time @-> job)
|
|
|
|
let () = register "hello-key" [main $ default_time]
|
|
</code></pre>
|
|
<p>and <code>unikernel.ml</code></p>
|
|
<pre><code class="language-OCaml">open Lwt.Infix
|
|
open Cmdliner
|
|
|
|
let hello =
|
|
let doc = Arg.info ~doc:"How to say hello." [ "hello" ] in
|
|
Mirage_runtime.register_arg Arg.(value & opt string "Hello World!" doc)
|
|
|
|
module Hello (Time : Mirage_time.S) = struct
|
|
let start _time =
|
|
let rec loop = function
|
|
| 0 -> Lwt.return_unit
|
|
| n ->
|
|
Logs.info (fun f -> f "%s" (hello ()));
|
|
Time.sleep_ns (Duration.of_sec 1) >>= fun () ->
|
|
loop (n-1)
|
|
in
|
|
loop 4
|
|
end
|
|
</code></pre>
|
|
<h3 id="2024-not-yet-released"><a class="anchor" aria-hidden="true" href="#2024-not-yet-released"></a>2024 (Not yet released)</h3>
|
|
<p>This is the future with time defunctorized. Read more in the <a href="https://github.com/mirage/mirage/issues/1513">discussion</a>.
|
|
To delay the start function, a <code>dep</code> of <code>noop</code> is introduced.</p>
|
|
<p><code>config.ml</code></p>
|
|
<pre><code class="language-OCaml">open Mirage
|
|
|
|
let main =
|
|
main
|
|
~packages:[package "duration"]
|
|
~dep:[dep noop]
|
|
"Unikernel" job
|
|
|
|
let () = register "hello-key" [main]
|
|
</code></pre>
|
|
<p>and <code>unikernel.ml</code></p>
|
|
<pre><code class="language-OCaml">open Lwt.Infix
|
|
open Cmdliner
|
|
|
|
let hello =
|
|
let doc = Arg.info ~doc:"How to say hello." [ "hello" ] in
|
|
Mirage_runtime.register_arg Arg.(value & opt string "Hello World!" doc)
|
|
|
|
let start () =
|
|
let rec loop = function
|
|
| 0 -> Lwt.return_unit
|
|
| n ->
|
|
Logs.info (fun f -> f "%s" (hello ()));
|
|
Mirage_timer.sleep_ns (Duration.of_sec 1) >>= fun () ->
|
|
loop (n-1)
|
|
in
|
|
loop 4
|
|
</code></pre>
|
|
<h2 id="conclusion"><a class="anchor" aria-hidden="true" href="#conclusion"></a>Conclusion</h2>
|
|
<p>The history of hello world shows that over time we slowly improve the developer
|
|
experience, and removing the boilerplate needed to get MirageOS unikernels up
|
|
and running. This is work over a decade including lots of other (here invisible)
|
|
improvements to the mirage utility.</p>
|
|
<p>Our current goal is to minimize the code generated by mirage, since code
|
|
generation has lots of issues (e.g. error locations, naming, binary size). It
|
|
is a long journey. At the same time, we are working on improving the performance
|
|
of MirageOS unikernels, developing unikernels that are useful in the real
|
|
world (<a href="https://github.com/robur-coop/miragevpn">VPN endpoint</a>, <a href="https://github.com/robur-coop/dnsvizor">DNSmasq replacement</a>, ...), and also <a href="https://github.com/robur-coop/mollymawk">simplifying the
|
|
deployment of MirageOS unikernels</a>.</p>
|
|
<p>If you're interested in MirageOS and using it in your domain, don't hesitate
|
|
to reach out to us (via eMail: team@robur.coop) - we're keen to deploy MirageOS
|
|
and find more domains where it is useful. If you can spare a dime, we're a
|
|
registered non-profit in Germany - and can provide tax-deductable receipts for
|
|
donations (<a href="https://robur.coop/Donate">more information</a>).</p>
|
|
|
|
</article>
|
|
|
|
</main>
|
|
<footer>
|
|
<a href="https://github.com/xhtmlboi/yocaml">Powered by <strong>YOCaml</strong></a>
|
|
<br />
|
|
</footer>
|
|
<script>hljs.highlightAll();</script>
|
|
</body>
|
|
</html>
|