How to add a relation to a charm

See first: Juju | Relation (integration), Juju | How to manage relations

To add integration capabilities to a charm, you’ll have to define it in your charm’s charmcraft.yaml file and then add integration (relation) event handlers for it in your charm’s src/charm.py file. The way to do this differs depending on whether your integration is a provide or require relation or rather a peer relation.

Contents:

  1. Define an integration endpoint
  2. Define integration event handlers

Define an integration endpoint

Integrations between different applications are defined in a charm’s charmcraft.yaml file using the provides and requires keywords. Integrations between multiple units of the same application are defined using the peers keyword.

See more: File charmcraft.yaml> Key peers, provides, requires

If you’re using an existing interface:
Make sure to consult the charm-relations-interfaces repository for guidance about how to implement them correctly.

If you’re defining a new interface:
Make sure to add your interface to the charm-relations-interfaces repository.

Define a provide or require integration

See also: Provide or require integration

Example integration definitions can be seen below. For example, a database might specify:

name: mongodb
# ...
provides:
  database:
    interface: mongodb

…and that of the node.js charm:

name: my-node-app
# ...
requires:
  database:
    interface: mongodb
provides:
  website:
    interface: http

Put together, these files indicate that an integration can be made between applications. The mongodb charm provides an interface named database with the mongodb interface, and the my-node-app charm requires an integration named database with the mongodb interface.

Define an implicit integration

See also: Implicit integration

To perform the implicit integration, here the rsyslog-forwarder charm is a subordinate charm that requires a valid scope: container integration named logging. In the event that the principal charm doesn’t provide this, the logging charm author can use juju-info:

requires:
  logging:
    interface: logging-directory
    scope: container
  juju-info:
    interface: juju-info
    scope: container

The administrator can then issue the following command:

juju integrate some-app rsyslog-forwarder

If the some-app charm author doesn’t define the logging-directory interface which would configure the principal charm to log files into a directory, Juju will use the less-specific juju-info interface to create a config that configures the principal charm to forward syslog to the IP of the integrated application (using the information available from the juju-info implicit integration).

Define a peer integration

See also: Peer integration

A peer integration is defined in the charmcraft.yaml. One example, a fictional database could define a replicas integration, with the interface mongodb-replica-set.

peers:
  replica-set:
    interface: mongodb-replica-set

Peering integrations are particularly useful when your application supports clustering. Consider the implications of operating applications such as MongoDB, PostgreSQL, and ElasticSearch where clusters must exchange information amongst one another to perform proper clustering.

Define integration event handlers

See also: Integration (relation) events

Define integration event handlers for a provide and requires integration

Here we will construct a trivial example of an integration. We could implement the following callback for the demo_relation_changed event:

# ...
class SpecialCharm(ops.CharmBase):
    # Set up some stored state in a private class variable
    _stored = ops.StoredState()

    def __init__(self, *args):
        super().__init__(*args)
        # ...
        # integration handling
        self.framework.observe(self.on.demo_relation_changed, self._on_demo_relation_changed)
        self.framework.observe(self.on.demo_relation_broken, self._on_demo_relation_broken)
        # Initialise our stored state
        self._stored.set_default(apps=dict())

    def _on_demo_relation_changed(self, event: ops.RelationChangedEvent) -> None:
        # Do nothing if we're not the leader
        if not self.unit.is_leader():
            return

        # Check if the remote unit has set the 'leader-uuid' field in the
        # application data bucket
        leader_uuid = event.relation.data[event.app].get("leader-uuid")
        # Store some data from the integration in local state
        self._stored.apps.update({event.relation.id: {"leader_uuid": leader_uuid}})

        # Fetch data from the unit data bag if available
        if event.unit:
            unit_field = event.relation.data[event.unit].get("special-field")
            logger.info("Got data in the unit bag from the integration: %s", unit_field)

        # Set some application data for the remote application
        # to consume. We can do this because we're the leader
        event.relation.data[self.app].update({"token": f"{uuid4()}"})

        # Do something
        self._on_config_changed(event)

    def _on_demo_relation_broken(self, event: ops.RelationBrokenEvent) -> None:
        # Remove the unit data from local state
        self._stored.apps.pop(event.relation.id, None)
        # Do something
        self._on_config_changed(event)
# ...

From the example above, note:

The callback will only run if the current unit is the application leader. The callback gets some application data from the remote unit, and stores it locally in state. It fetches unit data from the remote unit that joined the integration. It sets some application data for itself, to be consumed by the remote application.

A corresponding application that provides an interface that contains event callback which:

Sets some application data for the local application, if the unit is the leader Sets some unit data, containing just the unit’s name Fetches some application data from the remote application and stores it in state

def _on_demo_relation_changed(self, event: ops.RelationChangedEvent) -> None:
        # If we're the current leader
        if self.unit.is_leader():
            # Set a field in the application data bucket
            event.relation.data[self.app].update({"leader-uuid": self._stored.uuid})

        # Set a field in the unit data bucket
        event.relation.data[self.unit].update({"special-field": self.unit.name})
        # Log if we've received data over the integration
        if self._stored.token == "":
            logger.info("Got a new token from '%s'", event.app.name)
            # Get some info from the integration and store it in state
            self._stored.token = event.relation.data[event.app].get("token")

Define integration event handlers for a peer integration

This example illustrates a peer integration, where the events can be observed using juju debug-log. The logging from the charm will demonstrate how each charm is notified of peers leaving/joining, and how the integration data is eventually consistent between units.

