Charming Discourse with the reactive framework

David Lawson

on 27 February 2018

This article was last updated 6 years ago.


Recently the Canonical IS department was asked to deploy the Discourse forum software for a revamp of the Ubuntu Community site at https://community.ubuntu.org. Discourse is a modernization of classic forum/bulletin board software packages and is something IS has had an interest in for some time, so I was happy to help the Community team get this deployed. An initial, exploratory deployment was done to get a feel for the software and then the obvious next step for us towards production deployment was charming Discourse for easy, repeatable deployment in the future.

This process has some interesting challenges as an exercise in charming, Discourse is a Ruby on Rails application which Canonical IS doesn’t have substantial institutional experience with and with which I personally have essentially no experience. Luckily, writing charms with the reactive toolkit provides layers to abstract away much of the technology specific aspects of the process, allowing the charmer to focus on code directly related to the application they’re charming.

I’ll be building the charm with charms.reactive, the most current framework for writing charms, which provides a set of pre-built layers and interfaces to make interfacing with other services and charms quick and easy. The charm tool helpfully provides a set of defaults for working with charms.reactive. Both the charm tool and charms.reactive are amply documented elsewhere, so let’s skip to the code.

Note that some code is redacted for the sake of clarity, but the full charm is available at https://launchpad.net/discourse-charm for anyone to view.

It happens that the Discourse code is distributed as a git tree and there’s a charm layer specifically for deploying from git repos, so the base of the install is quite simple:

@when('codebase.available')
@when('ruby.available')
@when_not('discourse.installed')
def install_discourse():
    config = {'hostname': hookenv.config('hostname')}
    config['smtp_address'] = "127.0.0.1"
    # Required to work with a stock install postfix
    config['smtp_openssl_verify_mode'] = "none"
    config['smtp_domain'] = hookenv.config('hostname')
    # There's no config default for this, so only write it if it's set
    if hookenv.config('admin-users'):
        config['developer_emails'] = hookenv.config('admin-users')
    write_config(config)
    # Bundle command is from the ruby layer, it'll install our gem dependencies
    bundle('install')
    hookenv.status_set('blocked', 'Discourse installed, waiting on database.')
    set_state('discourse.installed')

The git-deploy layer sets the codebase.avilable state when it has successfully cloned the configured git repo from the remote source. Likewise, the ruby layer sets ruby.available when it’s ready, so we’re waiting on both those states. To prevent the initial configuration from running multiple times, we guard the function with a state we set at the end, discourse.installed, which we can also use to trigger additional reactions as we flow through the installation process. The actual code here is straightforward, we fetch some things from the juju configuration and write them out to the discourse.conf file with a utility function, then use the bundle command from the ruby layer to install gem dependencies.

We now have the code cloned locally and ruby dependencies installed, next we’ll need a database. Discourse needs a pair of postgres extensions installed and we might as well specify the database name, that way if we ever need to do multi-site or something else unusual, we can control that via a configuration options down the road.

@when_not('discourse.database.requested')
@when('db.connected')
def request_db(pgsql):
    pgsql.set_database('discourse')
    pgsql.set_extensions(['hstore', 'pg_trgm'])
    set_state('discourse.database.requested')

The postgresql layer provides the db.connected state, the name of the state is determined by the name of the relation connecting the two charms, since we chose to name the relation db on the Discourse side, the state is named db. Note that we pass in pgsql to this function, a class provided by the postgresql layer that let’s us manipulate the database and extract information about the connection to it. Again we’re guarding this function with a state set within it, so it only runs once.

Once the database and extensions are created, we can configure it.

@when_not('discourse.database.configured')
@when('discourse.installed')
@when('db.master.available')
def db_available(pgsql):
    write_db_config(pgsql)
        set_state('discourse.database.configured')

That’s pretty uninteresting, discourse.installed is set by the install function so this will run after the install happens so we know we’re not writing a config file to an empty directory. The db.master.available is set by the postgres layer when the database is actual available rather than just when the postgresql relation has been connected, so this configuration will happen after database and extension creation.

Let’s look at the write_db_config utility function though.

