First steps with the Operator Framework

About

This wiki page is a collection of tips for starting out with the Operator Framework. The Operator Framework is a new approach for writing charms that’s easy for development and maintenance.

If you have an improvement to make, please click the “Edit” button and make it. If you have a question that you would like answered, please add it below.

First steps with the Operator Framework

Getting set up

The entrypoint is the charm tool. The easiest way to install it is via snap:

$ sudo snap install charm

Alternatively, you can use pip provided by Python3:

$ sudo python3 -m pip install -U charm-tools

Creating a charm

charm includes the operator-python template to create a skeleton charm. The basic syntax is:

$ charm create -t operator-python <charm>

Check charm create --help for more advanced options.

Note: if the charm you’re writing is to replace an existing charm with the Operator Framework, you’ll need to ensure that there’s a symlink for upgrade-charm in the hooks directory:

$ cd hooks ; ln -s ../src/charm.py upgrade-charm

Questions

Can a charm target Kubernetes as well as other clouds at the same time?

No, at least not at this stage.

How do I write log messages?

Use the standard Python idioms.

#! /usr/bin/env python3
# src/charm.py

import sys
sys.path.insert(0, 'lib')

from ops.charm import CharmBase
from ops.model import ActiveStatus
from ops.main import main

import logging

class LoadReporter(CharmBase):
   def __init__(self, *args, **kwargs):
      super().__init__(*args, **kwargs)
      self.framework.observe(self.on.update_status, self.on_config_change)

   def on_update_status(self, _event):
      with open('/proc/loadavg') as f:
         load = f.read().strip()
      
      logger = logging.getLogger()
      logger.info(f"/proc/loadavg: {load}")

if __name__ == "__main__":
   main(LoadReporter) # main() sets up Juju logging infrastructure

Under the hood, the framework sets up a bespoke handler to communicate with Juju during the call to ops.main.main().

How do I access config values?

The CharmBase.model.config object is a dictionary representing
the current configuration for the charm. It’s accessed from within
charm code as self.model.config[key]. The values returned by the config objects are strings. Manual validation is required (see the next section).

Here is a working demonstration charm (echo) that shows how to access config parameters:

# config.yaml
options:
   message:
      type: string
      description: Message to be reported in the unit's status
      default: ''
# src/charm.py

import sys
sys.path.insert(0, 'lib')

from ops.charm import CharmBase
from ops.model import ActiveStatus
from ops.main import main

class EchoConfig(CharmBase):
   def __init__(self, *args, **kwargs):
      super().__init__(*args, **kwargs)
      self.framework.observe(self.on.config_changed, self.on_config_change)

   def on_config_change(self, _event):
       message = self.model.config['message']
       self.unit.status = ActiveStatus(message)

if __name__ == "__main__":
   main(EchoConfig)

If you would like to play around with this yourself, take a look at the echo charm.

How do I determine if the unit is the application “leader”?

Multi-unit applications need to be aware of leadership. The Juju controller nominates one unit as the application’s leader. The leader is able to modify application-level data. If the leader becomes unavailable, Juju will replace it.

The unit object provides an is_leader() method to allow charms to ascertain whether they are running within the leader’s context:

CharmBase.model.unit.is_leader()

Within a charm.py file, that method can be accessed within each of the callback methods:

# src/charm.py

import sys
sys.path.insert(0, 'lib')

from ops.charm import CharmBase
from ops.model import ActiveStatus
from ops.main import main

class MultiUnit(CharmBase):
   def __init__(self, *args, **kwargs):
      super().__init__(*args, **kwargs)
      self.framework.observe(self.on.update_status, self.on_update_status)

   def on_update_status(self, _event):
       if not self.model.unit.is_leader():  # ①
           return                           # ②
       hostname = self.model.config['log_forwarding_hostname']
       self.model._backend.relation_set(    # ③
                                        relation="log_forwarding", 
                                        key="hostname", 
                                        value=hostname, 
                                        is_app=True
       )
       
