Developing Reusable Components in the Operator Framework

Developing Reusable Components

How to layout charms and components using the Operator framework.

As we’ve iterated with the operator framework, there have been a lot of lessons
learned that we would like to try to communicate with a wider audience. As with
all software development, trying to find the right abstractions and right
delegation of responsibility is something that is as much an art as a science.
The goal of this document is to give guidance on how to produce high quality
reusable components that can be shared between charms.

This will be done using an example charm and component which are slightly
fictional, but designed to exemplify the desired layout and behavior of
components. The example used will be an application charm that needs to be
configured with a database, and the reusable component is the handling of the
database relation.

Example Charm and Component:

Charm metadata.yaml:

name: website
requires:
  db:
    interface: database

src/charm.py:

from ops import charm, framework, main
from ops.beta.interfaces import database

class Website(charm.CharmBase):
    """Represents our publicly exposed website.

    Uses our Database tier for storage.
    """

    _stored = framework.StoredState()

    def __init__(self, framework, key):
        super().__init__(framework, key)
        self.framework.observe(
            self.on.config_changed,
            self._on_config_changed)
        self.database = database.DBClient(self, "db")
        self.framework.observe(
          self.database.on.ready, 
          self._on_db_ready)
    
    def _on_config_changed(self, event):
        if self.model.config.dbname is not None:
          self.database.dbname = self.model.config.dbname

    def _on_db_ready(self, event):
        # write out the configuration for the website to use the
        # database, and start the website app
        with open('/etc/website.conf', 'w') as conf:
            conf.write('database: {}\n'.format(event.connection_url))

if __name__ == "__main__":
    main.main(Website)

Component definition:
lib/ops/lib/interface/database.py:

from ops import framework

class DatabaseReadyEvent(framework.EventBase):
    """Emitted once the remote database is ready for a connection.


    Includes the connection_url that can be used to establish a 
    connection to the database.
    """
    def __init__(self, handle, connection_url):
        super().__init__(handle)
        self.connection_url = connection_url

    def snapshot(self):
        return {'connection_url': self.connection_url}

    def restore(self, snapshot):
        self.connection_url = snapshot.get('connection_url')


class DatabaseEvents(framework.ObjectEvents):
    """Custom events emitted by the Database interface."""
    ready = framework.EventSource(DatabaseReadyEvent)


class Database(framework.Object):
    """Defines the interchange of the database interface.

    Clients can request a specific database name to be used 
    (dbname). Once that has been configured by the remote database, 
    the DatabaseReady event will fire with a connection_url that can be
    used by the application.
    """


    on = DatabaseEvents()
    _stored = framework.StoredState()


    def __init__(self, charm, relation_name):
        super().__init__(charm, relation_name)
        self.relation_name = relation_name
        self._stored.set_default(dbname=None)
        self.framework.observe(
            charm.on[relation_name].relation_changed,
            self._on_changed)
        self.framework.observe(
            charm.on[relation_name].relation_created,
            self._on_created)
        self.framework.observe(
            charm.on[relation_name].relation_joined,
            self._on_joined)


    @property
    def dbname(self):
        """The name of the database to use for this application."""
        return self._stored.dbname

    @dbname.setter
    def set_dbname(self, dbname):
        self._stored.dbname = dbname
        rel = self.model.get_relation(self.relation_name)
        if rel is None:
            # Relation is not established yet
            return
        if self.model.unit.is_leader():
            rel.data[self.model.app]['dbname'] = dbname

    def _on_created(self, event):
        """A new relationship was established to a database."""
        if self._stored.dbname is not None
            and self.model.unit.is_leader():
            my_app_data = event.relation.data[self.model.app]
            my_app_data['dbname'] = self._stored.dbname

    def _on_joined(self, event):
        """A new unit of the database has started."""
        # Juju <2.8 did not have relation-created, so we use 
        # relation-joined as a substitute
        self._on_created(event)

    def _on_changed(self, event):
        """New information has become available from the database"""
        remote_data = event.relation.data[event.app]
        if (self._stored.dbname is None
           or remote_data['dbname'] == self._stored.dbname):
           # The connection information should be correct
           connection_url = remote_data.get('connection_url')
           if connection_url is not None:
            self.on.ready.emit(connection_url)

Delegating handling of relation to shared components

Because relations are the points of interaction between charms, they make good
points for a reusable Component. This makes it easier to share a common
implementation of a relation between mulitple charms.

Discussion Points:

These are some rough guidelines that we’ve developed as we see how people are writing charms, and how easy/hard it is to maintain and develop them.

  • The name of the StoredState attribute is _stored for both the Charm and for the component. This data is private to the class, and other classes should not be poking at those private variables. The event attribute on is a public interface, and is intended as how other classes interact.

  • The event handlers themselves are recommended to be private. Since they are not meant as ways for other code to interact with your object. The object should be responsible for registering the events that it wants to observe in its __init__ method.

  • Avoid having a bunch of sequential logic checking preconditions in one catch-all method. Instead, factor out groups of associated conditions and trigger an event once all of them are met.

  • Document how users should interact with your component. Try to keep only methods you want them to use as public methods/attributes of your class. You can use ‘pydoc’ on your module to see if you’re documenting things adequately.

  • Use _stored.set_default() to ensure you always have valid attributes for _stored.

  • Config is defined by the charm and passed in as attributes to the component, rather than having the component doesn’t directly interact with config-changed. The expectation is that a reusable component will be passed in the config it needs explicitly by the charm (or component) that uses it. This gives greater flexibility as some Charms won’t want to expose configuration for a component.

2 Likes