Overview

Mistletoe is a package manager for Kubernetes. In fact, usage is pretty similar to other package managers like Helm.

Where Mistletoe differs is the packages themselves -- instead of a templating engine like Helm or manifest processor like Kustomize, this package manager is built around a WebAssembly runtime, and a rather simple one at that.

Ultimately, almost all existing package management solutions run on a workflow of "string-in-string-out". They take some configuration string in YAML or another format, pass it into the package, and the package returns Kubernetes resource manifest strings. The focus of Mistletoe is to open this workflow up to the maximum possible extent.

What does it look like?

Let's take a quick look at what a package looks like and what it looks like when you go to run it. To start, here's a very simple package written in Rust:

#![allow(unused)]
fn main() {
use mistletoe_api::v1alpha1::{MistResult, MistOutput};
use mistletoe_bind::mistletoe_package;

use indoc::formatdoc;
use serde::Deserialize;

mistletoe_package! {"
  name: namespace-example
  labels:
    mistletoe.dev/group: mistletoe-examples
"}

#[derive(Deserialize)]
pub struct Inputs {
    name: String,
}

pub fn generate(inputs: Inputs) -> MistResult {
    let name = inputs.name;

    let output = MistOutput::new()
        .with_file("namespace.yaml".to_string(), formatdoc!("
            apiVersion: v1
            kind: Namespace
            metadata:
              name: {name}
        "));

    Ok(output)
}
}

We'll discuss and expand the above example in the Rust section, but for now we'll just talk about what it does.

It takes the name parameter that is passed in by the engine, and creates a Namespace with that name. Generating the YAML output of this package is done with the mistctl generate command:

mistctl generate my-namespace -p mistletoe/examples/namespace-example:0.1.2

That will output:

apiVersion: v1
kind: Namespace
metadata:
  name: my-namespace

And that's the core workflow of Mistletoe! We're just scratching the surface of what's possible, so read on in the chapters ahead for further usage, development guides, and hopefully inspiration for ideas.

Disclaimer on the status of the project

Work has only just begun on Mistletoe. While the core loop is done and necessary features are being rapidly added, this is NOT near recommended for use, not even on the cautious level of "beta".

That said: input, ideas, and constructive criticism are very welcome at the formative stages of this project -- I want to give it the best life possible.

More info on the roadmap can be found at the bottom of the main site.

Mistletoe for users

This section is still very in-flux, mainly because the tool itself is very in-flux. The following may be subject to abrupt change, but we'll keep this book in sync of any changes.

As of now, there are really just two useful commands, inspect and generate:

> mistctl --help
Polyglot Kubernetes package manager

Usage: mistctl [COMMAND]

Commands:
  generate  Generate output YAML from a package
  inspect   Inspects the info exported by a package
  help      Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help

inspect

inspect calls the info function from the Mistletoe package and then exits:

> mistctl inspect --help
Inspects the info exported by a package

Usage: mistctl inspect [package]

Arguments:
  [package]  the package to inspect

Options:
  -h, --help  Print help

For now, it currently surfaces the raw YAML MistPackage value:

> mistctl inspect mistletoe/examples/namespace-example:0.1.2
apiVersion: mistletoe.dev/v1alpha1
kind: MistPackage
metadata:
  name: namespace-example
  labels:
    mistletoe.dev/group: mistletoe-examples

generate

This function actually calls the package and generates the output YAML -- note that this does not install it on your cluster.

> mistctl generate --help
Generate output YAML from a package

Usage: mistctl generate [OPTIONS] --package <PACKAGE> <name>

Arguments:
  <name>  the name of the installation

Options:
  -p, --package <PACKAGE>  package to call
  -f, --inputfile <FILE>   input file containing values to pass to the package
  -s, --set <VALUES>       set values to pass to the package
  -o, --output <TYPE>      output type, can be 'yaml', 'raw', or 'dir=<dirpath>'
  -h, --help               Print hel

The only required parameters are the name and --package. For instance, this is all our "namespace-example" package needs:

> mistctl generate my-namespace -p mistletoe/examples/namespace-example:0.1.2
apiVersion: v1
kind: Namespace
metadata:
  name: my-namespace

Additional input can be passed in with --inputfile and --set. inputfile is just a path to a YAML file of inputs you wish to pass into the package:

> cat inputs.yaml
namespace: my-namespace
// ...

> mistctl generate my-nginx -p mistletoe/fake-nginx-example -f inputs.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-nginx
  namespace: my-namespace
// ...

You can also control the output format with --output. This can be either raw, yaml, or dir=<output_dir>, with yaml being the default.

raw outputs the raw response from the package:

> mistctl generate my-namespace -p mistletoe/examples/namespace-example:0.1.2 --output raw
apiVersion: mistletoe.dev/v1alpha1
kind: MistResult
data:
  result: Ok
  files:
    namespace.yaml: |
      apiVersion: v1
      kind: Namespace
      metadata:
        name: my-namespace

yaml outputs the processed contents of the files:

> mistctl generate my-namespace -p mistletoe/examples/namespace-example:0.1.2 --output yaml
apiVersion: v1
kind: Namespace
metadata:
  name: my-namespace

And dir outputs the contents of the file into the filetree as specified by the package:

> mistctl generate my-namespace -p mistletoe/examples/namespace-example:0.1.2 --output dir=out
> find ./out
./namespace.yaml 

Mistletoe for developers

The default (and currently sole) engine in Mistletoe is the WebAssembly runtime. While the WebAssembly interface is pretty simple, I first want to get into the higher-level aspects of it.

Specifically, Mistletoe uses the WebAssembly interface to pass around CRDs. Let's follow the full run cycle of a package to cover our bases here. At a high level, it looks like this:

  1. Mistletoe calls the "info" method inside the package to grab information about it and how to run it.
  2. Then Mistletoe call the "generate" method with an input string.
  3. The package returns an output string, Mistletoe does some post-processing on it, and sends it into the next step of the process.

It's pretty simple, and made simpler by the fact that the strings we're passing around are actually Kubernetes CRDs, so they should be a familiar format to anyone in the Kubernetes ecosystem. Let's see each one.

MistPackage

This is that "info" method I was talking about. It takes no parameters and returns something like the following YAML:

apiVersion: mistletoe.dev/v1alpha1
kind: MistPackage
metadata:
  name: nginx-example
  labels:
    mistletoe.dev/group: mistletoe-examples

Right now, it's just some metadata, aka a name and some optional labels. The labels can be whatever you want, but for now there is one special one, and that's mistletoe.dev/group. This could be the name of your organization or the name of a multi-package project -- it's mainly used for identifying it to the user. But notably, it's still optional.

There may be future versions of the resource with more fields containing configuration information, but for now you only need to consider the above.

MistInput

And then there's MistInput. This is what gets passed into your packages "generate" function, and can be practically anything:

apiVersion: mistletoe.dev/v1alpha1
kind: MistInput
data:
  name: my-nginx
  namespace: my-namespace

It only has a "data" field, and it's almost completely freeform. It's mostly a passthrough for the inputs passed in by the user. For instance, if you later decide that you want your users passing in a port and maybe some labels, you might write your application to accept the following input:

apiVersion: mistletoe.dev/v1alpha1
kind: MistInput
data:
  name: my-nginx
  namespace: my-namespace
  port: 3001
  labels:
    extra-labels: with-extra-values

Your user can pass in this input like so:

mistctl generate my-nginx -p ./my-nginx-package.mist-pack.wasm -f input.yaml

Where "input.yaml" might look like this:

namespace: my-namespace
port: 3001
labels:
  extra-labels: with-extra-values

Note that the "name" isn't specified here. That's because it's a special parameter -- the engine passes in the value of the installation name to that field.

It's strongly recommended that you don't fail on unexpected fields, because the engine or maybe even your package may expect more fields down the line.

MistResult

This is the string that your package returns, and it has two modes: Ok and Err. Let's see Err first:

apiVersion: mistletoe.dev/v1alpha1
kind: MistResult
data:
  result: Err
  message: 'something went wrong'

It's pretty straight-forward, and only contains two fields: "result" and "message". The "result" identifies this as an Err, and the required "message" is passed back to the end-user. If needed, the "message" can be multiple lines.

Let's be more optimistic and assume it ran successfully. Let's look at the output of our namespace example on the overview page:

apiVersion: mistletoe.dev/v1alpha1
kind: MistResult
data:
  result: Ok
  message: 'nothing went wrong' # This line is optional
  files:
    namespace.yaml: |
      apiVersion: v1
      kind: Namespace
      metadata:
        name: my-namespace

The easy ones to explain are "result" and "message". "result" must be set to Ok, and the "message" is still passed on up to the user, though note that the "message" is now optional given that we're succeeding.

The important part is the "files". This is a map from filename to multi-line string, where the multi-line string is Kubernetes YAML that should be output to the file.

The filename is a relative path, and supports directories -- you can change the above name to resources/namespace.yaml and it would write the contents to ./out/resources/namespace.yaml assuming your user passed in -o dir=./out to the calling command.

What next?

And those are the only inputs and outputs at this point in time! If you're looking to see how these strings are actually passed around, head on over to the WebAssembly interface section. If you just want to get started on writing a package, check out the Rust support that's currently offered.

The WebAssembly interface

The WebAssembly interface is pretty simple, and we've already covered the format of the strings getting passed around in Mistletoe for developers.

All this section will cover is how these strings are written into and pulled out of the runtime. So let's get to the gist and inspect a package:

wasmer inspect ./examples/namespace-example/pkg/mistletoe_namespace_example_bg.wasm
Type: wasm
Size: 1.4 MB
Imports:
  Functions:
  Memories:
  Tables:
  Globals:
Exports:
  Functions:
    "__mistletoe_info": [] -> [I32]
    "__mistletoe_alloc": [I32] -> [I32]
    "__mistletoe_dealloc": [I32, I32] -> []
    "__mistletoe_generate": [I32, I32] -> [I32]
  Memories:
    "memory": not shared (18 pages..)
  Tables:
  Globals

So we see we only have four functions, and they all work in the standard pointer-sized type for WebAssembly, I32. To start, I want to cover the __mistletoe_info function -- this is the "info" function from the previous section. But specifically, I want to recover its return type, which is a pointer to a fat pointer.

A fat pointer is a two-pointer-sized array in the form of [I32, I32]. The first I32 is a pointer to the start of the return string. The second I32 is the length of the string. All these pointers point around the memory exported by the package.

So when Mistletoe uses the "info" function, it performs the following steps:

  1. It calls __mistletoe_info for the pointer to our fat pointer. It then follows that pointer to a place in the memory.
  2. It uses the fat pointer at the memory location by looking up the string by the pointer and length in the fat pointer.

Allocation into the package

To ensure safe memory usage between the runtime and the package, we require that the package exports __mistletoe_alloc and __mistletoe_dealloc methods for the runtime to use cooperatively.

The workflow for this is pretty simple. Mistletoe calls the "alloc" function with the length of the string it wishes to allocate, and the "alloc" function returns a pointer to an area in the memory. Mistletoe then writes the string to the pointed location.

There's also the "dealloc" function that Mistletoe uses to clean up, which takes a pointer and length. At the end of the run, this gets called twice -- first to deallocate any strings passed into the package, and then to dellocate anything returned by the package.

The "generate" method

The real meat and potatoes is in the __mistletoe_generate function. This is the main entrypoint into your package's logic. To review, its signature looked like this:

[I32, I32] -> [I32]

The two parameters we pass in are the pointer and length of an input string we've allocated into the package. What gets returned is a fat pointer to the output of the method.

Mistletoe copies the output out of the runtime, and deallocates everything sent into and retrieved out of the package. And that was the last step -- the package has now run and the engine will process your output for use on the cluster.

For Rust developers

To make creating packages in Rust a little easier, we provide a mistletoe-bind crate that contains macros and libraries to provide higher-level abstractions of the underlying WebAssembly bindings.

On the Overview page we saw a quick Rust example:

#![allow(unused)]
fn main() {
use mistletoe_api::v1alpha1::{MistResult, MistOutput};
use mistletoe_bind::mistletoe_package;

use indoc::formatdoc;
use serde::Deserialize;

mistletoe_package! {"
  name: namespace-example
  labels:
    mistletoe.dev/group: mistletoe-examples
"}

#[derive(Deserialize)]
pub struct Inputs {
    name: String,
}

pub fn generate(inputs: Inputs) -> MistResult {
    let name = inputs.name;

    let output = MistOutput::new()
        .with_file("namespace.yaml".to_string(), formatdoc!("
            apiVersion: v1
            kind: Namespace
            metadata:
              name: {name}
        "));

    Ok(output)
}
}

But that's just the body of the file. Let's catch up to what the full project looks like. The structure only has two files:

/src/lib.rs
/Cargo.toml

The contents of Cargo.toml is:

[package]
name = "mistletoe-namespace-example"
version = "0.1.2"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
indoc = "2.0"
mistletoe-api = "0.1"
mistletoe-bind = "0.1"
serde = { version = "1.0", features = ["derive"] }
wasm-bindgen = "0.2"

For a base package, everything here is required aside from indoc, which we added to leverage the formatdoc!() macro in the above.

Breaking it down

Let's go top-to-bottom on the Rust source code and cover what each portion does. To start, there's a macro that generates headers for our package:

#![allow(unused)]
fn main() {
mistletoe_package! {"
  name: namespace-example
  labels:
    mistletoe.dev/group: mistletoe-examples
"}
}

The input of this macro is actually the metadata section of the MistPackage object our package will return, covered in Mistletoe for developers. The only required field here is the name of the package. You can also add labels containing additional information about the package. For now, you may want to add a mistletoe.dev/group label with the name of your project or organization.

This generates some binding functions that we'll cover later in the section, but for now the only thing we need to worry about is that it will look for a generate function defined by you.

Let's look at ours, as well as the input object we defined for it:

#![allow(unused)]
fn main() {
#[derive(Deserialize)]
pub struct Inputs {
    name: String,
}

pub fn generate(inputs: Inputs) -> MistResult {
    let name = inputs.name;

    let output = MistOutput::new()
        .with_file("namespace.yaml".to_string(), formatdoc!("
            apiVersion: v1
            kind: Namespace
            metadata:
              name: {name}
        "));

    Ok(output)
}
}

The important part here is the signature: (inputs: Inputs) -> MistResult

Essentially, you can specify a function that takes any parameter that implements Deserialize (and that includes String), and outputs our MistResult object.

The input we'll be receiving is an arbitrary YAML document, with at least name specified in it (and potentially more fields in the future). For example, maybe the user will pass you these values:

name: my-namespace
labels:
  app: my-app
  app.kubernetes.io/name: my-app
  app.kubernetes.io/instance: my-installation

To accommodate, you might expand your struct definition to:

#![allow(unused)]
fn main() {
#[derive(Deserialize)]
pub struct Inputs {
    name: String,
    #[serde(default)] // If we don't receive it, just represent it here as `None`
    labels: Option<BTreeMap<String, String>>,
}
}

And it would just work as you'd expect!

MistResult

Before we get sidetracked, let's look at the contents of that function from before:

#![allow(unused)]
fn main() {
pub fn generate(inputs: Inputs) -> MistResult {
    let name = inputs.name;

    let output = MistOutput::new()
        .with_file("namespace.yaml".to_string(), formatdoc!("
            apiVersion: v1
            kind: Namespace
            metadata:
              name: {name}
        "));

    Ok(output)
}
}

Aside from the handy formatdoc!() macro from the indoc crate, the only unexplained types here are MistResult and MistOutput. To start, let's look at the definition of MistResult:

#![allow(unused)]
fn main() {
type MistResult = anyhow::Result<MistOutput>
}

So it's really just a wrapper around MistOutput. The error message is populated up to the user, so here is where you would put end-user input. For a quick example, let's create an arbitrary anyhow error, with a failure message:

#![allow(unused)]
fn main() {
return Err(anyhow!("dumb failure"));
}

The package run would exit unsuccessfully, and send the error message "dumb failure" on up to the user. Because it leverages the anyhow crate, this also works with any other error via the ? operator:

#![allow(unused)]
fn main() {
let parsed_yaml = serde_yaml::from_str(some_input_str)?;

// Since it's `anyhow`, we can also attach arbitrary failure messages
// to errors that we didn't make.
let parsed_yaml = serde_yaml::from_str(some_input_str)
    .with_context(|| format!("failed to parse some inner yaml"))?;
}

To circle back to the string interface mentioned in Mistletoe for developers, the MistResult here serializes to the same MistResult mentioned there. Specifically, the "Ok" case deserializes to a result: Ok MistResult, and likewise "Err" to result: Err.

MistOutput

Going over the other part of the type, we see that the actual content of it is MistOutput, which is the object we constructed at the start of the function. This is what's used to populate a "successful" MistResult, and it works like a builder. Let's look at a more complex usage of it:

#![allow(unused)]
fn main() {
let output = MistOutput::new()
    .with_message("Things went well!")
    .with_file("namespaces/namespace1.yaml".to_string(), formatdoc!("
        apiVersion: v1
        kind: Namespace
        metadata:
            name: namespace1
    "))
    .with_file("namespaces/namespace2.yaml".to_string(), formatdoc!("
        apiVersion: v1
        kind: Namespace
        metadata:
            name: namespace2
    "));
}

If you notice, we can use it to add as many files as we want! And we can organize them into a file tree as well. The usage is simple, just give it a filename and the contents of the file.

We also set the message here. This step is optional, but useful if you want to pass some information back to the user about the output of the package. (I also don't recommend passing in the trivial "Things went well!" message, this is more for if there's something significant the user should know.)

All that's left is to package it up into our MistResult!

#![allow(unused)]
fn main() {
Ok(output)
}

Putting it in a package

Ultimately, the rest of the work is taken care of by wasm_bindgen and wasm-pack. To run the last steps, you'll need to install the latter tool to your system:

cargo install wasm-pack

And then it's just one command to spit out the module! CD to your project directory and build a minimal version of it:

wasm-pack build --target no-modules

That should generate a ./pkg/<name>_bg.wasm file -- this is your package, and you can run it directly by passing the path into the --package option of any mistctl command.

Optionally, you may want to rename it to the standard <name>-<version>.mist-pack.wasm filename convention. This is particularly important if you want to put it in a registry, where this convention is required.

The generated code from mistletoe_package

While this information isn't really needed for usage of the Rust libraries, this could be helpful if you wish to implement bindings in a different language or re-implement new bindings in Rust.

The info function

Let's get start breaking down the content of the body of the mistletoe_package macro, starting with the "info" function:

#![allow(unused)]
fn main() {
const INFO: &'static str = #mistpackage_string;

static INFO_PTR: mistletoe_bind::include::once_cell::sync::Lazy<std::sync::atomic::AtomicPtr<[usize; 2]>>
    = mistletoe_bind::include::once_cell::sync::Lazy::new(||
{
    let wide_ptr = Box::new([INFO.as_ptr() as usize, INFO.len()]);
    std::sync::atomic::AtomicPtr::new(Box::into_raw(wide_ptr))
});

#[wasm_bindgen::prelude::wasm_bindgen]
pub fn __mistletoe_info() -> *mut [usize; 2] {
    unsafe { *INFO_PTR.as_ptr() }
}
}

The only template value being passed in here is #mistpackage_string value. This is actually a wrapped version of the string you pass into it when calling it. It takes this:

#![allow(unused)]
fn main() {
mistletoe_package! {"
  name: namespace-example
  labels:
    mistletoe.dev/group: mistletoe-examples
"}
}

And returns this:

apiVersion: mistletoe.dev/v1alpha1
kind: MistPackage
metadata:
  name: namespace-example
  labels:
    mistletoe.dev/group: mistletoe-examples

The end result of the "info" logic is that we embed that string into const INFO, create a static fat pointer to it at runtime in static INFO_PTR, and create a return function to send that fat pointer back to the engine.

The allocation functions

The next two functions declared are for allocation, and they're pretty simple since they're not our logic:

#![allow(unused)]
fn main() {
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn __mistletoe_alloc(len: usize) -> *mut u8 {
    unsafe {
        let layout = std::alloc::Layout::from_size_align(len, std::mem::align_of::<u8>()).unwrap();
        std::alloc::alloc(layout)
    }
}

#[wasm_bindgen::prelude::wasm_bindgen]
pub fn __mistletoe_dealloc(ptr: *mut u8, len: usize) {
    unsafe {
        let layout = std::alloc::Layout::from_size_align(len, std::mem::align_of::<u8>()).unwrap();
        std::alloc::dealloc(ptr, layout);
    }
}
}

We're really just exporting the Rust allocator API on up.

The generate function

The last bit, and the bit with the most moving parts, is the generate function(s):

#![allow(unused)]
fn main() {
fn __mistletoe_generate_result(input_str: &str) -> mistletoe_api::v1alpha1::MistResult {
    let input: mistletoe_api::v1alpha1::MistInput = mistletoe_bind::include::serde_yaml::from_str(input_str)?;
    generate(input.try_into_data()?)
}

#[wasm_bindgen::prelude::wasm_bindgen]
pub fn __mistletoe_generate(ptr: *const u8, len: usize) -> *mut [usize; 2] {
    let input_str = unsafe { std::str::from_utf8(std::slice::from_raw_parts(ptr, len)).unwrap() };
    let result = __mistletoe_generate_result(input_str);
    let mut output_str = std::mem::ManuallyDrop::new(mistletoe_api::v1alpha1::serialize_result(result).unwrap());
    let retptr = Box::into_raw(Box::new([output_str.as_mut_ptr() as usize, output_str.len()]));
    retptr
}
}

So there's really two parts of the path to this function: going into the user-defined generate function, and going out.

Going into it, we take the pointer and length passed in to us from the engine and convert it into a string. We then convert it into a MistInput object and then convert that into the user-defined type of their function. In our example, our function was:

#![allow(unused)]
fn main() {
pub fn generate(inputs: Inputs) -> MistResult { /* ... */ }
}

It'll convert it into anything that implements Deserialize (including String), which in our case is our custom Inputs type.

Going out of it, we take the output of the generate function and serialize it to the standard MistResult string output. We also wrap it to be manually-dropped, since we don't want to deallocate it before the runtime can read it -- the runtime will take care of that on our behalf.

Then we just create a fat pointer to it, create a pointer to the fat pointer, and send it on up to the runtime. Our job is done!