if __name__ == "__main__":
   main(MultiUnit)

# ① This call returns True or False.
#
# ② Charms should avoid exiting with a non-zero exit code. 
#   This is interpreted by Juju as an unexpected error, sending the application into the error state.
#
# ③ Accessing self.model._backend directly is not idiomatic code.

Leaders have access to:

  • modifying relation data at the application level, via the relation-set --app hook tool
  • setting “leader data”, via the leader-set hook tool
  • setting “charm state”, via the state-set hook tool

Notes:

  • Leadership can change during a hook’s execution. Anticipate hook tools returning non-zero exit codes, even when is_leader() has returned True.

How do I write an action?

The actions.yaml file is populated with the action details, e.g.

add-repo:
  description: Add a code repository
  params:
    repo:
      type: string
      description: Name of the repository.
    user:
      type: string
      description: Name of the user that has access to the repository.
  required: [repo, user]

Actions are registered as events but with a suffix of “_action” to the name. To observe these events, add to the __init__ method of the charm class:

    self.framework.observe(self.on.add_repo_action, self)

Then add a method:

    def on_add_repo_action(self, event):

We can access the parameters that the action was called with:

user = event.params["user"]

Set the results:

event.log("something happened")

event.set_results({"key": "value"})

By default, the conclusion of the event is marked as a success. If that’s not the case, we can set it as failed:

event.fail("The action failed")

How do I open and close ports?

As of 2020-05-10T00:00:00Z, you should call open-port and close-port directly from your charm. It’s possible to use small helper functions to assist with this, or import the functionality from charm-helpers.

from subprocess import run

def _modify_port(start=None, end=None, protocol='tcp', hook_tool="open-port"):
    assert protocol in {'tcp', 'udp', 'icmp'}
    if protocol == 'icmp':
        start = None
        end = None
    
    if start and end:
        port = f"{start}-{end}/"
    elif start:
        port = f"{start}/"
    else:
        port = ""
    run([hook_tool, f"{port}{protocol}"])

def enable_ping():
    _modify_port(None, None, protocol='icmp', hook_tool="open-port")

def disable_ping():
    _modify_port(None, None, protocol='icmp', hook_tool="close-port")

def open_port(start, end=None, protocol="tcp"):
    _modify_port(start, end, protocol=protocol, hook_tool="open-port")

def close_port(start, end=None, protocol="tcp"):
    _modify_port(start, end, protocol=protocol, hook_tool="close-port")

How do I test charms?

The Operator Framework includes a testing module (ops.testing) that enables simple unit testing.

The Test Harness is meant to be used as a mocking library that mocks out the Operator Framework’s dependencies. This allows you to easily write tests that are isolated from anything beyond the framework.

Here is an example of how to use it:

# src/charm.py

import sys
sys.path.insert(0, 'lib')

from ops.charm import CharmBase

class MagicCharm(CharmBase):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.magic = 0
        self.framework.observe(self.on.update_status, self.on_update_status)

    def on_update_status(self, _event):
        self.magic += 1 
# test_charm.py
import unittest
from ops.testing import Harness
from charm import MagicCharm

class CharmTest(unittest.TestCase):
    def setUp(self):
        self.harness = Harness(MagicCharm)

    def test__on_config_changed__does_the_thing_that_we_expect(self):
        self.harness.begin()

        # Exercise
        # Simulate a configuration update to your charm
        self.harness.update_config()

        # Check if this produced the desired effect
        self.assertEqual(harness.charm.magic, 1)

Working documentation on the harness may be found here.

Write a “pod spec” for Kubernetes-based charms

A “pod spec” is a definition of a Kubernetes Pod and its associated resources. To generate one, use the pod_spec_set() method

# src/charm.py

import sys
sys.path.insert(0, 'lib')

from ops.charm import CharmBase
from ops.model import ActiveStatus
from ops.main import main

class K8sCharm(CharmBase):
   def __init__(self, *args, **kwargs):
      super().__init__(*args, **kwargs)
      self.pod.pod_spec_set(...)

