Make your charm configurable

From Zero to Hero: Write your first Kubernetes charm > Make your charm configurable

See previous: Create a minimal Kubernetes charm

This document is part of a series, and we recommend you follow it in sequence. However, you can also jump straight in by checking out the code from the previous branches:

git clone https://github.com/canonical/juju-sdk-tutorial-k8s.git
cd juju-sdk-tutorial-k8s
git checkout 01_create_minimal_charm
git checkout -b 02_make_your_charm_configurable 

A charm might have a specific configuration that the charm developer might want to expose to the charm user so that the latter can change specific settings during runtime.

As a charm developer, it is thus important to know how to make your charm configurable.

This can be done by defining a charm configuration in a file called config.yaml and then adding configuration event handlers (‘hooks’) in the src/charm.py file.

In this part of the tutorial you will update your charm to make it possible for a charm user to change the port on which the workload application is available.

Read more: How to make your charm configurable

Contents:

  1. Define the configuration options
  2. Define the configuration event handlers
  3. Validate your charm
  4. Review the final code

Define the configuration options

To begin with, let’s define the options that will be available for configuration.

Create a file called config.yaml.

Inside this file, define a configuration option, as below. The name of your configurable option is going to be server-port. The default value is 8000 – this is the value you’re trying to allow a charm user to configure.

options:
  server-port:
    default: 8000
    description: Default port on which FastAPI is available
    type: int

Read more: options

Define the configuration event handlers

Open your src/charm.py file.

In the __init__ function, add an observer for the config_changed event and pair it with an _on_config_changed handler:

self.framework.observe(self.on.config_changed, self._on_config_changed)

Now, define the handler, as below. First, read the self.config attribute to get the new value of the setting. Then, validate that this value is allowed (or block the charm otherwise). Next, let’s log the value to the logger. Finally, since configuring something like a port affects the way we call our workload application, we also need to update our pebble configuration, which we will do via a newly created method _update_layer_and_restart that we will define shortly.

def _on_config_changed(self, event):
    port = self.config["server-port"]  # see config.yaml

    if int(port) == 22:
        self.unit.status = ops.BlockedStatus("invalid port number, 22 is reserved for SSH")
        return
    
    logger.debug("New application port is requested: %s", port)
    self._update_layer_and_restart(None)

A charm does not know which configuration option has been changed. Thus, make sure to validate all the values. This is especially important since multiple values can be changed in one call.

In the __init__ function, add a new attribute to define a container object for your workload:

# see 'containers' in metadata.yaml
self.container = self.unit.get_container("demo-server") 

Create a new method, as below. This method will get the current Pebble layer configuration and compare the new and the existing service definitions – if they differ, it will update the layer and restart the service.

def _update_layer_and_restart(self, event) -> None:
    """Define and start a workload using the Pebble API.

    You'll need to specify the right entrypoint and environment
    configuration for your specific workload. Tip: you can see the
    standard entrypoint of an existing container using docker inspect

    Learn more about Pebble layers at https://github.com/canonical/pebble
    """

    # Learn more about statuses in the SDK docs:
    # https://juju.is/docs/sdk/constructs#heading--statuses
    self.unit.status = ops.MaintenanceStatus("Assembling pod spec")
    new_layer = self._pebble_layer.to_dict()
    try:
        # Get the current pebble layer config
        services = self.container.get_plan().to_dict().get("services", {})
        if services != new_layer["services"]:
            # Changes were made, add the new layer
            self.container.add_layer("fastapi_demo", self._pebble_layer, combine=True)
            logger.info("Added updated layer 'fastapi_demo' to Pebble plan")

            self.container.restart(self.pebble_service_name)
            logger.info(f"Restarted '{self.pebble_service_name}' service")

        self.unit.status = ops.ActiveStatus()
    except ops.pebble.ConnectionError:
        self.unit.status = ops.WaitingStatus("Waiting for Pebble in workload container")

Now, crucially, update the _pebble_layer property to make the layer definition dynamic, as shown below. This will replace the static port 8000 with f"--port={self.config['server-port']}".

command = " ".join(
    [
        "uvicorn",
        "api_demo_server.app:app",
        "--host=0.0.0.0",
        f"--port={self.config['server-port']}",
    ]
)

As you may have noticed, the new _update_layer_and_restart method looks like a more advanced variant of the existing _on_demo_server_pebble_ready method. Remove the _on_demo_server_pebble_ready method and, in the __init__ method, bind the new _update_layer_and_restart method in the event observer instead. The final __init__ method should look as below:

def __init__(self, *args):
    super().__init__(*args)
    self.pebble_service_name = "fastapi-service"
    self.container = self.unit.get_container("demo-server")  # see 'containers' in metadata.yaml
    self.framework.observe(self.on.demo_server_pebble_ready, self._update_layer_and_restart)
    self.framework.observe(self.on.config_changed, self._on_config_changed)

Validate your charm

First, repack and refresh your charm:

charmcraft pack
juju refresh \
  --path="./demo-api-charm_ubuntu-22.04-amd64.charm" \
  demo-api-charm --force-units --resource \
  demo-server-image=ghcr.io/canonical/api_demo_server:1.0.1

Now, check the available configuration options:

juju config demo-api-charm

Our newly defined server-port option is there. Let’s try to configure it to something else, e.g., 5000:

juju config demo-api-charm server-port=5000

Now, let’s validate that the app is actually running and reachable on the new port by sending the HTTP request below, where 10.1.157.74 is the IP of our pod and 5000 is the new application port:

curl 10.1.157.74:5000/version

You should see JSON string with the version of the application: {"version":"1.0.1"}

Let’s also verify that our invalid port number check works by setting the port to 22 and then running juju status:

juju config demo-api-charm server-port=22
juju status

As expected, the application is indeed in the blocked state:

Model        Controller           Cloud/Region        Version  SLA          Timestamp
charm-model  tutorial-controller  microk8s/localhost  3.0.0    unsupported  18:19:24+01:00

App             Version  Status   Scale  Charm           Channel  Rev  Address         Exposed  Message
demo-api-charm           blocked      1  demo-api-charm             2  10.152.183.215  no       invalid port number, 22 is reserved for SSH

Unit               Workload  Agent  Address      Ports  Message
demo-api-charm/0*  blocked   idle   10.1.157.74         invalid port number, 22 is reserved for SSH

Congratulations, you now know how to make your charm configurable!

Review the final code

For the full code see: 02_make_your_charm_configurable

For a comparative view of the code before and after this doc see: Comparison

See next: Expose the version of the application behind your charm

Contributors: @beliaev-maksim

Last updated 9 days ago. Help improve this document in the forum.