See first: Juju | Relation (integration), Juju | How to manage relations
To add a 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:
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
> Keypeers
,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