Learning How to Write Reactive Charms by Porting our Minetest Charm

It has been a really long time since my last blog post, so let’s fix that by writing a followup post to my popular article on learning to write Juju Charms, where we wrote a simple Charm to deploy a production ready Minetest server, complete with postgresql integration through Juju relations.

Today, we are going to go a step further and delve into Reactive Charms, where we can define and maintain state through flags. Flags let us have a memory of events that have happened in the past, and only run certain functions to “react” to changes in those flags.

hero

Reactive Charms are primarily written in Python, and there are a lot of different submodules that exist to help you develop your Charm. So buckle up, because we are going to take our little Minetest Charm to the next level.

Original Charms vs Reactive Charms

Original Charms could be written in any language, and we decided to write our old Minetest Charm in bash. Reactive Charms are intended to be developed using Python 3, and to take advantage of the rich Python submodule ecosystem built and maintained by the community, which provides simple blueprints to make great production ready code.

Reactive Charms build on many of the same mechanisms from the older Bash Charms, and you will find that files like metadata.yaml and config.yaml are exactly the same, so we should be able to reuse some code from our old Charm during its port to becoming a Reactive charm.

In that case, make sure you read my previous articles so you have a good understanding of how hook based Charms work:

There are three notable changes between hook Charms and Reactive Charms.

Charmhelpers Library Code

There is a wealth of already implemented functions you can use to help develop your Charm, and they are in the charmhelpers Python module. There is excellent documentation available to help you find what these functions do, and what their API is.

charmhelpers helps you write correct code the first time, by implementing useful things like if a group exists or creating new groups, adding users, or adding users to groups.

You can also do things like get a dictionary of the Charm’s config.yaml, write to the juju log or set juju status information.

Have a look around, and I’m sure you will find all sorts of useful functions to help you write your Charm.

Flags

Reactive Charms have the ability to store state, so you can now selectively run functions only if they meet certain conditions, stored in flags. This is super useful, since you might only want to generate the configuration file once the database has been configured, so you don’t want config-changed to be run before the user relates a database, for example.

It also allows us to implement finite state machines for more complex deployments where you don’t want race conditions or to jump steps, which is particularly useful for managing critical data in storage Charms.

Flags can be named anything you want, and we use methods like set_flag() and clear_flag() to manage them.

Flags are actually implemented in the charms.reactive Python module, and are used as decorators on your functions. There are a whole bunch of different decorators you can use, but the common ones are when(), when_not(), when_any(), hook().

A simple example is to guard against only doing something once, much like a singleton pattern but not as advanced. We can do this by setting a flag:

@when_not('myprogram.installed')
def install_myprogram():
    # Get your things installed...
    
    set_flag('myprogram.installed')

When your Charm is first deployed, myprogram.installed won’t be set, so we will run the install_myprogram() function, and then once we set myprogram.installed we can no longer fulfil the @when_not() decorator, and we won’t run install_myprogram() again.

Neat.

Layers

Layers are all about incorporating the flags and hooks from other Charms, and putting them to use in your own Charm, helping the code reuse and correctness.

Layers are effectively libraries you can import, and are mostly set and forget with no need to write any code to make them work. You can set some options in the layer definition file, and they will be passed to layer functions as needed.

In this guide, we will take advantage of the basic and apt layers, as well as the pgsql interface for database management. I will show you how they work slightly later on.

Reactive Charm Writing Method

I’m again going to be following along the Reactive Charm Documentation as well as the recommended Reactive Charm Tutorial found on discourse.

What You Will Need To Get Started

We will need to have Juju installed, and also charm tools. We can get both of these from the Snap Store.

$ sudo snap install --classic juju
$ sudo snap install --classic charm

Create Charm Directory Structure

Charms are a collection of text files, which are primarily split up into Python scripts and YAML configuration files.

Much like last time, we will make a directory for our Charms to live in, but this time, we create two more directories, layers and interfaces.

$ mkdir -p ~/charms
$ mkdir -p ~/charms/layers
$ mkdir -p ~/charms/interfaces