def write_db_config(pgsql):
    config = ingest_config()
    db_config = pgsql.master
    config['db_name'] = db_config.dbname
    config['db_host'] = db_config.host
    config['db_username'] = db_config.user
    config['db_password'] = db_config.password
    write_config(config)

The ingest_config is another utility function that reads the existing discourse.conf and turns it into a dictionary that we can pass around, thus preserving anything in it that may have been configured either by another part of the charm or outside of juju for some reason. The rest of this simply takes pieces of the database connection string provided by the pgsql class and adds it to the config dictionary with key names appropriate to the discourse.conf file, which we then write out, then set a state indicating we’ve configured Discourse’s database connection.

Now that we have a basic configuration that will actually do something, there’s some preparatory work unique to Discourse that needs to happen before we can get much further, so we’ll go ahead and prepare the code for running.

@when_not('discourse.codebase.prepared')
@when('db.master.available')
@when('discourse.database.configured')
@when('discourse.database.requested')
def prepare_codebase(pgsql):
    # Create/update all the Discourse specific DB schema
    bundle('exec rake db:migrate RAILS_ENV=production')
    # Compile CSS/JS
    bundle('exec rake assets:precompile RAILS_ENV=production')
    if hookenv.config('plugins'):
        fetch_plugins()
    # The ruby layer doesn't have a concept of ownership, so after the rakes
    # have run chown everything
    subprocess.call(["/bin/chown", "-R", "www-data:www-data", "/srv/discourse/current/"])
    # Set some states to trigger server configuration off of
    set_state('discourse.configure.unicorn')
    set_state('discourse.configure.sidekiq')
    set_state('discourse.codebase.prepared')

We trigger on a guard state set within the reaction itself, as well as requiring that we have a database created, configured and available, putting together various states set in the previous functions. The bundle command, as mentioned earlier, is courtesy of the ruby layer and handles some of the under the hood complexity, allowing us to just run the commands we need, in this case migrating the database and compiling assets, steps familiar to anyone who has used any modern web framework. Unfortunately, the ruby layer doesn’t have an ownership concept the way the git-deploy layer does, so we need to make sure nothing ends up owned by root that would cause problems later. Then we set some states to trigger configuration of the app servers.

So now we have code, we have a database, we just need to configure and start up the application server. Discourse seems to support a wide variety of Rails application servers, I’ve seen people using Apache with Passenger, Thin, etc. The default in the Docker deploy is using Unicorn though, so we’ll go with that.

@when('discourse.configure.unicorn')
def configure_unicorn():
    if not os.path.isdir('/srv/discourse/current/tmp/pids'):
        os.makedirs('/srv/discourse/current/tmp/pids')
    render(source="unicorn.service",
           target="/lib/systemd/system/unicorn.service",
           perms=0o644,
           context={'config': hookenv.config})
    service('enable', 'unicorn')
    service('restart', 'unicorn')
    open_port(3000, protocol='tcp')
    hookenv.status_set('active', 'Discourse running.')
    remove_state('discourse.configure.unicorn')

When the state is set at the end of prepare_codebase, we’ll trigger this reaction. We create a PID directory for the Unicorn server, then render out a templated systemd configuration, enable the server, then ensure that it’s started or restarted, since this reaction can be triggered not only at install time but also when the configuration changes. We don’t really need to open the port, that tends to be something that’s only necessary with public facing services, but it’ll facilitate testing and configuration of Discourse via the web UI when people install the charm, so it’s convenient. We set the juju workload status to active with a relevant comment, then remove the state that triggered the reaction.

Discourse also has an asynchronous application server that handles operations that shouldn’t block a web request like sending mail, occasional recompilation of markdown of posts, etc. There doesn’t appear to be the diversity of options for that server that exists for the main application server, Sidekiq seems to be the standard, so we do basically the same process for that.

Now we have a running application server and if we visit port 3000 of the IP of the unit, we’ll get the Discourse welcome page, but that’s not a convenient way to access a forum or even particularly convenient for basic setup. Luckily, the website layer exists which lets a charm provide the “website” relation that can be consumed by most frontend servers. In this case I’ll be using Apache, but it could just as easily be Nginx, Squid, Varnish, whatever your preferred frontend web server might be.

