Create a minimal Kubernetes charm¶
From Zero to Hero: Write your first Kubernetes charm > Create a minimal Kubernetes charm
See previous: Set up your development environment
As you already know from your knowledge of Juju, when you deploy a Kubernetes charm, the following things happen:
The Juju controller provisions a pod with at least two containers, one for the Juju unit agent and the charm itself and one container for each application workload container that is specified in the
containersfield of a file in the charm that is calledcharmcraft.yaml.The same Juju controller injects Pebble – a lightweight, API-driven process supervisor – into each workload container and overrides the container entrypoint so that Pebble starts when the container is ready.
When the Kubernetes API reports that a workload container is ready, the Juju controller informs the charm that the instance of Pebble in that container is ready. At that point, the charm knows that it can start communicating with Pebble.
Typically, at this point the charm will make calls to Pebble so that Pebble can configure and start the workload and begin operations.
Note: In the past, the containers were specified in a
metadata.yamlfile, but the modern practice is that all charm specification is in a singlecharmcraft.yamlfile.
All subsequent workload management happens in the same way – the Juju controller sends events to the charm and the charm responds to these events by managing the workload application in various ways via Pebble. The picture below illustrates all of this for a simple case where there is just one workload container.

As a charm developer, your first job is to use this knowledge to create the basic structure and content for your charm:
descriptive files (e.g., YAML configuration files like the
charmcraft.yamlfile mentioned above) that give Juju, Python, or Charmcraft various bits of information about your charm, andexecutable files (like the
src/charm.pyfile that we will see shortly) where you will use Ops-enriched Python to write all the logic of your charm.
Create a charm project¶
In your virtual machine, go into your project directory and create the initial version of your charm:
cd ~/fastapi-demo
charmcraft init --profile kubernetes
Charmcraft created several files, including:
charmcraft.yaml- Metadata about your charm. Used by Juju and Charmcraft.pyproject.toml- Python project configuration. Lists the dependencies of your charm.src/charm.py- The Python file that will contain the logic of your charm.
These files currently contain placeholder code and configuration.
Charmcraft also created a module called src/fastapi_demo.py. We won’t need this module. In general, it’s a good place to put functions that interact with the running workload.
Write your charm¶
Edit the metadata¶
Open ~/k8s-tutorial/charmcraft.yaml in your usual text editor or IDE, then change the values of title, summary, and description to:
title: Web Server Demo
summary: A demo charm that operates a small Python FastAPI server.
description: |
This charm demonstrates how to write a Kubernetes charm with Ops.
Next, describe the workload container and its OCI image.
In charmcraft.yaml, replace the containers and resources blocks with:
containers:
demo-server:
resource: demo-server-image
resources:
# An OCI image resource for the container listed above.
demo-server-image:
type: oci-image
description: OCI image from GitHub Container Repository
# The upstream-source field is ignored by Charmcraft and Juju, but it can be
# useful to developers in identifying the source of the OCI image. It is also
# used by the 'canonical/charming-actions' GitHub action for automated releases.
# The test_deploy function in tests/integration/test_charm.py reads upstream-source
# to determine which OCI image to use when running the charm's integration tests.
upstream-source: ghcr.io/canonical/api_demo_server:1.0.1
Define the charm class¶
We’ll now write the charm code that handles events from Juju. Charmcraft created src/charm.py as the location for this logic.
Replace the contents of src/charm.py with:
#!/usr/bin/env python3
"""Kubernetes charm for a demo app."""
import ops
class FastAPIDemoCharm(ops.CharmBase):
"""Charm the service."""
def __init__(self, framework: ops.Framework) -> None:
super().__init__(framework)
if __name__ == "__main__": # pragma: nocover
ops.main(FastAPIDemoCharm)
As you can see, a charm is a pure Python class that inherits from the CharmBase class of Ops and which we pass to the main function defined in the ops.main module. We’ll refer to FastAPIDemoCharm as the “charm class”.
Handle the pebble-ready event¶
In the __init__ function of your charm class, use Ops constructs to add an observer for when the Juju controller informs the charm that the Pebble in its workload container is up and running, as below. As you can see, the observer is a function that takes as an argument an event and an event handler. The event name is created automatically by Ops for each container on the template <container>-pebble-ready. The event handler is a method in your charm class that will be executed when the event is fired; in this case, you will use it to tell Pebble how to start your application.
framework.observe(self.on.demo_server_pebble_ready, self._on_demo_server_pebble_ready)
Important
Generally speaking: A charm class is a collection of event handling methods. When you want to install, remove, upgrade, configure, etc., an application, Juju sends information to your charm. Ops translates this information into events and your job is to write event handlers
Tip
Pro tip: Use __init__ to hold references (pointers) to other Objects or immutable state only. That is because a charm is reinitialised on every event.
Next, define the event handler, as follows:
We’ll use the ActiveStatus class to set the charm status to active. Note that almost everything you need to define your charm is in the ops package that you imported earlier - there’s no need to add additional imports.
Use ActiveStatus as well as further Ops constructs to define the event handler, as below. As you can see, what is happening is that, from the event argument, you extract the workload container object in which you add a custom layer. Once the layer is set you replan your service and set the charm status to active.
def _on_demo_server_pebble_ready(self, event: ops.PebbleReadyEvent) -> None:
"""Define and start a workload using the Pebble API."""
# Get a reference the container attribute on the PebbleReadyEvent
container = event.workload
# Add initial Pebble config layer using the Pebble API
container.add_layer("fastapi_demo", self._get_pebble_layer(), combine=True)
# Make Pebble reevaluate its plan, ensuring any services are started if enabled.
container.replan()
# Learn more about statuses at
# https://documentation.ubuntu.com/juju/3.6/reference/status/
self.unit.status = ops.ActiveStatus()
The custom Pebble layer that you just added is defined in the self._get_pebble_layer() method. Update this method to match your application, as follows:
In the __init__ method of your charm class, name your service to fastapi-service and add it as a class attribute :
self.pebble_service_name = "fastapi-service"
Finally, define the _get_pebble_layer function as below. The command variable represents a command line that should be executed in order to start our application.
def _get_pebble_layer(self) -> ops.pebble.Layer:
"""Pebble layer for the FastAPI demo services."""
command = " ".join(
[
"uvicorn",
"api_demo_server.app:app",
"--host=0.0.0.0",
"--port=8000",
]
)
pebble_layer: ops.pebble.LayerDict = {
"summary": "FastAPI demo service",
"description": "pebble config layer for FastAPI demo server",
"services": {
self.pebble_service_name: {
"override": "replace",
"summary": "fastapi demo",
"command": command,
"startup": "enabled",
}
},
}
return ops.pebble.Layer(pebble_layer)
Add logger functionality¶
In the imports section of src/charm.py, import the Python logging module and define a logger object, as below. This will allow you to read log data in juju.
import logging
# Log messages can be retrieved using juju debug-log
logger = logging.getLogger(__name__)
Try your charm¶
Pack your charm¶
First, ensure that you are inside the Multipass Ubuntu VM, in the ~/fastapi-demo folder:
multipass shell juju-sandbox-k8s
cd ~/fastapi-demo
Now, pack your charm project directory into a .charm file, as below. This will produce a .charm file. In our case it was named fastapi-demo_amd64.charm; yours should be named similarly, though the name might vary slightly depending on your architecture.
charmcraft pack
# Packed fastapi-demo_amd64.charm
Important
If packing failed - perhaps you forgot to make charm.py executable earlier - you may need to run charmcraft clean before re-running charmcraft pack. charmcraft will generally detect when files have changed, but will miss only file attributes changing.
Important
Did you know? A .charm file is really just a zip file of your charm files and code dependencies that makes it more convenient to share, publish, and retrieve your charm contents.
Deploy your charm¶
Deploy the .charm file, as below. Juju will create a Kubernetes StatefulSet named after your application with one replica.
juju deploy ./fastapi-demo_amd64.charm --resource \
demo-server-image=ghcr.io/canonical/api_demo_server:1.0.1
Important
If you’ve never deployed a local charm (i.e., a charm from a location on your machine) before:
As you may know, when you deploy a charm from Charmhub it is sufficient to run juju deploy <charm name>. However, to deploy a local charm you need to explicitly define a --resource parameter with the same resource name and resource upstream source as in the charmcraft.yaml.
Monitor your deployment:
juju status --watch 1s
When all units are settled down, you should see the output below, where 10.152.183.215 is the IP of the K8s Service and 10.1.157.73 is the IP of the pod.
Model Controller Cloud/Region Version SLA Timestamp
testing concierge-microk8s microk8s/localhost 3.6.12 unsupported 13:38:19+01:00
App Version Status Scale Charm Channel Rev Address Exposed Message
fastapi-demo active 1 fastapi-demo 0 10.152.183.215 no
Unit Workload Agent Address Ports Message
fastapi-demo/0* active idle 10.1.157.73
Try the web server¶
Validate that the app is running and reachable by sending an HTTP request as below, where 10.1.157.73 is the IP of our pod and 8000 is the default application port.
curl 10.1.157.73:8000/version
You should see a JSON string with the version of the application:
{"version":"1.0.0"}
Congratulations, you’ve successfully created a minimal Kubernetes charm!
Inspect your deployment further¶
Run:
kubectl get namespaces
You should see that Juju has created a namespace called testing.
Try:
kubectl -n testing get pods
You should see that your application has been deployed in a pod that has 2 containers running in it, one for the charm and one for the application. The containers talk to each other via the Pebble API using the UNIX socket.
NAME READY STATUS RESTARTS AGE
modeloperator-5df6588d89-ghxtz 1/1 Running 0 10m
fastapi-demo-0 2/2 Running 0 10m
Check also:
kubectl -n testing describe pod fastapi-demo-0
In the output you should see the definition for both containers. You’ll be able to verify that the default command and arguments for our application container (demo-server) have been displaced by the Pebble service. You should be able to verify the same for the charm container (charm).
Write unit tests for your charm¶
When you’re writing a charm, you will want to ensure that it will behave as intended.
For example, you’ll want to check that the various components – relation data, Pebble services, or configuration files – all behave as expected in response to an event.
You can ensure all this by writing a rich battery of unit tests. In the context of a charm, we recommended using pytest (unittest can also be used) with ops.testing (was: Scenario), the framework for state-transition testing in Ops.
We’ll also use the Python testing tool tox to automate our testing and set up our testing environment.
In this section we’ll write a test to check that Pebble is configured as expected.
Write a test¶
Replace the contents of tests/unit/test_charm.py with:
import ops
from ops import testing
from charm import FastAPIDemoCharm
def test_pebble_layer():
ctx = testing.Context(FastAPIDemoCharm)
container = testing.Container(name="demo-server", can_connect=True)
state_in = testing.State(
containers={container},
leader=True,
)
state_out = ctx.run(ctx.on.pebble_ready(container), state_in)
# Expected plan after Pebble ready with default config
expected_plan = {
"services": {
"fastapi-service": {
"override": "replace",
"summary": "fastapi demo",
"command": "uvicorn api_demo_server.app:app --host=0.0.0.0 --port=8000",
"startup": "enabled",
# Since the environment is empty, Layer.to_dict() will not
# include it.
}
}
}
# Check that we have the plan we expected:
assert state_out.get_container(container.name).plan == expected_plan
# Check the unit is active:
assert state_out.unit_status == testing.ActiveStatus()
# Check the service was started:
assert (
state_out.get_container(container.name).service_statuses["fastapi-service"]
== ops.pebble.ServiceStatus.ACTIVE
)
This test checks the behaviour of the _on_demo_server_pebble_ready function that you set up earlier. The test simulates your charm receiving the pebble-ready event, then checks that the unit and workload container have the correct state.
Run the test¶
Run the following command from anywhere in the ~/fastapi-demo directory:
tox -e unit
The result should be similar to the following output:
...
============================================ test session starts =============================================
platform linux -- Python 3.12.3, pytest-8.4.1, pluggy-1.6.0 -- /home/ubuntu/fastapi-demo/.tox/unit/bin/python3
cachedir: .tox/unit/.pytest_cache
rootdir: /home/ubuntu/fastapi-demo
configfile: pyproject.toml
collected 1 item
tests/unit/test_charm.py::test_pebble_layer PASSED
============================================= 1 passed in 1.21s ==============================================
unit: commands[1]> coverage report
Name Stmts Miss Branch BrPart Cover Missing
----------------------------------------------------------
src/charm.py 18 0 0 0 100%
----------------------------------------------------------
TOTAL 18 0 0 0 100%
unit: OK (4.26=setup[0.23]+cmd[3.33,0.70] seconds)
congratulations :) (4.30 seconds)
Congratulations, you have written your first unit test!
Write integration tests for your charm¶
A charm should function correctly not just in a mocked environment, but also in a real deployment.
For example, it should be able to pack, deploy, and integrate without throwing exceptions or getting stuck in a waiting or a blocked status – that is, it should correctly reach a status of active or idle.
You can ensure this by writing integration tests for your charm. In the charming world, these are usually written with the jubilant library.
In this section we’ll write a small integration test to check that the charm packs and deploys correctly.
Write a test¶
Let’s write the simplest possible integration test, a smoke test. This test will deploy the charm, then verify that the installation event is handled without errors.
Replace the contents of tests/integration/test_charm.py with:
import logging
import pathlib
import jubilant
import yaml
logger = logging.getLogger(__name__)
METADATA = yaml.safe_load(pathlib.Path("charmcraft.yaml").read_text())
APP_NAME = METADATA["name"]
def test_deploy(charm: pathlib.Path, juju: jubilant.Juju):
"""Deploy the charm under test."""
resources = {
"demo-server-image": METADATA["resources"]["demo-server-image"]["upstream-source"]
}
juju.deploy(charm.resolve(), app=APP_NAME, resources=resources)
juju.wait(jubilant.all_active)
This test depends on two fixtures, which are defined in tests/integration/conftest.py:
charm- The.charmfile to deploy.juju- A Jubilant object for interacting with a temporary Juju model.
Run the test¶
Run the following command from anywhere in the ~/fastapi-demo directory:
tox -e integration
The test takes some time to run as Jubilant adds a new model to an existing cluster (whose presence it assumes). If successful, it’ll verify that your charm can pack and deploy as expected.
The result should be similar to the following output:
...
============================= 1 passed in 55.43s =============================
integration: OK (57.79=setup[0.23]+cmd[57.57] seconds)
congratulations :) (57.84 seconds)
Tip
tox -e integration doesn’t pack your charm. If you modify the charm code and want to run the integration tests again, run charmcraft pack before tox -e integration.
Review the final code¶
For the full code, see our example charm for this chapter.
See next: Make your charm configurable