1067 lines
41 KiB
Python
1067 lines
41 KiB
Python
# Copyright 2019-2021 Canonical Ltd.
|
||
#
|
||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||
# you may not use this file except in compliance with the License.
|
||
# You may obtain a copy of the License at
|
||
#
|
||
# http://www.apache.org/licenses/LICENSE-2.0
|
||
#
|
||
# Unless required by applicable law or agreed to in writing, software
|
||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
# See the License for the specific language governing permissions and
|
||
# limitations under the License.
|
||
|
||
"""Base objects for the Charm, events and metadata."""
|
||
|
||
import enum
|
||
import os
|
||
import pathlib
|
||
import typing
|
||
|
||
from ops import model
|
||
from ops._private import yaml
|
||
from ops.framework import EventBase, EventSource, Framework, Object, ObjectEvents
|
||
|
||
|
||
class HookEvent(EventBase):
|
||
"""Events raised by Juju to progress a charm's lifecycle.
|
||
|
||
Hooks are callback methods of a charm class (a subclass of
|
||
:class:`CharmBase`) that are invoked in response to events raised
|
||
by Juju. These callback methods are the means by which a charm
|
||
governs the lifecycle of its application.
|
||
|
||
The :class:`HookEvent` class is the base of a type hierarchy of events
|
||
related to the charm's lifecycle.
|
||
|
||
:class:`HookEvent` subtypes are grouped into the following categories
|
||
|
||
- Core lifecycle events
|
||
- Relation events
|
||
- Storage events
|
||
- Metric events
|
||
"""
|
||
|
||
|
||
class ActionEvent(EventBase):
|
||
"""Events raised by Juju when an administrator invokes a Juju Action.
|
||
|
||
This class is the data type of events triggered when an administrator
|
||
invokes a Juju Action. Callbacks bound to these events may be used
|
||
for responding to the administrator's Juju Action request.
|
||
|
||
To read the parameters for the action, see the instance variable :attr:`params`.
|
||
To respond with the result of the action, call :meth:`set_results`. To add
|
||
progress messages that are visible as the action is progressing use
|
||
:meth:`log`.
|
||
|
||
Attributes:
|
||
params: The parameters passed to the action.
|
||
"""
|
||
|
||
def defer(self):
|
||
"""Action events are not deferable like other events.
|
||
|
||
This is because an action runs synchronously and the administrator
|
||
is waiting for the result.
|
||
"""
|
||
raise RuntimeError('cannot defer action events')
|
||
|
||
def restore(self, snapshot: dict) -> None:
|
||
"""Used by the operator framework to record the action.
|
||
|
||
Not meant to be called directly by charm code.
|
||
"""
|
||
env_action_name = os.environ.get('JUJU_ACTION_NAME')
|
||
event_action_name = self.handle.kind[:-len('_action')].replace('_', '-')
|
||
if event_action_name != env_action_name:
|
||
# This could only happen if the dev manually emits the action, or from a bug.
|
||
raise RuntimeError('action event kind does not match current action')
|
||
# Params are loaded at restore rather than __init__ because
|
||
# the model is not available in __init__.
|
||
self.params = self.framework.model._backend.action_get()
|
||
|
||
def set_results(self, results: typing.Mapping) -> None:
|
||
"""Report the result of the action.
|
||
|
||
Args:
|
||
results: The result of the action as a Dict
|
||
"""
|
||
self.framework.model._backend.action_set(results)
|
||
|
||
def log(self, message: str) -> None:
|
||
"""Send a message that a user will see while the action is running.
|
||
|
||
Args:
|
||
message: The message for the user.
|
||
"""
|
||
self.framework.model._backend.action_log(message)
|
||
|
||
def fail(self, message: str = '') -> None:
|
||
"""Report that this action has failed.
|
||
|
||
Args:
|
||
message: Optional message to record why it has failed.
|
||
"""
|
||
self.framework.model._backend.action_fail(message)
|
||
|
||
|
||
class InstallEvent(HookEvent):
|
||
"""Event triggered when a charm is installed.
|
||
|
||
This event is triggered at the beginning of a charm's
|
||
lifecycle. Any associated callback method should be used to
|
||
perform one-time setup operations, such as installing prerequisite
|
||
software.
|
||
"""
|
||
|
||
|
||
class StartEvent(HookEvent):
|
||
"""Event triggered immediately after first configuration change.
|
||
|
||
This event is triggered immediately after the first
|
||
:class:`ConfigChangedEvent`. Callback methods bound to the event should be
|
||
used to ensure that the charm’s software is in a running state. Note that
|
||
the charm’s software should be configured so as to persist in this state
|
||
through reboots without further intervention on Juju’s part.
|
||
"""
|
||
|
||
|
||
class StopEvent(HookEvent):
|
||
"""Event triggered when a charm is shut down.
|
||
|
||
This event is triggered when an application's removal is requested
|
||
by the client. The event fires immediately before the end of the
|
||
unit’s destruction sequence. Callback methods bound to this event
|
||
should be used to ensure that the charm’s software is not running,
|
||
and that it will not start again on reboot.
|
||
"""
|
||
|
||
|
||
class RemoveEvent(HookEvent):
|
||
"""Event triggered when a unit is about to be terminated.
|
||
|
||
This event fires prior to Juju removing the charm and terminating its unit.
|
||
"""
|
||
|
||
|
||
class ConfigChangedEvent(HookEvent):
|
||
"""Event triggered when a configuration change is requested.
|
||
|
||
This event fires in several different situations.
|
||
|
||
- immediately after the :class:`install <InstallEvent>` event.
|
||
- after a :class:`relation is created <RelationCreatedEvent>`.
|
||
- after a :class:`leader is elected <LeaderElectedEvent>`.
|
||
- after changing charm configuration using the GUI or command line
|
||
interface
|
||
- when the charm :class:`starts <StartEvent>`.
|
||
- when a new unit :class:`joins a relation <RelationJoinedEvent>`.
|
||
- when there is a :class:`change to an existing relation <RelationChangedEvent>`.
|
||
|
||
Any callback method bound to this event cannot assume that the
|
||
software has already been started; it should not start stopped
|
||
software, but should (if appropriate) restart running software to
|
||
take configuration changes into account.
|
||
"""
|
||
|
||
|
||
class UpdateStatusEvent(HookEvent):
|
||
"""Event triggered by a status update request from Juju.
|
||
|
||
This event is periodically triggered by Juju so that it can
|
||
provide constant feedback to the administrator about the status of
|
||
the application the charm is modeling. Any callback method bound
|
||
to this event should determine the "health" of the application and
|
||
set the status appropriately.
|
||
|
||
The interval between :class:`update-status <UpdateStatusEvent>` events can
|
||
be configured model-wide, e.g. ``juju model-config
|
||
update-status-hook-interval=1m``.
|
||
"""
|
||
|
||
|
||
class UpgradeCharmEvent(HookEvent):
|
||
"""Event triggered by request to upgrade the charm.
|
||
|
||
This event will be triggered when an administrator executes ``juju
|
||
upgrade-charm``. The event fires after Juju has unpacked the upgraded charm
|
||
code, and so this event will be handled by the callback method bound to the
|
||
event in the new codebase. The associated callback method is invoked
|
||
provided there is no existing error state. The callback method should be
|
||
used to reconcile current state written by an older version of the charm
|
||
into whatever form that is needed by the current charm version.
|
||
"""
|
||
|
||
|
||
class PreSeriesUpgradeEvent(HookEvent):
|
||
"""Event triggered to prepare a unit for series upgrade.
|
||
|
||
This event triggers when an administrator executes ``juju upgrade-series
|
||
MACHINE prepare``. The event will fire for each unit that is running on the
|
||
specified machine. Any callback method bound to this event must prepare the
|
||
charm for an upgrade to the series. This may include things like exporting
|
||
database content to a version neutral format, or evacuating running
|
||
instances to other machines.
|
||
|
||
It can be assumed that only after all units on a machine have executed the
|
||
callback method associated with this event, the administrator will initiate
|
||
steps to actually upgrade the series. After the upgrade has been completed,
|
||
the :class:`PostSeriesUpgradeEvent` will fire.
|
||
"""
|
||
|
||
|
||
class PostSeriesUpgradeEvent(HookEvent):
|
||
"""Event triggered after a series upgrade.
|
||
|
||
This event is triggered after the administrator has done a distribution
|
||
upgrade (or rolled back and kept the same series). It is called in response
|
||
to ``juju upgrade-series MACHINE complete``. Associated charm callback
|
||
methods are expected to do whatever steps are necessary to reconfigure their
|
||
applications for the new series. This may include things like populating the
|
||
upgraded version of a database. Note however charms are expected to check if
|
||
the series has actually changed or whether it was rolled back to the
|
||
original series.
|
||
"""
|
||
|
||
|
||
class LeaderElectedEvent(HookEvent):
|
||
"""Event triggered when a new leader has been elected.
|
||
|
||
Juju will trigger this event when a new leader unit is chosen for
|
||
a given application.
|
||
|
||
This event fires at least once after Juju selects a leader
|
||
unit. Callback methods bound to this event may take any action
|
||
required for the elected unit to assert leadership. Note that only
|
||
the elected leader unit will receive this event.
|
||
"""
|
||
|
||
|
||
class LeaderSettingsChangedEvent(HookEvent):
|
||
"""Event triggered when leader changes any settings.
|
||
|
||
DEPRECATED NOTICE
|
||
|
||
This event has been deprecated in favor of using a Peer relation,
|
||
and having the leader set a value in the Application data bag for
|
||
that peer relation. (see :class:`RelationChangedEvent`).
|
||
"""
|
||
|
||
|
||
class CollectMetricsEvent(HookEvent):
|
||
"""Event triggered by Juju to collect metrics.
|
||
|
||
Juju fires this event every five minutes for the lifetime of the
|
||
unit. Callback methods bound to this event may use the :meth:`add_metrics`
|
||
method of this class to send measurements to Juju.
|
||
|
||
Note that associated callback methods are currently sandboxed in
|
||
how they can interact with Juju.
|
||
"""
|
||
|
||
def add_metrics(self, metrics: typing.Mapping, labels: typing.Mapping = None) -> None:
|
||
"""Record metrics that have been gathered by the charm for this unit.
|
||
|
||
Args:
|
||
metrics: A collection of {key: float} pairs that contains the
|
||
metrics that have been gathered
|
||
labels: {key:value} strings that can be applied to the
|
||
metrics that are being gathered
|
||
"""
|
||
self.framework.model._backend.add_metrics(metrics, labels)
|
||
|
||
|
||
class RelationEvent(HookEvent):
|
||
"""A base class representing the various relation lifecycle events.
|
||
|
||
Relation lifecycle events are generated when application units
|
||
participate in relations. Units can only participate in relations
|
||
after they have been "started", and before they have been
|
||
"stopped". Within that time window, the unit may participate in
|
||
several different relations at a time, including multiple
|
||
relations with the same name.
|
||
|
||
Attributes:
|
||
relation: The :class:`~ops.model.Relation` involved in this event
|
||
app: The remote :class:`~ops.model.Application` that has triggered this
|
||
event
|
||
unit: The remote :class:`~ops.model.Unit` that has triggered this event. This may be
|
||
``None`` if the relation event was triggered as an
|
||
:class:`~ops.model.Application` level event
|
||
|
||
"""
|
||
|
||
def __init__(self, handle, relation, app=None, unit=None):
|
||
super().__init__(handle)
|
||
|
||
if unit is not None and unit.app != app:
|
||
raise RuntimeError(
|
||
'cannot create RelationEvent with application {} and unit {}'.format(app, unit))
|
||
|
||
self.relation = relation
|
||
self.app = app
|
||
self.unit = unit
|
||
|
||
def snapshot(self) -> dict:
|
||
"""Used by the framework to serialize the event to disk.
|
||
|
||
Not meant to be called by charm code.
|
||
"""
|
||
snapshot = {
|
||
'relation_name': self.relation.name,
|
||
'relation_id': self.relation.id,
|
||
}
|
||
if self.app:
|
||
snapshot['app_name'] = self.app.name
|
||
if self.unit:
|
||
snapshot['unit_name'] = self.unit.name
|
||
return snapshot
|
||
|
||
def restore(self, snapshot: dict) -> None:
|
||
"""Used by the framework to deserialize the event from disk.
|
||
|
||
Not meant to be called by charm code.
|
||
"""
|
||
self.relation = self.framework.model.get_relation(
|
||
snapshot['relation_name'], snapshot['relation_id'])
|
||
|
||
app_name = snapshot.get('app_name')
|
||
if app_name:
|
||
self.app = self.framework.model.get_app(app_name)
|
||
else:
|
||
self.app = None
|
||
|
||
unit_name = snapshot.get('unit_name')
|
||
if unit_name:
|
||
self.unit = self.framework.model.get_unit(unit_name)
|
||
else:
|
||
self.unit = None
|
||
|
||
|
||
class RelationCreatedEvent(RelationEvent):
|
||
"""Event triggered when a new relation is created.
|
||
|
||
This is triggered when a new relation to another app is added in Juju. This
|
||
can occur before units for those applications have started. All existing
|
||
relations should be established before start.
|
||
"""
|
||
|
||
|
||
class RelationJoinedEvent(RelationEvent):
|
||
"""Event triggered when a new unit joins a relation.
|
||
|
||
This event is triggered whenever a new unit of a related
|
||
application joins the relation. The event fires only when that
|
||
remote unit is first observed by the unit. Callback methods bound
|
||
to this event may set any local unit settings that can be
|
||
determined using no more than the name of the joining unit and the
|
||
remote ``private-address`` setting, which is always available when
|
||
the relation is created and is by convention not deleted.
|
||
"""
|
||
|
||
|
||
class RelationChangedEvent(RelationEvent):
|
||
"""Event triggered when relation data changes.
|
||
|
||
This event is triggered whenever there is a change to the data bucket for a
|
||
related application or unit. Look at ``event.relation.data[event.unit/app]``
|
||
to see the new information, where ``event`` is the event object passed to
|
||
the callback method bound to this event.
|
||
|
||
This event always fires once, after :class:`RelationJoinedEvent`, and
|
||
will subsequently fire whenever that remote unit changes its settings for
|
||
the relation. Callback methods bound to this event should be the only ones
|
||
that rely on remote relation settings. They should not error if the settings
|
||
are incomplete, since it can be guaranteed that when the remote unit or
|
||
application changes its settings, the event will fire again.
|
||
|
||
The settings that may be queried, or set, are determined by the relation’s
|
||
interface.
|
||
"""
|
||
|
||
|
||
class RelationDepartedEvent(RelationEvent):
|
||
"""Event triggered when a unit leaves a relation.
|
||
|
||
This is the inverse of the :class:`RelationJoinedEvent`, representing when a
|
||
unit is leaving the relation (the unit is being removed, the app is being
|
||
removed, the relation is being removed). For remaining units, this event is
|
||
emitted once for each departing unit. For departing units, this event is
|
||
emitted once for each remaining unit.
|
||
|
||
Callback methods bound to this event may be used to remove all
|
||
references to the departing remote unit, because there’s no
|
||
guarantee that it’s still part of the system; it’s perfectly
|
||
probable (although not guaranteed) that the system running that
|
||
unit has already shut down.
|
||
|
||
Once all callback methods bound to this event have been run for such a
|
||
relation, the unit agent will fire the :class:`RelationBrokenEvent`.
|
||
|
||
Attributes:
|
||
departing_unit: The :class:`~ops.model.Unit` that is departing. This
|
||
can facilitate determining e.g. whether *you* are the departing
|
||
unit.
|
||
"""
|
||
|
||
def __init__(self, handle, relation, app=None, unit=None,
|
||
departing_unit_name=None):
|
||
super().__init__(handle, relation, app=app, unit=unit)
|
||
|
||
self._departing_unit_name = departing_unit_name
|
||
|
||
@property
|
||
def departing_unit(self) -> typing.Optional[model.Unit]:
|
||
"""The `ops.model.Unit` that is departing, if any."""
|
||
# doing this on init would fail because `framework` gets patched in
|
||
# post-init
|
||
if not self._departing_unit_name:
|
||
return None
|
||
return self.framework.model.get_unit(self._departing_unit_name)
|
||
|
||
def snapshot(self) -> dict:
|
||
"""Used by the framework to serialize the event to disk.
|
||
|
||
Not meant to be called by charm code.
|
||
"""
|
||
snapshot = super().snapshot()
|
||
if self._departing_unit_name:
|
||
snapshot['departing_unit'] = self.departing_unit.name
|
||
return snapshot
|
||
|
||
def restore(self, snapshot: dict) -> None:
|
||
"""Used by the framework to deserialize the event from disk.
|
||
|
||
Not meant to be called by charm code.
|
||
"""
|
||
super().restore(snapshot)
|
||
|
||
self._departing_unit_name = snapshot.get('departing_unit')
|
||
|
||
|
||
class RelationBrokenEvent(RelationEvent):
|
||
"""Event triggered when a relation is removed.
|
||
|
||
If a relation is being removed (``juju remove-relation`` or ``juju
|
||
remove-application``), once all the units have been removed, this event will
|
||
fire to signal that the relationship has been fully terminated.
|
||
|
||
The event indicates that the current relation is no longer valid, and that
|
||
the charm’s software must be configured as though the relation had never
|
||
existed. It will only be called after every callback method bound to
|
||
:class:`RelationDepartedEvent` has been run. If a callback method
|
||
bound to this event is being executed, it is guaranteed that no remote units
|
||
are currently known locally.
|
||
"""
|
||
|
||
|
||
class StorageEvent(HookEvent):
|
||
"""Base class representing storage-related events.
|
||
|
||
Juju can provide a variety of storage types to a charms. The
|
||
charms can define several different types of storage that are
|
||
allocated from Juju. Changes in state of storage trigger sub-types
|
||
of :class:`StorageEvent`.
|
||
"""
|
||
|
||
def __init__(self, handle, storage):
|
||
super().__init__(handle)
|
||
self.storage = storage
|
||
|
||
def snapshot(self) -> dict:
|
||
"""Used by the framework to serialize the event to disk.
|
||
|
||
Not meant to be called by charm code.
|
||
"""
|
||
snapshot = {}
|
||
|
||
if isinstance(self.storage, model.Storage):
|
||
snapshot["storage_name"] = self.storage.name
|
||
snapshot["storage_index"] = self.storage.index
|
||
snapshot["storage_location"] = str(self.storage.location)
|
||
return snapshot
|
||
|
||
def restore(self, snapshot: dict) -> None:
|
||
"""Used by the framework to deserialize the event from disk.
|
||
|
||
Not meant to be called by charm code.
|
||
"""
|
||
storage_name = snapshot.get("storage_name")
|
||
storage_index = snapshot.get("storage_index")
|
||
storage_location = snapshot.get("storage_location")
|
||
|
||
if storage_name and storage_index is not None:
|
||
storages = self.framework.model.storages[storage_name]
|
||
self.storage = next((s for s in storages if s.index == storage_index), None,)
|
||
if self.storage is None:
|
||
msg = 'failed loading storage (name={!r}, index={!r}) from snapshot' \
|
||
.format(storage_name, storage_index)
|
||
raise RuntimeError(msg)
|
||
self.storage.location = storage_location
|
||
|
||
|
||
class StorageAttachedEvent(StorageEvent):
|
||
"""Event triggered when new storage becomes available.
|
||
|
||
This event is triggered when new storage is available for the
|
||
charm to use.
|
||
|
||
Callback methods bound to this event allow the charm to run code
|
||
when storage has been added. Such methods will be run before the
|
||
:class:`InstallEvent` fires, so that the installation routine may
|
||
use the storage. The name prefix of this hook will depend on the
|
||
storage key defined in the ``metadata.yaml`` file.
|
||
"""
|
||
|
||
|
||
class StorageDetachingEvent(StorageEvent):
|
||
"""Event triggered prior to removal of storage.
|
||
|
||
This event is triggered when storage a charm has been using is
|
||
going away.
|
||
|
||
Callback methods bound to this event allow the charm to run code
|
||
before storage is removed. Such methods will be run before storage
|
||
is detached, and always before the :class:`StopEvent` fires, thereby
|
||
allowing the charm to gracefully release resources before they are
|
||
removed and before the unit terminates. The name prefix of the
|
||
hook will depend on the storage key defined in the ``metadata.yaml``
|
||
file.
|
||
"""
|
||
|
||
|
||
class WorkloadEvent(HookEvent):
|
||
"""Base class representing workload-related events.
|
||
|
||
Workload events are generated for all containers that the charm
|
||
expects in metadata. Workload containers currently only trigger
|
||
a PebbleReadyEvent.
|
||
|
||
Attributes:
|
||
workload: The :class:`~ops.model.Container` involved in this event.
|
||
Workload currently only can be a Container but in future may
|
||
be other types that represent the specific workload type e.g.
|
||
a Machine.
|
||
"""
|
||
|
||
def __init__(self, handle, workload):
|
||
super().__init__(handle)
|
||
|
||
self.workload = workload
|
||
|
||
def snapshot(self) -> dict:
|
||
"""Used by the framework to serialize the event to disk.
|
||
|
||
Not meant to be called by charm code.
|
||
"""
|
||
snapshot = {}
|
||
if isinstance(self.workload, model.Container):
|
||
snapshot['container_name'] = self.workload.name
|
||
return snapshot
|
||
|
||
def restore(self, snapshot: dict) -> None:
|
||
"""Used by the framework to deserialize the event from disk.
|
||
|
||
Not meant to be called by charm code.
|
||
"""
|
||
container_name = snapshot.get('container_name')
|
||
if container_name:
|
||
self.workload = self.framework.model.unit.get_container(container_name)
|
||
else:
|
||
self.workload = None
|
||
|
||
|
||
class PebbleReadyEvent(WorkloadEvent):
|
||
"""Event triggered when pebble is ready for a workload.
|
||
|
||
This event is triggered when the Pebble process for a workload/container
|
||
starts up, allowing the charm to configure how services should be launched.
|
||
|
||
Callback methods bound to this event allow the charm to run code after
|
||
a workload has started its Pebble instance and is ready to receive instructions
|
||
regarding what services should be started. The name prefix of the hook
|
||
will depend on the container key defined in the ``metadata.yaml`` file.
|
||
"""
|
||
|
||
|
||
class CharmEvents(ObjectEvents):
|
||
"""Events generated by Juju pertaining to application lifecycle.
|
||
|
||
This class is used to create an event descriptor (``self.on``) attribute for
|
||
a charm class that inherits from :class:`CharmBase`. The event descriptor
|
||
may be used to set up event handlers for corresponding events.
|
||
|
||
By default the following events will be provided through
|
||
:class:`CharmBase`::
|
||
|
||
self.on.install
|
||
self.on.start
|
||
self.on.remove
|
||
self.on.update_status
|
||
self.on.config_changed
|
||
self.on.upgrade_charm
|
||
self.on.pre_series_upgrade
|
||
self.on.post_series_upgrade
|
||
self.on.leader_elected
|
||
self.on.collect_metrics
|
||
|
||
|
||
In addition to these, depending on the charm's metadata (``metadata.yaml``),
|
||
named relation and storage events may also be defined. These named events
|
||
are created by :class:`CharmBase` using charm metadata. The named events may be
|
||
accessed as ``self.on[<name>].<relation_or_storage_event>``
|
||
"""
|
||
|
||
install = EventSource(InstallEvent)
|
||
start = EventSource(StartEvent)
|
||
stop = EventSource(StopEvent)
|
||
remove = EventSource(RemoveEvent)
|
||
update_status = EventSource(UpdateStatusEvent)
|
||
config_changed = EventSource(ConfigChangedEvent)
|
||
upgrade_charm = EventSource(UpgradeCharmEvent)
|
||
pre_series_upgrade = EventSource(PreSeriesUpgradeEvent)
|
||
post_series_upgrade = EventSource(PostSeriesUpgradeEvent)
|
||
leader_elected = EventSource(LeaderElectedEvent)
|
||
leader_settings_changed = EventSource(LeaderSettingsChangedEvent)
|
||
collect_metrics = EventSource(CollectMetricsEvent)
|
||
|
||
|
||
class CharmBase(Object):
|
||
"""Base class that represents the charm overall.
|
||
|
||
:class:`CharmBase` is used to create a charm. This is done by inheriting
|
||
from :class:`CharmBase` and customising the sub class as required. So to
|
||
create your own charm, say ``MyCharm``, define a charm class and set up the
|
||
required event handlers (“hooks”) in its constructor::
|
||
|
||
import logging
|
||
|
||
from ops.charm import CharmBase
|
||
from ops.main import main
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
def MyCharm(CharmBase):
|
||
def __init__(self, *args):
|
||
logger.debug('Initializing Charm')
|
||
|
||
super().__init__(*args)
|
||
|
||
self.framework.observe(self.on.config_changed, self._on_config_changed)
|
||
self.framework.observe(self.on.stop, self._on_stop)
|
||
# ...
|
||
|
||
if __name__ == "__main__":
|
||
main(MyCharm)
|
||
|
||
As shown in the example above, a charm class is instantiated by
|
||
:func:`~ops.main.main` rather than charm authors directly instantiating a
|
||
charm.
|
||
|
||
Args:
|
||
framework: The framework responsible for managing the Model and events for this
|
||
charm.
|
||
key: Ignored; will remove after deprecation period of the signature change.
|
||
|
||
"""
|
||
|
||
# note that without the #: below, sphinx will copy the whole of CharmEvents
|
||
# docstring inline which is less than ideal.
|
||
#: Used to set up event handlers; see :class:`CharmEvents`.
|
||
on = CharmEvents()
|
||
|
||
def __init__(self, framework: Framework, key: typing.Optional = None):
|
||
super().__init__(framework, None)
|
||
|
||
for relation_name in self.framework.meta.relations:
|
||
relation_name = relation_name.replace('-', '_')
|
||
self.on.define_event(relation_name + '_relation_created', RelationCreatedEvent)
|
||
self.on.define_event(relation_name + '_relation_joined', RelationJoinedEvent)
|
||
self.on.define_event(relation_name + '_relation_changed', RelationChangedEvent)
|
||
self.on.define_event(relation_name + '_relation_departed', RelationDepartedEvent)
|
||
self.on.define_event(relation_name + '_relation_broken', RelationBrokenEvent)
|
||
|
||
for storage_name in self.framework.meta.storages:
|
||
storage_name = storage_name.replace('-', '_')
|
||
self.on.define_event(storage_name + '_storage_attached', StorageAttachedEvent)
|
||
self.on.define_event(storage_name + '_storage_detaching', StorageDetachingEvent)
|
||
|
||
for action_name in self.framework.meta.actions:
|
||
action_name = action_name.replace('-', '_')
|
||
self.on.define_event(action_name + '_action', ActionEvent)
|
||
|
||
for container_name in self.framework.meta.containers:
|
||
container_name = container_name.replace('-', '_')
|
||
self.on.define_event(container_name + '_pebble_ready', PebbleReadyEvent)
|
||
|
||
@property
|
||
def app(self) -> model.Application:
|
||
"""Application that this unit is part of."""
|
||
return self.framework.model.app
|
||
|
||
@property
|
||
def unit(self) -> model.Unit:
|
||
"""Unit that this execution is responsible for."""
|
||
return self.framework.model.unit
|
||
|
||
@property
|
||
def meta(self) -> 'CharmMeta':
|
||
"""Metadata of this charm."""
|
||
return self.framework.meta
|
||
|
||
@property
|
||
def charm_dir(self) -> pathlib.Path:
|
||
"""Root directory of the charm as it is running."""
|
||
return self.framework.charm_dir
|
||
|
||
@property
|
||
def config(self) -> model.ConfigData:
|
||
"""A mapping containing the charm's config and current values."""
|
||
return self.model.config
|
||
|
||
|
||
class CharmMeta:
|
||
"""Object containing the metadata for the charm.
|
||
|
||
This is read from ``metadata.yaml`` and/or ``actions.yaml``. Generally
|
||
charms will define this information, rather than reading it at runtime. This
|
||
class is mostly for the framework to understand what the charm has defined.
|
||
|
||
The :attr:`maintainers`, :attr:`tags`, :attr:`terms`, :attr:`series`, and
|
||
:attr:`extra_bindings` attributes are all lists of strings. The :attr:`containers`,
|
||
:attr:`requires`, :attr:`provides`, :attr:`peers`, :attr:`relations`,
|
||
:attr:`storages`, :attr:`resources`, and :attr:`payloads` attributes are all
|
||
mappings of names to instances of the respective :class:`RelationMeta`,
|
||
:class:`StorageMeta`, :class:`ResourceMeta`, or :class:`PayloadMeta`.
|
||
|
||
The :attr:`relations` attribute is a convenience accessor which includes all
|
||
of the ``requires``, ``provides``, and ``peers`` :class:`RelationMeta`
|
||
items. If needed, the role of the relation definition can be obtained from
|
||
its :attr:`role <RelationMeta.role>` attribute.
|
||
|
||
Attributes:
|
||
name: The name of this charm
|
||
summary: Short description of what this charm does
|
||
description: Long description for this charm
|
||
maintainers: A list of strings of the email addresses of the maintainers
|
||
of this charm.
|
||
tags: Charm store tag metadata for categories associated with this charm.
|
||
terms: Charm store terms that should be agreed to before this charm can
|
||
be deployed. (Used for things like licensing issues.)
|
||
series: The list of supported OS series that this charm can support.
|
||
The first entry in the list is the default series that will be
|
||
used by deploy if no other series is requested by the user.
|
||
subordinate: True/False whether this charm is intended to be used as a
|
||
subordinate charm.
|
||
min_juju_version: If supplied, indicates this charm needs features that
|
||
are not available in older versions of Juju.
|
||
containers: A dict of {name: :class:`ContainerMeta` } for each of the 'containers'
|
||
declared by this charm in the `matadata.yaml` file.
|
||
requires: A dict of {name: :class:`RelationMeta` } for each 'requires' relation.
|
||
provides: A dict of {name: :class:`RelationMeta` } for each 'provides' relation.
|
||
peers: A dict of {name: :class:`RelationMeta` } for each 'peer' relation.
|
||
relations: A dict containing all :class:`RelationMeta` attributes (merged from other
|
||
sections)
|
||
storages: A dict of {name: :class:`StorageMeta`} for each defined storage.
|
||
resources: A dict of {name: :class:`ResourceMeta`} for each defined resource.
|
||
payloads: A dict of {name: :class:`PayloadMeta`} for each defined payload.
|
||
extra_bindings: A dict of additional named bindings that a charm can use
|
||
for network configuration.
|
||
actions: A dict of {name: :class:`ActionMeta`} for actions that the charm has defined.
|
||
Args:
|
||
raw: a mapping containing the contents of metadata.yaml
|
||
actions_raw: a mapping containing the contents of actions.yaml
|
||
|
||
"""
|
||
|
||
def __init__(self, raw: dict = None, actions_raw: dict = None):
|
||
raw = raw or {}
|
||
actions_raw = actions_raw or {}
|
||
|
||
self.name = raw.get('name', '')
|
||
self.summary = raw.get('summary', '')
|
||
self.description = raw.get('description', '')
|
||
self.maintainers = []
|
||
if 'maintainer' in raw:
|
||
self.maintainers.append(raw['maintainer'])
|
||
if 'maintainers' in raw:
|
||
self.maintainers.extend(raw['maintainers'])
|
||
self.tags = raw.get('tags', [])
|
||
self.terms = raw.get('terms', [])
|
||
self.series = raw.get('series', [])
|
||
self.subordinate = raw.get('subordinate', False)
|
||
self.min_juju_version = raw.get('min-juju-version')
|
||
self.requires = {name: RelationMeta(RelationRole.requires, name, rel)
|
||
for name, rel in raw.get('requires', {}).items()}
|
||
self.provides = {name: RelationMeta(RelationRole.provides, name, rel)
|
||
for name, rel in raw.get('provides', {}).items()}
|
||
self.peers = {name: RelationMeta(RelationRole.peer, name, rel)
|
||
for name, rel in raw.get('peers', {}).items()}
|
||
self.relations = {}
|
||
self.relations.update(self.requires)
|
||
self.relations.update(self.provides)
|
||
self.relations.update(self.peers)
|
||
self.storages = {name: StorageMeta(name, storage)
|
||
for name, storage in raw.get('storage', {}).items()}
|
||
self.resources = {name: ResourceMeta(name, res)
|
||
for name, res in raw.get('resources', {}).items()}
|
||
self.payloads = {name: PayloadMeta(name, payload)
|
||
for name, payload in raw.get('payloads', {}).items()}
|
||
self.extra_bindings = raw.get('extra-bindings', {})
|
||
self.actions = {name: ActionMeta(name, action) for name, action in actions_raw.items()}
|
||
# This is taken from Charm Metadata v2, but only the "containers" and
|
||
# "containers.name" fields that we need right now for Pebble. See:
|
||
# https://discourse.charmhub.io/t/charm-metadata-v2/3674
|
||
self.containers = {name: ContainerMeta(name, container)
|
||
for name, container in raw.get('containers', {}).items()}
|
||
|
||
@classmethod
|
||
def from_yaml(
|
||
cls, metadata: typing.Union[str, typing.TextIO],
|
||
actions: typing.Optional[typing.Union[str, typing.TextIO]] = None):
|
||
"""Instantiate a CharmMeta from a YAML description of metadata.yaml.
|
||
|
||
Args:
|
||
metadata: A YAML description of charm metadata (name, relations, etc.)
|
||
This can be a simple string, or a file-like object. (passed to `yaml.safe_load`).
|
||
actions: YAML description of Actions for this charm (eg actions.yaml)
|
||
"""
|
||
meta = yaml.safe_load(metadata)
|
||
raw_actions = {}
|
||
if actions is not None:
|
||
raw_actions = yaml.safe_load(actions)
|
||
if raw_actions is None:
|
||
raw_actions = {}
|
||
return cls(meta, raw_actions)
|
||
|
||
|
||
class RelationRole(enum.Enum):
|
||
"""An annotation for a charm's role in a relation.
|
||
|
||
For each relation a charm's role may be
|
||
|
||
- A Peer
|
||
- A service consumer in the relation ('requires')
|
||
- A service provider in the relation ('provides')
|
||
"""
|
||
peer = 'peer'
|
||
requires = 'requires'
|
||
provides = 'provides'
|
||
|
||
def is_peer(self) -> bool:
|
||
"""Return whether the current role is peer.
|
||
|
||
A convenience to avoid having to import charm.
|
||
"""
|
||
return self is RelationRole.peer
|
||
|
||
|
||
class RelationMeta:
|
||
"""Object containing metadata about a relation definition.
|
||
|
||
Should not be constructed directly by charm code. Is gotten from one of
|
||
:attr:`CharmMeta.peers`, :attr:`CharmMeta.requires`, :attr:`CharmMeta.provides`,
|
||
or :attr:`CharmMeta.relations`.
|
||
|
||
Attributes:
|
||
role: This is :class:`RelationRole`; one of peer/requires/provides
|
||
relation_name: Name of this relation from metadata.yaml
|
||
interface_name: Optional definition of the interface protocol.
|
||
limit: Optional definition of maximum number of connections to this relation endpoint.
|
||
scope: "global" (default) or "container" scope based on how the relation should be used.
|
||
"""
|
||
|
||
VALID_SCOPES = ['global', 'container']
|
||
|
||
def __init__(self, role: RelationRole, relation_name: str, raw: dict):
|
||
if not isinstance(role, RelationRole):
|
||
raise TypeError("role should be a Role, not {!r}".format(role))
|
||
self._default_scope = self.VALID_SCOPES[0]
|
||
self.role = role
|
||
self.relation_name = relation_name
|
||
self.interface_name = raw['interface']
|
||
|
||
self.limit = raw.get('limit')
|
||
if self.limit and not isinstance(self.limit, int):
|
||
raise TypeError("limit should be an int, not {}".format(type(self.limit)))
|
||
|
||
self.scope = raw.get('scope') or self._default_scope
|
||
if self.scope not in self.VALID_SCOPES:
|
||
raise TypeError("scope should be one of {}; not '{}'".format(
|
||
', '.join("'{}'".format(s) for s in self.VALID_SCOPES), self.scope))
|
||
|
||
|
||
class StorageMeta:
|
||
"""Object containing metadata about a storage definition.
|
||
|
||
Attributes:
|
||
storage_name: Name of storage
|
||
type: Storage type
|
||
description: A text description of the storage
|
||
read_only: Whether or not the storage is read only
|
||
minimum_size: Minimum size of storage
|
||
location: Mount point of storage
|
||
multiple_range: Range of numeric qualifiers when multiple storage units are used
|
||
"""
|
||
|
||
def __init__(self, name, raw):
|
||
self.storage_name = name
|
||
self.type = raw['type']
|
||
self.description = raw.get('description', '')
|
||
self.shared = raw.get('shared', False)
|
||
self.read_only = raw.get('read-only', False)
|
||
self.minimum_size = raw.get('minimum-size')
|
||
self.location = raw.get('location')
|
||
self.multiple_range = None
|
||
if 'multiple' in raw:
|
||
range = raw['multiple']['range']
|
||
if '-' not in range:
|
||
self.multiple_range = (int(range), int(range))
|
||
else:
|
||
range = range.split('-')
|
||
self.multiple_range = (int(range[0]), int(range[1]) if range[1] else None)
|
||
|
||
|
||
class ResourceMeta:
|
||
"""Object containing metadata about a resource definition.
|
||
|
||
Attributes:
|
||
resource_name: Name of resource
|
||
filename: Name of file
|
||
description: A text description of resource
|
||
"""
|
||
|
||
def __init__(self, name, raw):
|
||
self.resource_name = name
|
||
self.type = raw['type']
|
||
self.filename = raw.get('filename', None)
|
||
self.description = raw.get('description', '')
|
||
|
||
|
||
class PayloadMeta:
|
||
"""Object containing metadata about a payload definition.
|
||
|
||
Attributes:
|
||
payload_name: Name of payload
|
||
type: Payload type
|
||
"""
|
||
|
||
def __init__(self, name, raw):
|
||
self.payload_name = name
|
||
self.type = raw['type']
|
||
|
||
|
||
class ActionMeta:
|
||
"""Object containing metadata about an action's definition."""
|
||
|
||
def __init__(self, name, raw=None):
|
||
raw = raw or {}
|
||
self.name = name
|
||
self.title = raw.get('title', '')
|
||
self.description = raw.get('description', '')
|
||
self.parameters = raw.get('params', {}) # {<parameter name>: <JSON Schema definition>}
|
||
self.required = raw.get('required', []) # [<parameter name>, ...]
|
||
|
||
|
||
class ContainerMeta:
|
||
"""Metadata about an individual container.
|
||
|
||
NOTE: this is extremely lightweight right now, and just includes the fields we need for
|
||
Pebble interaction.
|
||
|
||
Attributes:
|
||
name: Name of container (key in the YAML)
|
||
mounts: :class:`ContainerStorageMeta` mounts available to the container
|
||
"""
|
||
|
||
def __init__(self, name, raw):
|
||
self.name = name
|
||
self._mounts = {}
|
||
|
||
# This is not guaranteed to be populated/is not enforced yet
|
||
if raw:
|
||
self._populate_mounts(raw.get('mounts', []))
|
||
|
||
@property
|
||
def mounts(self) -> typing.Dict:
|
||
"""An accessor for the mounts in a container.
|
||
|
||
Dict keys match key name in :class:`StorageMeta`
|
||
|
||
Example::
|
||
|
||
storage:
|
||
foo:
|
||
type: filesystem
|
||
location: /test
|
||
containers:
|
||
bar:
|
||
mounts:
|
||
- storage: foo
|
||
- location: /test/mount
|
||
"""
|
||
return self._mounts
|
||
|
||
def _populate_mounts(self, mounts: typing.List):
|
||
"""Populate a list of container mountpoints.
|
||
|
||
Since Charm Metadata v2 specifies the mounts as a List, do a little data manipulation
|
||
to convert the values to "friendly" names which contain a list of mountpoints
|
||
under each key.
|
||
"""
|
||
for mount in mounts:
|
||
storage = mount.get("storage", "")
|
||
mount = mount.get("location", "")
|
||
|
||
if not mount:
|
||
continue
|
||
|
||
if storage in self._mounts:
|
||
self._mounts[storage].add_location(mount)
|
||
else:
|
||
self._mounts[storage] = ContainerStorageMeta(storage, mount)
|
||
|
||
|
||
class ContainerStorageMeta:
|
||
"""Metadata about storage for an individual container.
|
||
|
||
Attributes:
|
||
storage: a name for the mountpoint, which should exist the keys for :class:`StorageMeta`
|
||
for the charm
|
||
location: the location `storage` is mounted at
|
||
locations: a list of mountpoints for the key
|
||
|
||
If multiple locations are specified for the same storage, such as Kubernetes subPath mounts,
|
||
`location` will not be an accessible attribute, as it would not be possible to determine
|
||
which mount point was desired, and `locations` should be iterated over.
|
||
"""
|
||
|
||
def __init__(self, storage, location):
|
||
self.storage = storage
|
||
self._locations = [location]
|
||
|
||
def add_location(self, location):
|
||
"""Add an additional mountpoint to a known storage."""
|
||
self._locations.append(location)
|
||
|
||
@property
|
||
def locations(self) -> typing.List:
|
||
"""An accessor for the list of locations for a mount."""
|
||
return self._locations
|
||
|
||
def __getattr__(self, name):
|
||
if name == "location":
|
||
if len(self._locations) == 1:
|
||
return self._locations[0]
|
||
else:
|
||
raise RuntimeError(
|
||
"container has more than one mountpoint with the same backing storage. "
|
||
"Request .locations to see a list"
|
||
)
|
||
else:
|
||
raise AttributeError(
|
||
"{.__class__.__name__} has no such attribute: {}!".format(self, name)
|
||
)
|