We also need to setup some environment variables for Charm tools to use, so add the following to your ~./bashrc:

$ cat << EOF | tee --append ~/.bashrc
export CHARM_LAYERS_DIR="~/charms/layers"
export CHARM_INTERFACES_DIR="~/charms/interfaces"
EOF
$ source ~/.bashrc

We can use Charm tools to automatically generate the correct directory structure for us, so run:

$ cd ~/charms/layers
$ charm create minetest-server

You should now have these files in ~/charms/layers/minetest-server:

directory structure

Edit the README File

We need a README file to tell our users what our Charm is about, how to deploy it, and how to scale it. We will tweak what we did last time, and the following should do:

Minetest is a fun, free and open source voxel game inspired by Minecraft.
It supports various game modes, like survival and creative, and many more can
be added with mods.

This Charm deploys a basic game server, and is backed by a PostgreSQL database
for maximum performance. There are no mods, so you will need to add them
yourself.

To deploy:

$ juju bootstrap
$ juju deploy postgresql
$ juju deploy minetest-server
$ juju relate postgresql:db minetest-server:db
$ juju expose minetest-server

Edit the metadata.yaml File

The role of metadata.yaml has not changed, and it still tells Juju what the Charm is called, what it does, who wrote it, what Ubuntu distribution it is compatible with, and what interfaces are exposed and required to function.

name: minetest-server
summary: Minetest is a opensource voxel game designed to be modded.
maintainer: Matthew Ruffell <[email protected]>
description: |
    Minetest is a fun, opensource voxel game engine that can be customised with
    different game modes and mods.
    This charm installs Minetest with a PostgreSQL backend.
tags:
- social
series:
- hirsute
- focal
provides:
  server:
    interface: minetest
requires:
  db:
    interface: pgsql

Describe Configuration Options in config.yaml

Since we want users of our Charm to be able to configure the Minetest server to suit their needs, such as changing the server message of the day, or the port it is being served on, we need to define configuration variables in config.yaml.

This is also pretty straightforward.

The only thing to note is you should carefully consider what options you want to expose to your users. Users don’t really care about the fine details, so only expose what most people will understand and use.

Saying that, make sure you set sensible defaults. All Charms should work out of the box on first deployment. If people are interested in changing config, they will, otherwise they will leave everything alone.

An example config is: (inspired by the existing config.yaml in James Tait’s older minetest charm)

options:
    port:
        default: 30000
        description: Server port to listen on
        type: int
    server-name:
        default: "Minetest server"
        description: Name of the server
        type: string
    server-description:
        default: "Juju deployed Minetest server"
        description: Description of server
        type: string
    motd:
        default: "Welcome!"
        description: Message of the day
        type: string
    strict-protocol-version-checking:
        default: "false"
        description: Set to true to disallow old clients from connecting
        type: string
    creative-mode:
        default: "false"
        description: Set to true to enable creative mode (unlimited inventory)
        type: string
    enable-damage:
        default: "false"
        description: Enable players getting damage and dying
        type: string
    default-password:
        default: ""
        description: New users need to input this password
        type: string
    default-privs:
        default: "build,shout"
        description: |
            Available privileges: build, shout, teleport, settime, privs, ban
            See /privs in game for a full list on your server and mod configuration
        type: string
    enable-pvp:
        default: "true"
        description: Whether to enable players killing each other
        type: string

All Charms should include a copyright file, which includes details about the copyright and licensing status of the files inside the Charm.

We will again use the debian/copyright file format to license our charm, by placing the following in a file called copyright.

We will take the OpenStack Keystone Charm copyright file as inspiration, so the below will do:

Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0

Files: *
Copyright: 2021, Matthew Ruffell.
License: GPL-3