To understand the pod_spec_set() method, you should review the information provided by in some related posts in Discourse. The method is a thin wrapper around the underlying hook tool set-pod-spec provided by Juju:

How to install and use Python dependencies, e.g. charmhelpers and cryptography

As of 2020-05-10T00:00:00Z, you should save a copy of your your dependencies within the ‘lib’ directory, then import them from src/charm.py. This pattern is known as “vendoring dependencies”.

Troubleshooting

This section is a note of a few things that are worth checking if things aren’t working as expected.

  • Check that the top of the charm.py contains #!/usr/bin/env python3. Juju does not have exclusive control over the system’s Python installation. Modifications to the machine’s Python installation can adversely affect charms.
  • Ensure that the hooks directory has symlinks to charm.py named install, upgrade-charm and start.
  • Check that charm.py is marked executable.

TODO

Have you played around with the Operator Framework at all? Can you answer any of these?

  • Getting Relation Data
  • Setting relation data
  • Using resources to enable offline deployments (see Pattern: using resources to support offline deployments )
  • Getting subordinate/juju-info relation data
  • Changing application-level , aka “leader” data
  • Rendering a pod spec (for k8s charms)
  • Getting network information
    • IP address for daemons to listen on
    • IP address to publish to clients
  • Opening/Closing ports
  • Adding unit tests
  • How to install and use Python dependencies, e.g. charmhelpers and cryptography
  • How to make use of StoredState to capture the status of the unit.
  • Determining if the unit is the application “leader”
5 Likes

Excellent material @timClicks!

The code examples for the given ‘task’ is exactly what I would have wished for with all frameworks prior to Operator.

I really hope the complete ‘TODO’ list will be finalized.

The charm template is a great idea @timClicks but would it be useful to have different versions for IaaS charms versus k8s charms as they’d presumably be very different? We have something that might be a useful starting point for the latter in https://github.com/sajoupa/make-k8s-charm (which was developed as a proof of concept during some k8s charm experiments) so let me know if it’s something we should follow up on.

What you and @sajoupa have done is almost the perfect foundation for adding a template to the charm utility provided by charm-tools. It’s quite easy to add a new template. See the others for reference.

@cory_fu is the primary maintainer of charm-tools (I believe) and should be able to offer any guidance necessary.

I’m slightly hesitant about baking in a new template for k8s yet, as I don’t know if the patterns are sufficiently mature to codify yet. Perhaps @jameinel & co could comment on that aspect?

While I’m more or less the primary maintainer of charm-tools, it is also co-maintained by the Kubernetes and OpenStack teams. However, in general I would say that it’s mostly in maintenance-only mode with the expectation that it will be phased out in favor of the charmcraft tool to be developed by the Charm Tech team for use with the operator framework.

New templates can be added, though, if there’s need, but I would recommend using the entry-point pattern used by the OpenStack charm templates, which can be added with a single line PR to charm-tools.

Additionally, if it’s primarily for personal use, the charm command is pluggable in the same way as juju; just name the command charm-<subcommand> instead of juju-<subcommand>. Though the plugin subcommand would need to be different from the existing subcommands, like charm create, of course.

I’m slightly hesitant about baking in a new template for k8s yet, as I don’t know if the patterns are sufficiently mature to codify yet.

Not being on the Charm Tech team, I’d defer to @jameinel but I would personally agree with this sentiment. I would even take that a bit further and say that the difference between a K8s and machine charm in the new framework would really just be down to what APIs you use and possibly what helpers you import, so having an entirely separate template (or charmcraft init option) shouldn’t really be necessary.

I think having a template as a starting point is a great thing. We’ll want to make sure we look closely at the standard templates as we discover best practices and keep the templates up to date.
I do think K8s charms tends to feel a bit different than machine charms. We’re still exploring the space, but the fact that your main point of interaction is pod.set_spec rather than the myriad of ways that you might configure a machine tend to focus the charms in a different direction. (Far more is dependent on being the leader, etc). Also as the above template points out, a common pattern is identifying the OCI resource for your pod and using it in your pod spec.