The charm code:

# ...
class DemoCharm(ops.CharmBase):
    """Charm the service."""

    _stored = ops.StoredState()

    def __init__(self, *args):
        super().__init__(*args)
        # ...
        self.framework.observe(self.on.leader_elected, self._on_leader_elected)
        self.framework.observe(self.on.replicas_relation_joined, self._on_replicas_relation_joined)
        self.framework.observe(self.on.replicas_relation_departed, self._on_replicas_relation_departed)
        self.framework.observe(self.on.replicas_relation_changed, self._on_replicas_relation_changed)

        self._stored.set_default(leader_ip="")
        # ...

    def _on_leader_elected(self, event: ops.LeaderElectedEvent) -> None:
        """Handle the leader-elected event"""
        logging.debug("Leader %s setting some data!", self.unit.name)
        # Get the peer relation object
        peer_relation = self.model.get_relation("replicas")
        # Get the bind address from the juju model
        # Convert to string as integration data must always be a string
        ip = str(self.model.get_binding(peer_relation).network.bind_address)
        # Update some data to trigger a replicas_relation_changed event
        peer_relation.data[self.app].update({"leader-ip": ip})

    def _on_replicas_relation_joined(self, event: ops.RelationJoinedEvent) -> None:
        """Handle relation-joined event for the replicas integration"""
        logger.debug("Hello from %s to %s", self.unit.name, event.unit.name)

        # Check if we're the leader
        if self.unit.is_leader():
            # Get the bind address from the juju model
            ip = str(self.model.get_binding(event.relation).network.bind_address)
            logging.debug("Leader %s setting some data!", self.unit.name)
            event.relation.data[self.app].update({"leader-ip": ip})

        # Update our unit data bucket in the integration
        event.relation.data[self.unit].update({"unit-data": self.unit.name})

    def _on_replicas_relation_departed(self, event: ops.RelationDepartedEvent) -> None:
        """Handle relation-departed event for the replicas integration"""
        logger.debug("Goodbye from %s to %s", self.unit.name, event.unit.name)

    def _on_replicas_relation_changed(self, event: ops.RelationChangedEvent) -> None:
        """Handle relation-changed event for the replicas integration"""
        logging.debug("Unit %s can see the following data: %s", self.unit.name, event.relation.data.keys())
        # Fetch an item from the application data bucket
        leader_ip_value = event.relation.data[self.app].get("leader-ip")
        # Store the latest copy locally in our state store
        if leader_ip_value and leader_ip_value != self._stored.leader_ip:
            self._stored.leader_ip = leader_ip_value


if __name__ == "__main__":
    ops.main(DemoCharm)
# ...

To illustrate how these events play out, open a separate terminal and run juju debug-log. Use the juju CLI to add units to the charm, observing the events in the debug log, then remove them and see the reverse.

More on how to handle relations

With Ops, a charm supporting a relation can interact with the relation lifecycle events by observing relation events through the framework and using the relation databags through the Relation object-oriented wrapper.

The framework populates the charm’s .on field with all relation events that a charm could receive throughout its lifecycle, so that a charm with a, say, database endpoint can do:

import ops

class MyCharm(ops.CharmBase):
    def __init__(self, framework: ops.Framework):
        ...

        self.framework.observe(self.on.database_relation_changed, self._on_database_relation_changed)
    
    def _on_database_relation_changed(self, e: ops.RelationChangedEvent):
        ...

For each relation event, Ops exposes a corresponding one: for example, *-relation-changed is wrapped by an ops.RelationChangedEvent.

Charm code can interact with the relation whose data has changed through the Relation object exposed by the event object:

    def _on_database_relation_changed(self, e: ops.RelationChangedEvent):
        relation: ops.Relation = e.relation

For example, the charm can inspect the contents of the remote unit databags:

    def _on_database_relation_changed(self, e: ops.RelationChangedEvent):
        relation: ops.Relation = e.relation
        remote_units_databags = {
            relation.data[unit] for unit in relation.units if unit.app is not self.app
        }

Or the peer unit databags:

    def _on_database_relation_changed(self, e: ops.RelationChangedEvent):
        relation: ops.Relation = e.relation
        peer_units_databags = {
            relation.data[unit] for unit in relation.units if unit.app is self.app
        }

Or the remote leader databag:

    def _on_database_relation_changed(self, e: ops.RelationChangedEvent):
        relation: ops.Relation = e.relation
        remote_app_databag = relation.data[relation.app]

Or the local application databag:

    def _on_database_relation_changed(self, e: ops.RelationChangedEvent):
        relation: ops.Relation = e.relation
        local_app_databag = relation.data[self.app]

Or the local unit databag:

    def _on_database_relation_changed(self, e: ops.RelationChangedEvent):
        relation: ops.Relation = e.relation
        local_unit_databag = relation.data[self.unit]

These objects all resemble str:str dictionaries in that the charm code can read and write to them using standard pythonic __setitem__ and __getitem__ notation.

data: RelationData
data_out = data["key"]  # results in a relation-get call
data["key"] = "data-in"  # results in a relation-set call

If the charm does not have permission to do an operation (e.g. because it is not a leader unit), an exception will be raised.

Contributors: @benhoyt, @florezal , @ghibourg, @jameinel, @jnsgruk, @ppasotti, @rbarry, @rwcarlsen, @sed-i, @tmihoc, @tony-meyer, @toto

Last updated a month ago. Help improve this document in the forum.