License: GPL-3
 This package is free software; you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by
 the Free Software Foundation; either version 3 of the License, or
 (at your option) any later version.
 .
 This package is distributed in the hope that it will be useful,
 but WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 GNU General Public License for more details.
 .
 You should have received a copy of the GNU General Public License
 along with this package; if not, write to the Free Software
 Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301 USA
 .
 On Debian systems, the complete text of the GNU General
 Public License can be found in `/usr/share/common-licenses/GPL-3'.

Make an Icon for the Charm Store

If you want your Charm to look nice on the Charm store listing or on the Juju GUI, then you should probably set an icon.

Open up icon.svg in Inkscape or whatever vector editor you like, and make a nice icon:

icon

I used the icon found at /usr/share/icons/hicolor/scalable/apps/minetest.svg to make this icon.

Defining Layers and Their Options

Layers are a mechanism to integrate related Charms into your own Charm. Think of them as libraries you can import and leverage to perform tasks correctly, so you don’t have to get into the specifics yourself.

For example, take the layer:apt layer. This implements package management via apt, and it will automatically be called when the Charm is deployed in the install phase. We can include some options in the options: section, and we can tell it to automatically install minetest, without having to specify anything more. The days of manually writing apt install minetest are over.

The layer:basic layer implements basic hooks like start, stop, and uses magic to link different hooks and conditions to flags. This is the layer that is also responsible for autogenerating our hooks directory when we run charm build.

Finally, we also specify the interface:pgsql interface, which tells Juju that we will be using the postgresql charm, and that we will be using related flags like db.connected and db.database.available.

Our final layers.yaml looks like the following:

includes:
  - 'layer:basic'
  - 'layer:apt'
  - 'interface:pgsql'
options:
  apt:
    packages:
      - minetest

Creating Templates for Game Configuration and System Service Files

Templates are a wonderful new addition to Reactive Charms. They allow us to define our configuration files in one place, and fill out any unknown variables

$ mkdir templates

We will need two templates. One, a systemd service file to run minetest on boot, and the other will be the actual minetest configuration.

Let’s do the systemd service first.

Make a file called minetest.service and put the following service description in it:

[Unit]
Description=Minetest
Documentation=https://wiki.minetest.net/Main_Page

[Service]
Type=simple
User=minetest

ExecStart=/usr/games/minetest --server

ExecStop=/bin/kill -2 $MAINPID

[Install]
WantedBy=multi-user.target

Note, we can use the Jinja2 templating engine to fill variables for us when we render the file later on. We can place values within '{{ object.attribute }}' style syntax.

For example, we can fetch the server-name configuration from the Juju config entries with '{{ config["server-name" }}'. We will pass in database details later, and use my_database as an object placeholder for now.

Let’s use this information to create the minetest configuration file. Name it world.mt and fill it with:

port =  {{ config["port"] }} 
server_name =  {{ config["server-name"] }} 
server_description =  {{ config["server_description"] }} 
motd =  {{ config["motd"] }} 
strict_protocol_version_checking =  {{ config["strict-protocol-version-checking"] }} 
creative_mode =  {{ config["creative-mode"] }} 
enable_damage =  {{ config["enable-damage"] }} 
default_password =  {{ config["default-password"] }} 
default_privs =  {{ config["default-privs"] }} 
enable_pvp =  {{ config["enable-pvp"] }} 
gameid = minetest
backend = postgresql
player_backend = postgresql
auth_backend = sqlite3
pgsql_connection = host= {{ database["private-address"] }}  port= {{ database["port"] }}  user= {{ database["user"] }}  password= {{ database["password"] }}  dbname= {{ database["database"] }} 
pgsql_player_connection = host= {{ database["private-address"] }}  port= {{ database["port"] }}  user= {{ database["user"] }}  password= {{ database["password"] }}  dbname= {{ database["database"] }} 

Writing the Actual Deployment and Management Code

In Reactive Charms, we implement the logic to manage the Charm in reactive/charm_name.py, or in our case, reactive/minetest_server.py.

Have a read of the final code, and I’ll commentate how it works below.

from charms.reactive import when, when_not, set_flag
from charmhelpers.core.host import group_exists, add_group, user_exists, adduser, mkdir, service, service_restart, chownr
from charmhelpers.core.templating import render
from charmhelpers.core.hookenv import log, status_set, application_version_set, config, relations_of_type
from charmhelpers.fetch import get_upstream_version

@when('apt.installed.minetest')
@when_not('minetest-server.installed')
def install_minetest_server():
    log("Setting up users and groups", 'info')
    
    # Add minetest group to system if it doesn't exist
    if not group_exists('minetest'):
        add_group('minetest', system_group=True)
    
    # Add minetest user to system if it doesn't exist
    if not user_exists('minetest'):
        adduser('minetest', system_user=True, primary_group='minetest',
                home_dir='/home/minetest')
    
    # Ensure the minetest world directory exists
    mkdir(path='/home/minetest/.minetest/worlds/world',
          owner='minetest',
          group='minetest',
          perms=0o775)
    
    # Ensure permissions are correct
    chownr(path='/home/minetest',
           owner='minetest',
           group='minetest',
           chowntopdir=True)
    
    log("Installing systemd service files", 'info')
    
    # Install the systemd service file
    render(source='minetest.service',
           target='/etc/systemd/system/minetest.service',
           owner='root',
           group='root',
           perms=0o644,
           context={
           })
    
    # Set the version number in Juju to what was installed
    application_version_set(get_upstream_version('minetest'))
    
    # Enable the minetest service
    service('enable', 'minetest.service')
    
    # We are all installed now, we don't need to call this function again
    set_flag('minetest-server.installed')
    
    
@when('config.changed')
@when('minetest.database.configured')
def minetest_regenerate_configuration():
    status_set('maintenance', 'Configuring minetest')
    
    # Fetch our minetest and database configuration variables
    my_config = config()
    my_database = relations_of_type('db')[0]
    
    log("Installing minetest configuration file", 'info')
    
    # Populate the configuration file and install it in place
    render(source='world.mt',
           target='/home/minetest/.minetest/worlds/world/world.mt',
           owner='minetest',
           group='minetest',
           perms=0o664,
           context={
               'config':my_config,
               'database':my_database,
           })
    
    # Restart the minetest service to take on new config
    service_restart('minetest.service')
    
    # Tell Juju that minetest is good to go
    status_set('active', 'Configuration file written')

@when('db.database.available')
def database_connected():
    # We have a database now, so we can generate config anytime now
    set_flag('minetest.database.confgured')
    
    # Generate the config file with database credentials
    minetest_regenerate_configuration()

@when_not('db.connected')
def missing_database():
    status_set('blocked', 'Relation to postgresql required')

We first import all the functions we need from the charmhelpers python module, which is actually quite a lot for our small piece of code, but it’s okay, since we want charmhelpers to do our heavy lifting.

We next have a function install_minetest_server(), that acts as a singleton like I described when I mentioned how flags work. It has an extra condition though, and that is @when('apt.installed.minetest'). This ensures that we only call install_minetest_server() once the apt layer has completed installing the minetest package.

In install_minetest_server(), we set up the minetest user and group, set up a /home directory and world directory, and install a systemd service file. We also get the minetest package version and expose it to Juju for pretty juju status prompts with our actual minetest version.

Next up we have minetest_regenerate_configuration() which collects the Charms config parameters, and database relation parameters, and renders the variables into the template config file we created above. Smart right? I thought so. We also restart the systemd service to load the new configuration, and set the Charm’s status to active.

We used two flags for minetest_regenerate_configuration(), which makes sure we only call the function when both config.changed and minetest.database.confgured is set. config.changed acts like a hook in reality, and minetest.database.confgured is what actually stops the function from being run before a database is available.

To pull this off, we have two functions, missing_database() and database_connected(). missing_database() sets the Charms status to blocked when there isn’t a postgresql relation present, which is what we want, since without a backing database, we can’t play minetest.

database_connected() is called when we have a postgresql relation, and the database is created and we have user credientals available. This is from the db.database.available flag that the postgresql interface sets. We take the opportunity to set ‘minetest.database.confgured’ so we can go ahead and render our configuration, and then manually call minetest_regenerate_configuration() to make that happen.

Its not too complicated, and it actually turned out to be less code than the old hook based Charm.

Deploying the Charm

Now that everything is in place, let’s go ahead and deploy the Charm to our machines, and get our minetest server running.

Creating the Controller

We will be using LXD as the cloud backend for our Juju model today, so go ahead and deploy a juju controller with the “localhost” backend:

$ juju bootstrap --bootstrap-series=hirsute localhost lxd-controller
Creating Juju controller "lxd-controller" on localhost/localhost
Looking for packaged Juju agent version 2.9.12 for amd64
Located Juju agent version 2.9.12-ubuntu-amd64 at https://streams.canonical.com/juju/tools/agent/2.9.12/juju-2.9.12-ubuntu-amd64.tgz
To configure your system to better support LXD containers, please see: https://github.com/lxc/lxd/blob/master/doc/production-setup.md
Launching controller instance(s) on localhost/localhost...
 - juju-6b05e2-0 (arch=amd64)                 
Installing Juju agent on bootstrap instance
Fetching Juju Dashboard 0.8.1
Waiting for address
Attempting to connect to 10.29.181.61:22
Connected to 10.29.181.61
Running machine configuration script...
Host key fingerprint is SHA256:H0KFu2A5tmmM2blQ5dJ70iMhav+6RJ+4wKrkTp08y2M
+---[RSA 2048]----+
|         ..      |
|        o.       |
|       =..       |
|      X.=        |
|     X.OS=.      |
|  o.B.Bo=++.     |
| o *o+o.o+..     |
|+ .Eooo.         |
|o+oo. ++.        |
+----[SHA256]-----+
Bootstrap agent now started
Contacting Juju controller at 10.29.181.61 to verify accessibility...

Bootstrap complete, controller "lxd-controller" is now available
Controller machines are in the "controller" model
Initial model "default" added

Note, I used --bootstrap-series=hirsute to use Hirsute as the operating system for the controller.

We can confirm our controller deployed properly with juju controllers:

juju controller

Looking at juju status we now have a nice empty model:

juju status

Deploying the PostgreSQL Charm

Our Minetest Charm depends on postgresql as a database backend to store our player information and world data, so let’s go ahead and deploy it first.

Things have changed slightly from the last time I wrote a blog post, with Charms now being able to be found on Charmhub, instead of the Charm Store.

So, we go to Charmhub, and search for postgresql, and come across the entry postgresql at revision 235.

Deploying it is simple, we just run:

$ juju deploy postgresql
Located charm "postgresql" in charm-hub, revision 235
Deploying "postgresql" from charm-hub charm "postgresql", revision 235 in channel stable

and we can watch juju status while we wait.

juju status

Eventually it will complete, and postgresql will be ready to use:

juju status

Proofing and Building our Minetest Charm

We can do a quick sanity check over our charm with charm proof, which tells us if we are missing anything critical, or need to change some boilerplate code.

charm proof

In our case, we are missing some hooks, which we will add later.

If everything looks okay, go ahead and build your charm with charm build:

charm build

All green, fantastic! Time to deploy.

Deploying our Minetest Charm

Our Charm was built and placed into /tmp/charm-builds/minetest-server, so point Juju at that location, and deploy away:

$ juju deploy /tmp/charm-builds/minetest-server
Located local charm "minetest-server", revision 0
Deploying "minetest-server" from local charm "minetest-server", revision 0

We can watch juju status like normal to see how it went:

juju status

Ouch. Error in the install hook. Not a problem, Juju can tell us what went wrong in an instant, with the juju debug-log command. Run that, and let’s see what went wrong:

juju debug-log

Silly me, seems I forgot to include a logger module, not to worry, we can fix that right up. Add the following to minetest-server.py at the top:

from charmhelpers.core.hookenv import log, status_set

and we should be good to go. But if you happen to have a different problem, don’t forget you can juju ssh minetest-server/0 to get a shell inside the minetest LXD container, where you can debug from there.

juju ssh

The charm itself lives in /var/lib/juju/agents/unit-minetest-server-0/charm/, so cd into there, edit minetest-server.py in vim, save and exit.

juju location

We don’t have to redeploy the entire Charm to get small bugfixes, and production servers you might not have that luxury at all. Instead, we can run:

$ juju resolved minetest-server/0

and this tells Juju that we fixed the errors, and to re-try that hook again. If we check juju status, it seems to have worked:

juju status

We are now waiting on our database connection, so let’s make the relation happen:

$ juju relate postgresql:db minetest-server:db

Checking juju status now, we see all green, and that our configuration file has been written correctly:

juju status

This is promising! Let’s expose the port:

$ juju expose minetest-server

Open up minetest, and connect to the server listed at private-address in juju status, which is 10.29.181.198 in my case, on port 30000, which we set in our configuration:

minetest connect

And click connect, and wow it works! We find ourselves in a snowy world, all powered by Minetest + Postgresql + Juju with Reactive Charms. Very fancy, and production ready.

minetest working

Debugging the Charm

Now that we have written our Reactive Charm, we also need to be able to debug it and know what to do when things go wrong. These tips should help.

Getting Debug Logs

As mentioned when we were writing the Reactive code, your first port of call when you run into a problem is to run juju debug-log. This gives you the log outputs of all active running Charms, and any error messages like stack traces are very prominent and repeated often, so you won’t miss anything.

juju debug-log

Make sure to make use of log() from charmhelpers.core.hookenv, and use it to write useful information to the Juju log, as well to print debug information like a print statement or printk. I did this a lot when writing this charm, so I could see the contents of relations_of_type() with Python’s dir().

It’s also very helpful to have juju debug-log running in a window on a second screen so you can keep a detailed watch of deployment progress when you are developing your charm.

Debugging Hooks and Examining Flags at Runtime

In the previous article, we used juju debug-hooks application-name/unit to access a tmux session to see what data is exchanged during various hooks like db-relation-joined and config-changed.

We can still do all of that, but juju debug-hooks has gotten more powerful for Reactive Charms.

If you run:

$ juju debug-hooks minetest-server/5

You get the same tmux session:

juju debug-hooks

Now, we can run hooks manually by executing the python scripts that are backing them, in the hooks directory of the Charm.

The session is opened to the Charm directory, at var/lib/juju/agents/unit-minetest-server-5/charm, so we can ls hooks/ to see what we can run:

juju debug-hooks

If we wanted to run config-changed manually, we can do this with:

$ python3 hooks/config-changed

and it runs. Very useful if you need to watch what is happening in juju debug-log concurrently.

But what happens if your flags aren’t getting hit? No worries, we can see what the values for the flags are by running:

$ charms.reactive -p get_flags

juju get-flags

Not only can we see what they are actually called (which is useful in itself, I thought db.available was a flag, but it was actually called db.database.available instead, and get_flags() told me this), but we can also see if they are set or unset, with commands like all_flags_set(), get_unset_flags(), is_flag_set(), and we can also change flags with set_flag(), clear_flag(), toggle_flag(). Very useful.

Cleaning Up

Once we have had our fun and want to reclaim some disk space back, we can tear down and remove the deployment with:

$ juju remove-application minetest-server
removing application minetest-server
$ juju remove-application postgresql
removing application postgresql

You can check juju status to keep an eye on progress. If anything gets stuck you can forcefully remove the machine number 5 with:

$ juju remove-machine 5 --force

If you want to remove your controller, then run:

$ juju destroy-controller lxd-controller --destroy-all-models
WARNING! This command will destroy the "lxd-controller" controller.
This includes all machines, applications, data and other resources.

Continue? (y/N):y
Destroying controller
Waiting for hosted model resources to be reclaimed
Waiting for 1 model
All hosted models reclaimed, cleaning up controller machines

Conclusion

In this article we revisited writing Juju Charms, this time taking the more modern and robust Reactive Charms for a spin. We ported our simple Minetest Charm to Reactive, which was quite straightforward, and managed to make our code simpler than when had hook based Charms.

I enjoyed digging into all the new Charmhelper functionality and getting my head around how flags work, and I hope it has been useful with helping you to write your own Reactive Charms.

Hopefully you enjoyed the read, and as always feel free to contact me.

Matthew Ruffell