My particular concern for the exact template above is things like "you can pass something called --config to render config.yaml, but the actual syntax of what that looks like is not well clarified, and it seems to affect how other templates are rendered (each template that is rendered is passed the config map.)
There are also some minor things like “it uses os.system() for everything rather than subprocess.run()”.

I appreciate the idea of flexibility, but I’d rather have someone learn how to generate a charm layout rather than learning how to generate the config for a template to generate a charm layout. (eg, for config.yaml it seems to just include the raw content of value.juju_config, which means they need to both learn how to write everything in config.yaml and how to put that into the structure that mkc.py wants it so that it can put it into the file.)

Hi,

The gap make-k8s-charm aimed to fill was to avoid having to duplicate ~100 lines for each charm creation (which charm now mainly avoids).
All the examples I’d found previously were more advanced than what you need to just get started.
Also, when you have to manually import too much code, you end up forgetting to remove things like “YourClassNameGoesHere”.

I have a pretty simple charm to create in mind, will have a go at creating it starting with charm create, will file bug if I have suggestions !

Thanks @timClicks for the operator framework getting started…

one thing as I get started with charming via this list of instructions … is it expected that the .git/ folder is brought in with this command

charm create -t operator-python <charm>

I did this command inside an empty .git folder and then realized this was nesting a .git project within my own… my guess here is to perform the command outside of my empty project, then move the contents sans .git/ yes?

EDIT: Is there other material with this that talks about how to add say some bash related commands I want to run… do I just run them via python in the appropriate method name (ie on_install)

I first mistakenly tried adding commands to the install directly… until I noticed it seemed to not work…

#!/bin/bash

apt install libxrender1 libxtst6 libxi6

juju-log -l 'INFO' 'Dependencies installed'

curl my_setup_bundle.sh --output my_setup_bundle.sh
chmod +x my_setup_bundle.sh

juju-log -l 'INFO' 'downloaded'

# Install dependency
yes n | my_setup_bundle.sh

../src/charm.py

The template script is rather naive and doesn’t expect that you’re already within a git repository. One option is available is to delete the inner .git directory.

1 Like

As for creating a “hooks/install” and having it not called. We recently landed a patch to address this.

https://github.com/canonical/operator/pull/221
and
https://github.com/canonical/operator/pull/283

it is a little bit tricky, because ‘hooks/install’ might be a symlink to ‘src/charm.py’ or it might be a shim python script that invokes ‘src/charm.py’, etc. We believe we’ve caught all the edge cases.

With the current operator framework you need to trigger main.main() on all the hooks. You can do this with a symlink from ‘hooks/install’ (which is what I think the template does, and works with Juju 2.7 and 2.8). Or you can create a ‘dispatch’ file in your charm’s root directory and point it at src/charm.py. (Which will work with 2.8 and means we don’t need a big symlink farm.) The operator framework can handle you doing both, so if you want to get the new stuff in 2.8+ you can have both.

The current plan for evolution is to leverage dispatch as the way to make sure stuff that the charm needs are installed (similar to how the reactive framework created minimal wrappers for each hook (eg https://api.jujucharms.com/charmstore/v5/postgresql-207/archive/hooks/install)

However, if you want to have some of your charm in python, but test something in bash, you can very much create ‘dispatch’ and point it at src/charm.py, and then drop in a shell script as ‘hooks/install’. And the operator framework will exec ‘hooks/install’ and then trigger ‘on_install’ events.

I would have expected this to work, even before we landed support for it. Are you sure that ‘src/charm.py’ was marked executable?
It might be that the code that says “if invoked via ‘install’, create all the symlinks for all the other hooks” doesn’t work if you wrap it in a bash script. (it used the target of the install hook to determine what target to create for the other hooks.)

You could create them yourself, or use ‘dispatch’ instead for Juju 2.8. :slight_smile:

1 Like

Suggested edit: I believe “–classic” is required when installing charm via snap