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 charmcraft tool. The recommended way to install it is via snap:

$ sudo snap install charmcraft --beta

Alternatively, you can use pip provided by Python3:

$ pip3 install --user charmcraft

Creating a charm

The minimal charm contains a metadata.yaml file (reference), a requirements.txt file containing the ops framework, and an executable src/charm.py file as the entry point for the charm. However, a better starting point is to use charmcraft init to initialize a new charm based on the current directory name with the recommended additional files (see charmcraft init --help for additional options).

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

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.log = logging.getLogger(self.__class__.__name__)
        self.framework.observe(self.on.update_status, self.on_update_status)

    def on_update_status(self, _event):
        with open('/proc/loadavg') as f:
            load = f.read().strip()
      
        self.log.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.config object is a dictionary representing the current configuration for the charm. It’s accessed from within charm code as self.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

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 charm’s unit object provides an is_leader() method to allow charms to ascertain whether they are running within the leader’s context:

self.unit.is_leader()

For example:

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

    def publish_hostname(self, event):
        if not self.model.unit.is_leader():
            return
        hostname = self.config['log_forwarding_hostname']
        event.relation.data[self.app]['hostname'] = hostname

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

Notes:

  • While rare, leadership can change during a hook’s execution if the charm runs some particularly long blocking code. It may be useful to use leader data to record what initialization steps the leader has taken or begun if there is a chance they will take a long time.

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]

Similar to hook, actions are registered as events, but with a suffix of “_action”, and can be observed just like any other event. The event object for an action will have a few additional attributes and methods for working with the action:

  • params[] A mapping of the parameters the action was called with.
  • log() Emit a real-time message to the caller of the action.
  • set_result() Set the final result data for the action.
  • fail() Report that the action failed, with a message.

For example:

class RepoManager(CharmBase):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.framework.observe(self.on.add_repo_action, self.on_add_repo_action)

    def on_add_repo_action(self, event):
        event.log(f"Got {event.params['repo']} and {event.params['user']}")
        if user == "admin":
            event.fail(f"Admin user repo is reserved")
        else:
            event.set_result({"repo": f"/repos/{event.params['user']}"})

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”
4 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

1 Like

Is there any reason why charm config is accessed as model.config ?
juju has model and mode-config, which are totally different from charm config.

2 Likes

Totally. I have the same question. It spontaneous makes me think I’m accessing the model config.

1 Like

Is there any reason why charm config is accessed as model.config ?
juju has model and mode-config, which are totally different from charm config.

IIRC, the thinking around that is that the model object represents everything in the Juju model from the charm’s current perspective. Charms can’t directly see model-level config, only their own charm-level config, and the hook tool used to get the charm’s config is config-get, hence model.config was chosen to reflect “the config that the charm can see in the model”.

That said, I totally agree that it’s confusing. Even though a charm author might know that they can’t directly access the model-level config, they will surely be aware that it exists. And there has also been some discussion about potentially allowing the charm access to at least some subset of the model-level config. Additionally, we also have shortcuts on the CharmBase class for the charm’s app, unit, meta, and charm dir, so it seems like it would be much more natural to include a config shortcut there so that it is accessed with self.config. That would then allow for deprecating the model.config name and moving / renaming it to make it less ambiguous (model.charm_config or maybe model.app.config come to mind).

2 Likes

That makes me feel hopeful. Makes alot more sense to me and probably appeals to less experienced developers which increases adoption.

All good there.

Submitted https://github.com/canonical/operator/pull/419 if you feel like reviewing or chiming in.

1 Like

thank you! +1’ed :slight_smile:

1 Like

Thanks for the great explaination. +1 to self.config !

1 Like

The same happened to me:

$ sudo snap install charm

I get (My system is in spanish, sorry):

$ sudo snap install charm
error: Esta revisión del snap "charm" se publicó usando el confinamiento clásico por lo cual podría
       realizar cambios arbitrarios del sistema fuera de la caja de seguridad en el que los snaps
       suelen estar confinados, lo cual podría suponer un riesgo para su sistema.

       Si lo entiende y desea continuar repita la orden incluyendo --classic.

If I use the --classic option everything seems to be fine:

$ sudo snap install charm --classic
Se ha instalado charm 2.7.8 por Canonical✓

The charm snap is no longer the recommended way to get started with operator framework charms. I updated the post to describe using charmcraft instead, and worked through updating a few of the examples to be in line with the latest version of the framework, though I’ve run out of time for today to updating all of them. I hope the getting started and first few examples are enough to get you going for now, though!

(I should note that the self.config alias is available in the master branch of the framework but hasn’t been released as of 0.10.0, so you may have to change that to self.model.config for now.)

1 Like