To add integration capabilities to a charm, you’ll have to define the relation in your charm’s charmcraft.yaml file and then add relation event handlers in your charm’s src/charm.py
file.
See first: Juju | Relation (integration), Juju | How to manage relations
Implement the relation
Declare the relation in charmcraft.yaml
To integrate with another charm, or with itself (to communicate with other units of the same charm), declare the required and optional relations in your charm’s charmcraft.yaml
file.
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.
To exchange data with other units of the same charm, define one or more peers
endpoints including an interface name for each. Each peer relation must have an endpoint, which your charm will use to refer to the relation (as ops.Relation.name
).
peers:
replicas:
interface: charm_gossip
To exchange data with another charm, define a provides
or requires
endpoint including an interface name. By convention, the interface name should be unique in the ecosystem. Each relation must have an endpoint, which your charm will use to refer to the relation (as ops.Relation.name
).
provides:
smtp:
interface: smtp
requires:
db:
interface: postgresql
limit: 1
Note that implementing a cross-model relation is done in the same way as one between applications in the same model. The ops library does not distinguish between data from a different model or from the same model as the one the charm is deployed to.
Which side of the relation is the “provider” or the “requirer” is often arbitrary, but if one side has a workload that is a server and the other a client, then the server side should be the provider. This becomes important for how Juju sets up network permissions in cross-model relations.
See more: File ‘charmcraft.yaml’
If the relation is with a subordinate charm, make sure to set the scope
field to container
.
requires:
log-forwarder:
interface: rsyslog-forwarder
scope: container
Other than this, implement a subordinate relation in the same way as any other relation. Note however that subordinate units cannot see each other’s peer data.
See also: Charm taxonomy
Add code to use the integration
For most integrations, you will now want to progress with using the charm library recommended by the charm that you are integrating with. Read the documentation for the other charm on Charmhub and follow the instructions, which will typically involve adding a requirer object in your charm’s __init__
and then observing custom events.
In most cases, the charm library will handle observing the Juju relation events, and your charm will only need to interact with the library’s custom API. Come back to this guide when you are ready to add tests.
See more: Charmhub
If you are developing your own interface - most commonly for charm-specific peer data exchange, then you will need to observe the Juju relation events and add appropriate handlers.
Set up a relation
To do initial setup work when a charm is first integrated with another charm (or, in the case of a peer relation, when a charm is first deployed) your charm will need to observe the relation-created event. For example, a charm providing a database relation might need to create the database and credentials, so that the requirer charm can use the database. In the src/charm.py
file, in the __init__
function of your charm, set up relation-created
event observers for the relevant relations and pair those with an event handler.
The name of the event to observe is combined with the name of the endpoint. With an endpoint named “db”, to observe relation-created
, our code would look like:
framework.observe(self.on.db_relation_created, self._on_db_relation_created)
Now, in the body of the charm definition, define the event handler. In this example, if we are the leader unit, then we create a database and pass the credentials to use it to the charm on the other side via the relation data:
def _on_db_relation_created(self, event: ops.RelationCreatedEvent):
if not self.unit.is_leader():
return
credentials = self.create_database(event.app.name)
event.relation.data[event.app].update(credentials)
The event object that is passed to the handler has a relation
property, which contains an ops.Relation
object. Your charm uses this object to find out about the relation (such as which units are included, in the .units
attribute, or whether the relation is broken, in the .active
attribute) and to get and set data in the relation databag.
See more: ops.RelationCreatedEvent
To do additional setup work when each unit joins the relation (both when the charms are first integrated and when additional units are added to the charm), your charm will need to observe the relation-joined
event. In the src/charm.py
file, in the __init__
function of your charm, set up relation-joined
event observers for the relevant relations and pair those with an event handler. For example:
framework.observe(self.on.smtp_relation_joined, self._on_smtp_relation_joined)
Now, in the body of the charm definition, define the event handler. In this example, a “smtp_credentials” key is set in the unit data with the ID of a secret:
def _on_smtp_relation_joined(self, event: ops.RelationJoinedEvent):
smtp_credentials_secret_id = self.create_smtp_user(event.unit.name)
event.relation.data[event.unit]["smtp_credentials"] = smtp_credentials_secret_id
See more: ops.RelationJoinedEvent
Exchange data with other units
To use data received through the relation, have your charm observe the relation-changed
event. In the src/charm.py
file, in the __init__
function of your charm, set up relation-changed
event observers for each of the defined relations. For example:
framework.observe(self.on.replicas_relation_changed, self._update_configuration)
See more: [ops.RelationChangedEvent](Event '<relation name>-relation-changed'), Juju | Relation (integration)
Most of the time, you should use the same holistic handler as when receiving other data, such as secret-changed
and config-changed
. To access the relation(s) in your holistic handler, use the ops.Model.get_relation
method or ops.Model.relations
attribute.
See also: Juju | Holistic vs Delta Charms
If your change will have at most one relation on the endpoint, to get the Relation
object use Model.get_relation
; for example:
rel = self.model.get_relation("db")
if not rel:
# Handle the case where the relation does not yet exist.
If your charm may have multiple relations on the endpoint, to get the relation objects use Model.relations
rather than Model.get_relation
with the relation ID; for example:
for rel in self.model.relations.get('smtp', ()):
# Do something with the relation object.
Once your charm has the relation object, it can be used in exactly the same way as when received from an event.
Now, in the body of the charm definition, define the holistic event handler. In this example, we check if the relation exists yet, and for a provided secret using the ID provided in the relation data, and if we have both of those then we push that into a workload configuration:
def _update_configuration(self, _: ops.Eventbase):
# This handles secret-changed and relation-changed.
db_relation = self.model.get_relation('db')
if not db_relation:
# We’re not integrated with the database charm yet.
return
secret_id = db_relation.data[self.model.app]['credentials']
if not secret_id:
# The credentials haven’t been added to the relation by the remote app yet.
return
secret_contents = self.model.get_secret(id=secret_id).get_contents(refresh=True)
self.push_configuration(
username=secret['username'],
password=secret['password'],
)
Exchange data across the various relations
To add data to the relation databag, use the .data
attribute much as you would a dictionary, after selecting whether to write to the app databag (leaders only) or unit databag. For example, to copy a value from the charm config to the relation data:
def _on_config_changed(self, event: ops.ConfigChangedEvent):
if relation := self.model.get_relation('ingress'):
relation.data[self.app]["domain"] = self.model.config["domain"]
To read data from the relation databag, again use the .data
attribute, selecting the appropriate databag, and then using it as if it were a regular dictionary.
The charm can inspect the contents of the remote unit databags:
def _on_database_relation_changed(self, event: ops.RelationChangedEvent):
remote_units_databags = {
event.relation.data[unit] for unit in event.relation.units if unit.app is not self.app
}
Or the peer unit databags:
def _on_database_relation_changed(self, e: ops.RelationChangedEvent):
peer_units_databags = {
event.relation.data[unit] for unit in event.relation.units if unit.app is self.app
}
Or the remote leader databag:
def _on_database_relation_changed(self, event: ops.RelationChangedEvent):
remote_app_databag = event.relation.data[relation.app]
Or the local application databag:
def _on_database_relation_changed(self, event: ops.RelationChangedEvent):
local_app_databag = event.relation.data[self.app]
Or the local unit databag:
def _on_database_relation_changed(self, event: ops.RelationChangedEvent):
local_unit_databag = event.relation.data[self.unit]
If the charm does not have permission to do an operation (e.g. because it is not the leader unit), an exception will be raised.
Clean up when a relation is removed
To do clean-up work when a unit in the relation is removed (for example, removing per-unit credentials), have your charm observe the relation-departed
event. In the src/charm.py
file, in the __init__
function of your charm, set up relation-departed
event observers for the relevant relations and pair those with an event handler. For example:
framework.observe(self.on.smtp_relation_departed, self._on_smtp_relation_departed)
Now, in the body of the charm definition, define the event handler. For example:
def _on_smtp_relation_departed(self, event: ops.RelationDepartedEvent):
if self.unit != event.departing_unit:
self.remove_smtp_user(event.unit.name)
See more: ops.RelationDepartedEvent
To clean up after a relation is entirely removed, have your charm observe the relation-broken
event. In the src/charm.py
file, in the __init__
function of your charm, set up relation-broken
events for the relevant relations and pair those with an event handler. For example:
framework.observe(self.on.db_relation_broken, self._on_db_relation_broken)
Now, in the body of the charm definition, define the event handler. For example:
def _on_db_relation_broken(self, event: ops.RelationBrokenEvent):
if not self.is_leader():
return
self.drop_database(event.app.name)
See more: ops.RelationBrokenEvent
Test the relation
Write unit tests
To write unit tests covering your charm’s behaviour when working with relations, in your unit/test_charm.py
file, create a Harness
object and use it to simulate adding and removing relations, or the remote app providing data. For example:
@pytest.fixture()
def harness():
harness = testing.Harness(MyCharm)
yield harness
harness.cleanup()
def test_new_smtp_relation(harness):
# Before the test begins, we have integrated a remote app
# with this charm, so we call add_relation() before begin().
relation_id = harness.add_relation('smtp', 'consumer_app')
harness.begin()
# For the test, we simulate a unit joining the relation.
harness.add_relation_unit()
assert 'smtp_credentials’ in harness.get_relation_data(relation_id, 'consumer_app/0' )
def test_db_relation_broken(harness):
relation_id = harness.add_relation('db', 'postgresql')
harness.begin()
harness.remove_relation(relation_id)
assert harness.charm.get_db() is None
def test_receive_db_credentials(harness):
relation_id = harness.add_relation('db', 'postgresql')
harness.begin()
harness.update_relation_data(relation_id, harness.charm.app, {'credentials-id': 'secret:xxx'})
assert harness.charm.db_tables_created()
See more: ops.testing.Harness
Write scenario tests
For each relation event that your charm observes, write at least one Scenario test. Create a Relation
object that defines the relation, include that in the input state, run the relation event, and assert that the output state is what you’d expect. For example:
ctx = scenario.Context(MyCharm)
relation = scenario.Relation(id=1, endpoint='smtp', remote_units_data={1: {}})
state_in = scenario.State(relations=[relation])
state_out = context.run(relation.joined_event(remote_unit_id=1), state=state_in)
assert 'smtp_credentials' in state_out.relations[0].remote_units_data[1]
See more: Scenario Relations
Write integration tests
Write an integration test that verifies that your charm behaves as expected in a real Juju environment. Other than when testing peer relations, as well as deploying your own charm, the test needs to deploy a second application, either the real charm or a facade that provides enough functionality to test against.
The pytest-operator plugin provides methods to deploy multiple charms. For example:
# This assumes that your integration tests already include the standard
# build and deploy test that the charmcraft profile provides.
@pytest.mark.abort_on_fail
async def test_active_when_deploy_db_facade(ops_test: OpsTest):
await ops_test.model.deploy('facade')
await ops_test.model.integrate(APP_NAME + ':postgresql', 'facade:provide-postgresql')
present_facade('postgresql', model=ops_test.model_name,
app_data={
'credentials': 'secret:abc',
})
await ops_test.model.wait_for_idle(
apps=[APP_NAME],
status='active',
timeout=600,
)
See more: pytest-operator
Contributors: @benhoyt, @florezal , @ghibourg, @jameinel, @jnsgruk, @ppasotti, @rbarry, @rwcarlsen, @sed-i, @tony-meyer, @tmihoc, @toto