Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
cc1a517
update k8s-1-minimal with setting workload version
tromai Jun 10, 2026
9381cf9
update tutorial for creating a minimal kubernetes charm
tromai Jun 10, 2026
2234b14
update docs creating minimal k8s charm
tromai Jun 10, 2026
c448138
fix typo
tromai Jun 10, 2026
6f5e0e2
merge setting workload version step to write your charm section
tromai Jun 11, 2026
601f883
rewrite the explanation about HTTP requests between workload and charm
tromai Jun 11, 2026
1bf544f
WIP:
tromai Jun 11, 2026
f41826e
fix format of k8s-2-configurable
tromai Jun 11, 2026
e522d58
address feedback from Ben
tromai Jun 15, 2026
035a521
remove hardcode version string
tromai Jun 15, 2026
5ac4068
Merge remote-tracking branch 'origin/main' into grab-workload-version…
tromai Jun 15, 2026
80bbc83
add instruction to add requests as a dependency in chapter 5
tromai Jun 16, 2026
050a5d9
Merge branch 'main' into grab-workload-version-in-k8s-tutorial
tromai Jun 18, 2026
93f8331
Merge branch 'main' into grab-workload-version-in-k8s-tutorial
tromai Jun 22, 2026
5ef30e9
fix tutorial chapter 1 wordings from PR feedback
tromai Jun 22, 2026
e25cec0
use an obvious mock version number in mock_get_version
tromai Jun 22, 2026
e2cd1b6
use direct version matching in workload version integration tests
tromai Jun 22, 2026
54d186e
put error handling into get_version method; let JSONDecodeError bubbl…
tromai Jun 22, 2026
0908fdb
fix linting issues for chapter 5 example code
tromai Jun 22, 2026
8d596b3
remove mock_version from test_get_db_info* test functions
tromai Jun 23, 2026
38fff1d
update wording following pr feedbacks
tromai Jun 23, 2026
49e6a12
remove newlines from _replan_workload functions
tromai Jun 23, 2026
272592c
return the BlockedStatus block to _replan_workload from chapter 3 onw…
tromai Jun 23, 2026
7734bdc
Merge branch 'main' into grab-workload-version-in-k8s-tutorial
tromai Jun 24, 2026
befcb27
address PR feedback
tromai Jun 24, 2026
6ac7f7b
Merge branch 'main' into grab-workload-version-in-k8s-tutorial
tromai Jun 24, 2026
16190ec
improve the text on how workload application and charm can communica…
tromai Jun 27, 2026
790f3f2
use APP_NAME instead of haredcoding the charm name in integration tests
tromai Jun 27, 2026
9efb148
use 'workload application' instead of api_demo_server to prevent conf…
tromai Jun 27, 2026
1844a5b
simplify the comment about hardcoding version in integration tests
tromai Jun 27, 2026
c97ebc4
let the errors from get_version bubble up; change from 0.0.0.0 to loc…
tromai Jun 27, 2026
1ed5882
Merge branch 'main' into grab-workload-version-in-k8s-tutorial
tromai Jun 28, 2026
1d9f2f0
Merge branch 'main' into grab-workload-version-in-k8s-tutorial
tromai Jul 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ When you deploy a Kubernetes charm, the following things happen:
1. 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.
1. 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.
1. Typically, at this point the charm will make calls to Pebble so that Pebble can configure and start the workload and begin operations.
1. During operations, the charm may need to directly communicate with the workload application. The charm container and workload container can communicate via `localhost` because they share the same pod, and containers in the same pod share the same network namespace.

> Note: In the past, the containers were specified in a `metadata.yaml` file, but the modern practice is that all charm specification is in a single `charmcraft.yaml` file.

