Ops constructs

Ops (ops) > Ops constructs

This document provide an overview of the constructs that the Ops library makes available to charm authors.

Contents:

Main / Entrypoint

The Ops library provides a top-level main method. This method should be the main entrypoint for your charm. The method takes a charm class as an argument, and is used to initialise the charm code when the charm is invoked, and ensure that events are dispatched to the charm in response to controller-emitted events.

This method is imported and called automatically in the template charm like so:

import ops

# ... charm code

if __name__ == "__main__":
  ops.main(HelloCharm)

Charm

ops.CharmBase is the base class from which all Charms are formed. All charms written using Ops must use this abstraction. Charm authors must ensure that their Charm __init__ methods invoke super().__init__ so that the relevant framework events are defined for the new charm.

A charm’s __init__ method should be used to ensure that the charm observes all events relevant to its operation, and configures default values for any Stored State (see Framework). An example implementation of this from the charmcraft template is below:

import ops

class HelloCharm(ops.CharmBase):
  def __init__(self, *args):
    super().__init__(*args)
    # ...

Each time an event is fired by the Juju controller, the charm’s __init__ method is called, meaning that a new charm object is created each time it responds to an event. The charm is not a persistent process, which motivates the need for StoredState.

Framework

A reference to the Ops library is bound into each charm class instantiation as self.framework by the main method. This abstraction is an object-oriented representation of the framework itself, providing mechanisms for observing events, manipulating charm state, and accessing charm metadata and configuration.

Event handling

Charm authors should use the framework construct to observe framework events and bind event handlers to them:

import ops

class HelloCharm(ops.CharmBase):
  def __init__(self, *args):
    super().__init__(*args)
    self.framework.observe(self.on.config_changed, self._on_config_changed)
    self.framework.observe(self.on.fortune_action, self._on_fortune_action)

  def _on_config_changed(self, event: ops.ConfigChangedEvent):
    current = self.model.config["thing"]
# ...

In this simple initialisation we create a new charm that observes two events: config-changed and fortune-action . These are bound to private methods within the HelloCharm class, as indicated by the _ preceding the handler names. By convention, event handlers are usually private methods.

In this example, the event argument to _on_config_changed is annotated with the ConfigChangedEvent type. All events in the Ops library inherit from the EventBase class. By default it provides three methods, though defer() is the only one of those designed to be called by a charm author directly. The defer() method allows developers to put the relevant event handler/callback into a queue such that the same callback will be called again when the charm code is next invoked - where an invocation could be the result of any action or event.

The defer() method does not halt execution of the callback, and should nearly always be followed by an explicit return. When the charm is re-invoked, the callback will execute from the start, not from the point of deferral.

Developers will generally call defer() as a result of some precondition not being met - though consideration should be given to whether the particular callback should be deferred, or whether it would be better to simply wait for the event to be triggered again.

Accessing charm metadata

The self.framework construct also provides a convenient way for charm authors to access a charm’s metadata. This can be accessed either as self.framework.meta or self.meta and is an instantiation of the CharmMeta class that provides properties for all supported metadata fields, as per the specification.

Stored state

Because charms do not run as a persistent process, the Juju controller and Ops facilitate access to a per-unit state store, which can be useful for tracking configuration options and other information relevant to the current deployment of the application.

By convention, the state store should be a class attribute on the Charm class named _stored. This attribute should always be private, hence the preceding _. Charm state should not be used to track the global state of the application - such as whether an application has started, or whether a particular event has been triggered. Such behaviour will lead to brittle and unpredictable charms.

The Ops library exposes this storage through the StoredState class which can be initialised like so:

import ops
# ...

class HelloCharm(ops.CharmBase):
  # Set up the state store
  _stored = ops.StoredState()

# ...

Charm developers are recommended to provide default values for charm state attributes. This initialisation should take place in the charm’s __init__ method:

def __init__(self, *args):
  super().__init__(*args)
  # Initialise the 'things' attribute of the state to an empty list
  self._stored.set_default(things=[])

The set_default method will check the state returned from previous charm invocations, and will only create a new entry with the default value if the entry does not already exist. The set_default method does not perform any type checking. Charm authors should take care to consistently assign data of the same type to specific state attributes, or check the type of any returned values where there could be ambiguity.

Once initialised, the store can be accessed much like any other variable in Python:

# Set a value
self._stored.things = ["some", "interesting", "items"]
# Get a value
important_things = self._stored.things

Ops will only persist changes in stored state to the Juju storage backend when the lifecycle event from which it is being manipulated returns successfully. If an exception is thrown that causes the event invocation to exit early, the state will not be saved.

There is more information on when and how to use stored state (and when and how to avoid using it) in Stored State: Uses, Limitations.

Model

The Ops library enables developers to access information about the Juju model into which a charm is deployed. The model can be accessed from within a charm class with self.model (a property accessor for self.framework.model).

Applications and units