@when('website.available')
def configure_website(website):
    hostname = hookenv.config('hostname')
    website.configure(port=3000, hostname=hostname)

That’s a remarkably concise reaction that has a lot abstracted away for us. The website.available state is set by the website interface layer when a relation is created between the Discourse charm and any other charm that can consume the website relation, so it doesn’t get executed until there’s a unit to consume it. When that happens we just provide the port Unicorn is listening on and the hostname configured in Discourse’s juju configuration, so the web server knows what its FQDN should be and we’re done, the layer handles everything else.

So now we have a running Discourse that we can access via the web server of our choice, but what about when we want to update the Discourse code? Or suppose we need to change the hostname or install a new plugin? How do we handle those situations?

@when('config.changed')
def write_new_config():
    config = ingest_config()
    config['hostname'] = hookenv.config('hostname')
    admin_users = hookenv.config('admin-users')
    if admin_users:
        config['developer_emails'] = admin_users
    write_config(config)
    # This will force the prepare_codebase reaction to run which will handle
    # any changes in the plugin list and then will in turn run the
    # configure_unicorn and configure_sidekiq reactions
    remove_state('discourse.codebase.prepared')

The config.changed state is provided by charms.reactive whenever a juju configuration value changes. Since the Discourse config is just key value pairs, it’s safe to write the various configuration settings to the discourse.conf whenever any of them change, so we don’t have to worry about determining which of the configuration options changed at any given time, though charms.reactive does include a facility to do that. That allows you to only restart a service when a configuration option that requires a restart to take effect is changed, for example, something important in more complex applications. In this case the simple route is fine, we write out the new config with all values, then trigger the prepare_codebase reaction again. If a new version of the code has been fetched or the plugin list has changed, that will run through all the steps necessary to update the application and restart the services to pick up the new code/plugin.

This isn’t the entirety of the charm, obviously, there’s some additional interfaces and layers involved to provide monitoring of the various application servers and required supporting services on the unit, redis and postfix in particular, as well as other bits and pieces of utility code that aren’t particularly illuminating in and of themselves. This does show the meat of the code that actually installs, configures and runs Discourse though.

So with all the pieces in place, we can go ahead and deploy a full stack:

juju deploy discourse
juju deploy postgresql
juju deploy apache2
juju add-relation discourse:db postgresql:db
juju add-relation discourse:website apache2:balancer

There is a tiny bit of additional configuration to be done, Discourse needs a hostname and admin users, Apache needs vhost templates to configure its virtual hosts to use the balancer created by the website relation to serve the site, but the user experience of deploying and configuring Discourse via charm is very, very simple.

Hopefully this provides a view into the power the reactive framework gives charm authors to focus on the details of getting their application running with a minimum of boilerplate code and complexity. Overall, the Discourse charm ended up taking me a few days to develop, including some time setting up the manual test install, tracking down bugs and testing, then finally deploying into production. I was able to go from a very basic understanding of Rails applications in general and Discourse in particular to a deployed service leveraging other charms within a few days of work. Now anyone else who may want to provide a Discourse forum for their community has a robust method for deploying and scaling it with very little effort.

Ubuntu cloud

Ubuntu offers all the training, software infrastructure, tools, services and support you need for your public and private clouds.

Newsletter signup

Get the latest Ubuntu news and updates in your inbox.

By submitting this form, I confirm that I have read and agree to Canonical's Privacy Policy.

Related posts

Kubernetes backups just got easier with the CloudCasa charm from Catalogic

For a native integration for Canonical’s Kubernetes platform, Juju was the perfect fit, and the charm makes consuming CloudCasa seamless for users.

What is a Kubernetes operator?

Kubernetes is the open source, industry-standard platform for deploying, managing and scaling containerized applications – and applications on Kubernetes are...

Operate popular open source on Kubernetes – Attend Operator Day at KubeCon EU 2024

Operate popular open source on Kubernetes – Attend Operator Day at KubeCon EU 2024