Expand All @@ -22,7 +23,7 @@ All subsequent workload management happens in the same way -- the Juju controlle

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.yaml` file mentioned above) that give Juju, Python, or Charmcraft various bits of information about your charm, and
- descriptive files (e.g., YAML configuration files like the `charmcraft.yaml` file mentioned above) that give Juju, Python, or Charmcraft various bits of information about your charm, and
- executable files (like the `src/charm.py` file that we will see shortly) where you will use Ops-enriched Python to write all the logic of your charm.

## Create a charm project
Expand All @@ -45,6 +46,7 @@ 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.
- `src/fastapi_demo.py` - A helper module that will contain functions for interacting with your workload application.

These files currently contain placeholder code and configuration.

Expand Down Expand Up @@ -83,6 +85,47 @@ resources:
upstream-source: ghcr.io/canonical/api_demo_server:1.0.4
```

### Write a helper module

Your charm will interact with our workload application. It's a good idea to write a helper module that wraps the workload application. Charmcraft created `src/fastapi_demo.py` as a placeholder helper module.

The helper module will be independent of the main logic of your charm. This will make it easier to test your charm. In this tutorial, the helper module only contains the logic to get the version of the workload application. The server has an endpoint at `/version` that returns a JSON payload containing the version number. This is called the workload version.

To make things easier for Juju users, your charm should expose the workload version to Juju. It will be visible in Juju's status output. For more information, see {ref}`how-to-set-the-workload-version`.

Replace the content of `src/fastapi_demo.py` with:

```python
import json
import logging
import urllib.request

logger = logging.getLogger(__name__)


def get_version(port: int) -> str:
"""Get the version of fastapi_demo that is running.

Args:
port: The port where fastapi_demo web server is listening.
"""
response = urllib.request.urlopen(f"http://localhost:{port}/version")
data = json.loads(response.read())
return data["version"]
```

Notice that the helper module is stateless. In fact, your charm as a whole will be stateless. The main logic of your charm will:

1. Receive an event from Juju.
2. Use Pebble calls and the helper module to manage the workload application and check its status.
3. Report the status back to Juju.

```{tip}
After adding code to your charm, run `tox -e format` to format the code. Then run `tox -e lint` to check the code against coding style standards and run static checks. You can run these commands from anywhere in the `~/fastapi-demo` directory in your virtual machine.

You can also run these commands in `~/k8s-tutorial` if uv and tox are available on your host machine. However, be careful when running the same tox command inside and outside your virtual machine. If tox fails with an error related to the `.tox` directory, use `-re` instead of `-e` in the commands. This recreates the tox environment.
```

### 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.
Expand All @@ -96,6 +139,8 @@ Replace the contents of `src/charm.py` with:

import ops

import fastapi_demo


class FastAPIDemoCharm(ops.CharmBase):
"""Charm the service."""
Expand Down Expand Up @@ -188,6 +233,20 @@ def _get_pebble_layer(self) -> ops.pebble.Layer:
return ops.pebble.Layer(pebble_layer)
```

### Set the workload version

The workload version is available after the workload starts, which happens after Pebble reevaluates its plan. We'll use the `src/fastapi_demo.py` helper module for this step.

In `src/charm.py`, append the following lines to the `_on_demo_server_pebble_ready` function:

```python
# Set the workload version of this charm.
version = fastapi_demo.get_version(port=8000)
self.unit.set_workload_version(version)
```
Comment thread
tromai marked this conversation as resolved.

We get the workload version over port 8000 because `_get_pebble_layer` deploys the app on this port. Then `self.unit.set_workload_version` exposes the workload version to Juju. If the `get_version` call fails (for example, an `URLError` exception is raised), the charm will go into error status. The Juju logs will show the error message, to help you debug the error.

### 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`.
Expand Down Expand Up @@ -245,22 +304,22 @@ 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.
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. The workload version is located in the app's `Version` column.

```text
Model Controller Cloud/Region Version SLA Timestamp
testing concierge-k8s k8s 3.6.13 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
fastapi-demo 1.0.4 active 1 fastapi-demo 0 10.152.183.215 no
Comment thread
tromai marked this conversation as resolved.

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.
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
Expand Down Expand Up @@ -325,12 +384,23 @@ Replace the contents of `tests/unit/test_charm.py` with:

```python
import ops
import pytest
from ops import testing

from charm import FastAPIDemoCharm


def test_pebble_layer():
def mock_get_version(port: int):
"""Get a mock version string without executing the workload code."""
return "0.0.1"