In the context of a Juju model, a charm represents a deployed application. In the case of a database, one might deploy the database application with juju deploy some-database. That application will comprise of one or more units, which represent individual running instances.

The Ops library gives charm authors access to representations of the current application and unit through self.app and self.unit (which are property accessors for self.model.app and self.model.unit respectively). These getters return the Application or Unit running the code. Charm authors can also access arbitrary applications and units from the model with the get_app() and get_unit() methods.

Each Unit, returned by self.unit or get_unit() has a name property, but also an app property which will return the Application the unit is associated with.

Planned units

Sometimes, it is useful to be able to take a peek into the future, to see how many units an application might have. An application that can run in HA mode may want to know whether it is a single unit, or part of a cluster, for example, without waiting for its peers to come online.

It is important to treat this future knowledge carefully. A human operator may change their plans, and add or remove units. Or a deploy issue may change the number and identity of units.

Given that caution, self.app.planned_units will return a count of units currently “planned” for this charm’s application, inclusive of the current unit.

A single unit application will always have a “planned unit” count of 1. An HA application will typically have a planned unit count of 3 or more. It is also possible to have a planned unit count of 0, for applications without a specific workload.

Configuration

Runtime application configuration is stored in the model; charm authors can access the configuration using the Framework’s model abstraction. An example of this is shown below:

# ...
def _on_config_changed(self, event):
  current_thing = self.model.config["thing"]
# ...

There is more information on working with configuration in the Handling configuration section

Statuses

Each unit of a deployed application is able to report its own status to the Juju controller. There are a total of six valid status types supported, though only four are accessible from charm code. Each status can be set with an accompanying message (shown in example below):

Status Description
ActiveStatus ActiveStatus is the “green status” for a charm. It signifies that the unit has successfully installed and configured the supported application, and the application has been started.
WaitingStatus Signifies that the current unit is healthy, but the charm is waiting on a process (such as a package installation). A waiting unit could also be waiting on another (already related) application, such as a database, before it is able to be configured or started by the charm. No action is expected from the administrator when this status is reported.
MaintenanceStatus Signifies that the charm has received an event which requires it to temporarily pause or stop the supported application, for example a database migration or package upgrade. A charm indicating MaintenanceStatus should be able to restore service without intervention from an administrator. This status should also be set when the stop lifecycle event is received.
BlockedStatus This is an unhealthy status, and signifies that the charm requires intervention by an administrator before it can start/restore service of the application. This status is commonly set when a charm detects a malformed or missing configuration item; or the charm requires a relation with another application which has not yet been defined by the administrator.
UnknownStatus Unknown state that is never set from Charm code. New units are deployed initially with UnknownStatus, this signifies that the charm has not yet set a status, or that the charm code has yet to be invoked.
ErrorStatus Error state that is never set from Charm code. This state is set automatically any time a lifecycle event fails to complete successfully. This is usually caused by underlying, uncaught exceptions resulting from code failures (such as incorrect syntax, insufficient bound checking etc.) or network outages.

The relevant status types are attributes of ops and can be used as follows:

import ops
# ...

class HelloCharm(ops.CharmBase):
# ...
  def _on_install(self, event):
    self.unit.status = ops.MaintenanceStatus("Installing application packages")
    # ...
    self.unit.status = ops.ActiveStatus()
# ...

The default ActiveStatus for an application should, in most cases, be set without a message. If there is a pressing need to relay information to the administrator, consider using other logging constructs, or whether indeed the application should be relaying an ActiveStatus, or reverting to MaintenanceStatus or BlockedStatus to convey information.

Containers

The model abstraction also understands the workloads that are running and, where Pebble is used, the Ops library provides methods to interact with workloads by interacting with the Pebble API. At present, only workloads of type Container are supported.

Container objects are generally accessed through a property of an event (e.g. PebbleReadyEvent) or by querying for a container by name with the Unit.get_container() method. The container name is specified in the metadata.yaml file in the containers map (more information later in Running a workload).

Here we consider an example where the workload is running in a container named pause:

# ...
def __init__(self, *args):
  super().__init__(*args)
  # See 'Running a workload' section for more information on Pebble events
  self.framework.observe(self.on.pause_pebble_ready, self._on_pause_pebble_ready)
  self.framework.observe(self.on.config_changed, self._on_config_changed)
  # ...

def _on_pause_pebble_ready(self, event: ops.PebbleReadyEvent) -> None:
  # Get a reference to the container from the PebbleReadyEvent
  container = event.workload
  # ...

def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None:
  # Get a reference to the container from the unit
  container = self.unit.get_container("<container_name>")
  # ...

The Unit class also provides a containers property that returns a dictionary of Containers, ordered by name. The Container class provides a number of helper methods for interacting with Pebble: modifying Pebble configuration, starting and stopping services, and reading or writing files or running commands on the workload container. See the How to interact with Pebble page for details and examples.

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