How to add an integration to a charm

See also: Integration (relation)

To add an integration 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.

Contributors: @florezal

Last updated 9 days ago. Help improve this document in the forum.