@pytest.fixture
def mock_version(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("fastapi_demo.get_version", mock_get_version)


def test_pebble_layer(mock_version):
ctx = testing.Context(FastAPIDemoCharm)
container = testing.Container(name="demo-server", can_connect=True)
state_in = testing.State(
Expand Down Expand Up @@ -361,10 +431,14 @@ def test_pebble_layer():
state_out.get_container(container.name).service_statuses["fastapi-service"]
== ops.pebble.ServiceStatus.ACTIVE
)
# Check the workload version is set
assert state_out.workload_version is not None
```

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.

In unit tests, we avoid any interaction with the outside world. The `get_version` method performs an HTTP call, which must be patched. We use the `mock_version` fixture to achieve this.

### Run the test

Run the following command from anywhere in the `~/fastapi-demo` directory:
Expand All @@ -390,10 +464,10 @@ tests/unit/test_charm.py::test_pebble_layer PASSED
unit: commands[1]> coverage report
Name Stmts Miss Branch BrPart Cover Missing
-----------------------------------------------------------------
src/charm.py 17 0 0 0 100%
src/fastapi_demo.py 4 4 0 0 0% 9-20
src/charm.py 20 0 0 0 100%
src/fastapi_demo.py 8 3 0 0 62% 35-37
-----------------------------------------------------------------
TOTAL 21 4 0 0 81%
TOTAL 28 3 0 0 89%
unit: OK (1.91=setup[0.09]+cmd[1.54,0.28] seconds)
congratulations :) (1.93 seconds)
```
Expand Down Expand Up @@ -440,6 +514,13 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju):
}
juju.deploy(charm, app=APP_NAME, resources=resources)
juju.wait(jubilant.all_active)


def test_workload_version_is_set(juju: jubilant.Juju):
# Verify that the workload version has been set.
version = juju.status().apps[APP_NAME].version
# Hardcoded version for simplicity. Ideally we'd get the version directly from the workload.
assert version == "1.0.4"
```

This test depends on two fixtures:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,9 @@ def _replan_workload(self) -> None:
logger.info(f"Replanned with '{self.pebble_service_name}' service")
except (ops.pebble.APIError, ops.pebble.ConnectionError) as e:
logger.info("Unable to connect to Pebble: %s", e)
return
version = fastapi_demo.get_version(config.server_port)
self.unit.set_workload_version(version)
```

We removed three `self.unit.status = ` lines from this version of the method. We'll handle replacing those shortly.
Comment on lines +239 to 244

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following text for this code block is now kind of contradictory -- it's explaining that we removed self.unit.status = lines (presumably switching to the collect status pattern), but we're conspicuously leaving a self.unit.state = ops.BlockedStatus without further explanation.

If this is the right pattern, then I think it could use some explanation as to why it's not replaced.

But is it the right pattern? It looks like our collect status handler will happily clobber this blocked status with active status.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But is it the right pattern? It looks like our collect status handler will happily clobber this blocked status with active status.

Is that true? I had assumed that the collect-status handler wouldn't override higher priority status with a lower priority one - but I actually don't know.

In tutorial text we say:

We also want to clean up the code to remove the places where we're setting the status outside of this method, other than anywhere we're wanting a status to show up during the event execution (such as MaintenanceStatus).

Which suggested to me that a maintenance status would survive collect-status. And so presumably blocked status would too.

I think it would be good to test this to confirm.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh but I suppose the point is that maintenance status gets overridden after the work in the event execution has completed. Hmm

Expand Down Expand Up @@ -419,7 +422,7 @@ Congratulations, your relation with PostgreSQL is functional!
Now that our charm uses `fetch_database_relation_data` to extract database authentication data and endpoint information from the relation data, we should write a test for the feature. Here, we're not testing the `fetch_database_relation_data` function directly, but rather, we're checking that the response to a Juju event is what it should be:

```python
def test_relation_data():
def test_relation_data(mock_version):
ctx = testing.Context(FastAPIDemoCharm)
relation = testing.Relation(
endpoint="database",
Expand Down Expand Up @@ -453,7 +456,7 @@ def test_relation_data():
In this chapter, we also defined a new method `_on_collect_status` that checks various things, including whether the required database relation exists. If the relation doesn't exist, we wait and set the unit status to `blocked`. We can also add a test to cover this behaviour:

```python
def test_no_database_blocked():
def test_no_database_blocked(mock_version):
ctx = testing.Context(FastAPIDemoCharm)
container = testing.Container(name="demo-server", can_connect=True)
state_in = testing.State(
Expand Down Expand Up @@ -522,6 +525,10 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju):
juju.deploy("postgresql-k8s", channel="14/stable", trust=True)
juju.integrate(APP_NAME, "postgresql-k8s")
juju.wait(jubilant.all_active)

version = juju.status().apps[APP_NAME].version
# Hardcoded version for simplicity. Ideally we'd get the version directly from the workload.
assert version == "1.0.4"
```

This test depends on the `charm` fixture so that the test fails immediately if a `.charm` file isn't available.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,13 @@ def _replan_workload(self) -> None:
# service if required.
self.container.replan()
logger.info(f"Replanned with '{self.pebble_service_name}' service")

self.unit.status = ops.ActiveStatus()
except (ops.pebble.APIError, ops.pebble.ConnectionError) as e:
logger.info("Unable to connect to Pebble: %s", e)
self.unit.status = ops.MaintenanceStatus("Waiting for Pebble in workload container")
return
self.unit.status = ops.ActiveStatus()
version = fastapi_demo.get_version(config.server_port)
self.unit.set_workload_version(version)
Comment on lines +134 to +136

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very minor, but perhaps self.unit.status = ops.ActiveStatus() should be the very last line?

@tromai tromai Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, initially self.unit.status = ops.ActiveStatus() is the last line.

However, we decided that putting self.unit.set_workload_version(version) last would simplify the tutorial, and went with it.

In chapter 1, section Set the workload version, we can simply write

In `src/charm.py`, append the following lines to the `_on_demo_server_pebble_ready` function:

```python
# Set the workload version of this charm.
version = fastapi_demo.get_version(port=8000)
self.unit.set_workload_version(version)
```

And this code propagated into Chapter 2 as you see here, and I kept it like that.

```

When the config is loaded as part of creating the Pebble layer, if the config is invalid (in our case, if the port is set to 22), then a `ValueError` will be raised. The `_replan_workload` method handles that by logging the error and setting the status of the unit to blocked, letting the Juju user know that they need to take action.
Expand Down Expand Up @@ -242,7 +244,7 @@ Since we added a new feature to configure `server-port` and use it in the Pebble
First, we'll add a test that sets the port in the input state and asserts that the port is used in the service's command in the container layer:

```python
def test_config_changed():
def test_config_changed(mock_version):
Comment thread
tromai marked this conversation as resolved.
ctx = testing.Context(FastAPIDemoCharm)
container = testing.Container(name="demo-server", can_connect=True)
state_in = testing.State(
Expand All @@ -260,10 +262,12 @@ def test_config_changed():
assert "--port=8080" in command
```

We need the `mock_version` fixture because `_on_config_changed` calls `_replan_workload`, which gets the workload version using `fastapi_demo.get_version`. The fixture patches `get_version` to avoid making a real HTTP call, so that the unit test deterministic.

In `_on_config_changed`, we specifically don't allow port 22 to be used. If port 22 is configured, we set the unit status to `blocked`. So, we can add a test to cover this behaviour by setting the port to 22 in the input state and asserting that the unit status is blocked:

```python
def test_config_changed_invalid_port():
def test_config_changed_invalid_port(mock_version):
ctx = testing.Context(FastAPIDemoCharm)
container = testing.Container(name="demo-server", can_connect=True)
state_in = testing.State(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,13 @@ Let's write some integration tests to check that our application works correctly

The existing integration tests use a model that is created by the `juju` fixture. We'll define a similar fixture that creates a separate model for COS Lite.

First, in `tests/integration/test_charm.py`, import `json` and `time` from the standard library. Then import `pytest`, `pytest_jubilant`, and `requests`. Your imports should now look like this:
First, in `tests/integration/test_charm.py`, import `json` and `time` from the standard library. Then import `pytest` and `pytest_jubilant`, which are already dependencies of the integration tests. Also import `requests`, which is a new dependency. Run the following command to add `requests` as a dependency:

```text
uv add --group integration requests
```

Your imports should now look like this:

```python
import json
Expand Down Expand Up @@ -513,14 +519,14 @@ Add two test functions to `tests/integration/test_charm.py`:

```python
@pytest.mark.juju_setup
def test_deploy_cos(cos: jubilant.Juju):
def test_deploy_cos(charm: pathlib.Path, cos: jubilant.Juju):
Comment thread
james-garner-canonical marked this conversation as resolved.
"""Deploy COS Lite in a separate model."""
cos.deploy("cos-lite", trust=True)
cos.wait(jubilant.all_active, timeout=10 * 60) # Allow time for the bundle to deploy.


@pytest.mark.juju_setup
def test_integrate_loki(juju: jubilant.Juju, cos: jubilant.Juju):
def test_integrate_loki(charm: pathlib.Path, juju: jubilant.Juju, cos: jubilant.Juju):
"""Integrate our app with Loki from COS Lite."""
cos.offer("loki", endpoint="logging")
juju.integrate(APP_NAME, f"{cos.model}.loki")
Expand All @@ -533,7 +539,7 @@ def test_integrate_loki(juju: jubilant.Juju, cos: jubilant.Juju):
Add a test function to `tests/integration/test_charm.py`:

```python
def test_loki_data(cos: jubilant.Juju):
def test_loki_data(charm: pathlib.Path, cos: jubilant.Juju):
"""Use Loki's HTTP API to verify that Loki has a label for our app.

COS Lite exposes Loki's API through the Traefik load balancer. Traefik comes with an action
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ The application is set up such that, once deployed, you can access the deployed
|-|-|
| To get Prometheus metrics: | `http://<IP>:8000/metrics` |
| To get a Swagger UI to interact with API: |`http://<IP>:8000/docs`|
| To get the app's version |`http://<IP>:8000/version`|

## OCI image

Expand Down
5 changes: 5 additions & 0 deletions examples/k8s-1-minimal/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@

import ops

import fastapi_demo

# Log messages can be retrieved using juju debug-log
logger = logging.getLogger(__name__)

Expand All @@ -43,6 +45,9 @@ def _on_demo_server_pebble_ready(self, event: ops.PebbleReadyEvent) -> None:
# Learn more about statuses at
# https://documentation.ubuntu.com/juju/3.6/reference/status/
self.unit.status = ops.ActiveStatus()
# Set the workload version of this charm.
version = fastapi_demo.get_version(port=8000)
self.unit.set_workload_version(version)

def _get_pebble_layer(self) -> ops.pebble.Layer:
"""Pebble layer for the FastAPI demo services."""
Expand Down
37 changes: 37 additions & 0 deletions examples/k8s-1-minimal/src/fastapi_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env python3

# Copyright 2026 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Functions for interacting with the workload.

The intention is that this module could be used outside the context of a charm.
"""

import json
import logging
import urllib.request

logger = logging.getLogger(__name__)


def get_version(port: int) -> str:
"""Get the version of fastapi_demo that is running.

Args:
port: The port where fastapi_demo web server is listening.
"""
response = urllib.request.urlopen(f"http://localhost:{port}/version")
data = json.loads(response.read())
return data["version"]
7 changes: 7 additions & 0 deletions examples/k8s-1-minimal/tests/integration/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,10 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju):
}
juju.deploy(charm, app=APP_NAME, resources=resources)
juju.wait(jubilant.all_active)


def test_workload_version_is_set(juju: jubilant.Juju):
# Verify that the workload version has been set.
version = juju.status().apps[APP_NAME].version
# Hardcoded version for simplicity. Ideally we'd get the version directly from the workload.
assert version == "1.0.4"
Loading
Loading