From cc1a5170fca2ab702738a496647795745297db31 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Wed, 10 Jun 2026 15:45:40 +1000 Subject: [PATCH 01/26] update k8s-1-minimal with setting workload version --- examples/k8s-1-minimal/src/charm.py | 5 +++++ .../k8s-1-minimal/tests/integration/test_charm.py | 10 ++++++++++ examples/k8s-1-minimal/tests/unit/test_charm.py | 11 ++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/examples/k8s-1-minimal/src/charm.py b/examples/k8s-1-minimal/src/charm.py index 9d670a353..cdf85238b 100755 --- a/examples/k8s-1-minimal/src/charm.py +++ b/examples/k8s-1-minimal/src/charm.py @@ -20,6 +20,8 @@ import ops +import fastapi_demo + # Log messages can be retrieved using juju debug-log logger = logging.getLogger(__name__) @@ -40,6 +42,9 @@ def _on_demo_server_pebble_ready(self, event: ops.PebbleReadyEvent) -> None: 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() + # Set the workload version of this charm. + version = fastapi_demo.get_version(port=8000) + self.unit.set_workload_version(version) # Learn more about statuses at # https://documentation.ubuntu.com/juju/3.6/reference/status/ self.unit.status = ops.ActiveStatus() diff --git a/examples/k8s-1-minimal/tests/integration/test_charm.py b/examples/k8s-1-minimal/tests/integration/test_charm.py index f3852e50f..446bc2453 100644 --- a/examples/k8s-1-minimal/tests/integration/test_charm.py +++ b/examples/k8s-1-minimal/tests/integration/test_charm.py @@ -39,3 +39,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["fastapi-demo"].version + # We'll need to update this version every time we upgrade to a new workload + # version. If the workload has an API or some other way of getting the + # version, the test should get it from there and use that to compare to the + # unit setting. + assert version == "1.0.4" diff --git a/examples/k8s-1-minimal/tests/unit/test_charm.py b/examples/k8s-1-minimal/tests/unit/test_charm.py index 03223ee54..5a523f5af 100644 --- a/examples/k8s-1-minimal/tests/unit/test_charm.py +++ b/examples/k8s-1-minimal/tests/unit/test_charm.py @@ -15,18 +15,25 @@ # To learn more about testing, see https://documentation.ubuntu.com/ops/latest/explanation/testing/ 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 "1.0.4" + + +def test_pebble_layer(monkeypatch: pytest.MonkeyPatch): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( containers={container}, leader=True, ) + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) state_out = ctx.run(ctx.on.pebble_ready(container), state_in) # Expected plan after Pebble ready with default config expected_plan = { @@ -51,3 +58,5 @@ 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 From 9381cf9c9affc7be240219d8317ccf90e14e8ef4 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Wed, 10 Jun 2026 17:18:29 +1000 Subject: [PATCH 02/26] update tutorial for creating a minimal kubernetes charm --- .../create-a-minimal-kubernetes-charm.md | 110 +++++++++++++++++- examples/k8s-1-minimal/src/fastapi_demo.py | 37 ++++++ 2 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 examples/k8s-1-minimal/src/fastapi_demo.py diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index 5a238d5ff..b5bbf3164 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -22,7 +22,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 @@ -45,6 +45,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 fastapi_demo server. These files currently contain placeholder code and configuration. @@ -260,7 +261,7 @@ 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 @@ -306,6 +307,84 @@ 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`). +### Set your workload version + +The workload version is the version of the application which our charm manages. In our case, it is the version of `fastapi_demo`, which is available at `10.1.157.73:8000/version`. To make things easier for Juju admins, our charm should expose the workload version to Juju. It will be visible in `juju status`. For more information, see {ref}`how-to-set-the-workload-version`. + +We first define a function to obtain `fastapi_demo` 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 installed. + + Args: + port: The port where fastapi_demo web server is listening. + """ + response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + data = json.loads(response.read()) + return data["version"] +``` + +From the previous, we know that our workload server is deployed at `0.0.0.0:8000`. Because the charm container is in the same pod as the workload, the charm can reach `0.0.0.0:8000`. The rest of `get_version` function takes the HTTP response, decodes the JSON payload, and extracts the version string. + +In our charm, the workload is available after we tell Pebble to reevaluate its plan, which contain the command to run the server. We can expose the workload version to Juju after that step. + +Modify the `_on_demo_server_pebble_ready` function: + +```python +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() + # Set the workload version of this charm. + version = fastapi_demo.get_version(port=8000) + self.unit.set_workload_version(version) + # Learn more about statuses at + # https://documentation.ubuntu.com/juju/3.6/reference/status/ + self.unit.status = ops.ActiveStatus() +``` + +You can then pack and refresh your charm + +``` +charmcraft pack +juju refresh fastapi-demo --force-units \ + --path ./fastapi-demo_amd64.charm \ + --resource demo-server-image=ghcr.io/canonical/api_demo_server:1.0.4 +``` + +```{tip} +`--force-units` ensures that the refresh succeeds even if the application is in an error state. This option is useful during development, when your charm code might be the cause of the error. You should avoid using `--force-units` when refreshing applications in production. +``` + +Monitor your deployment: + +```text +juju status --watch 1s +``` + +When all units are settled down, you can see the workload version in the `Version` colunm: + +```text +... + +App Version Status Scale Charm Channel Rev Address Exposed Message +fastapi-demo 1.0.4 active 1 fastapi-demo 0 10.152.183.215 no + +... +``` + (write-unit-tests-for-your-charm)= ## Write unit tests for your charm @@ -325,18 +404,25 @@ 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 "1.0.4" + + +def test_pebble_layer(monkeypatch: pytest.MonkeyPatch): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( containers={container}, leader=True, ) + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) state_out = ctx.run(ctx.on.pebble_ready(container), state_in) # Expected plan after Pebble ready with default config expected_plan = { @@ -361,6 +447,8 @@ 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. @@ -390,10 +478,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) ``` @@ -440,6 +528,16 @@ 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["fastapi-demo"].version + # We'll need to update this version every time we upgrade to a new workload + # version. If the workload has an API or some other way of getting the + # version, the test should get it from there and use that to compare to the + # unit setting. + assert version == "1.0.4" ``` This test depends on two fixtures: diff --git a/examples/k8s-1-minimal/src/fastapi_demo.py b/examples/k8s-1-minimal/src/fastapi_demo.py new file mode 100644 index 000000000..69c25b9ff --- /dev/null +++ b/examples/k8s-1-minimal/src/fastapi_demo.py @@ -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 installed. + + 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"] From 2234b14b075a58d8790d96e90c03a59af9f77c6a Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Thu, 11 Jun 2026 09:28:13 +1000 Subject: [PATCH 03/26] update docs creating minimal k8s charm --- .../create-a-minimal-kubernetes-charm.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index b5bbf3164..6a885439d 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -336,6 +336,8 @@ From the previous, we know that our workload server is deployed at `0.0.0.0:8000 In our charm, the workload is available after we tell Pebble to reevaluate its plan, which contain the command to run the server. We can expose the workload version to Juju after that step. +Then, in `src/charm.py`, add `import fastapi_demo` in the imports at the top of the file. + Modify the `_on_demo_server_pebble_ready` function: ```python From c448138a09d44bed2b091c8611781324625a0e86 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Thu, 11 Jun 2026 09:42:46 +1000 Subject: [PATCH 04/26] fix typo --- .../create-a-minimal-kubernetes-charm.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index 6a885439d..a4ba7b505 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -376,7 +376,7 @@ Monitor your deployment: juju status --watch 1s ``` -When all units are settled down, you can see the workload version in the `Version` colunm: +When all units are settled down, you can see the workload version in the `Version` column: ```text ... From 6f5e0e235bd4e12ce8ff6a105dc2b2b3e7d99176 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Thu, 11 Jun 2026 16:16:45 +1000 Subject: [PATCH 05/26] merge setting workload version step to write your charm section --- .../create-a-minimal-kubernetes-charm.md | 155 ++++++++---------- .../study-your-application.md | 1 + 2 files changed, 73 insertions(+), 83 deletions(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index a4ba7b505..87300d5d6 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -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. After that, our workload, the `api_demo_server` app, serves on the deployed IP on port 8000. The charm can reach that endpoint because our charm container and the workload container are deployed in the same pod. > 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. @@ -45,7 +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 fastapi_demo server. +- `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. @@ -84,6 +85,49 @@ resources: upstream-source: ghcr.io/canonical/api_demo_server:1.0.4 ``` +### Write a helper module + +Your charm will interact with our workload application `api_demo_server`. It’s a good idea to write a helper module that wraps `api_demo_server`. 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 `api_demo_server`. The server has an endpoint at `/version` that returns a JSON payload containing the version number (see {ref}`study-your-application`). + +This is called the workload version. To make things easier for Juju admins, our charm should expose the workload version to Juju. It will be visible in `juju status`. For more information, see {ref}`how-to-set-the-workload-version`. + +We first define a function to get 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 installed. + + Args: + port: The port where fastapi_demo web server is listening. + """ + response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + data = json.loads(response.read()) + return data["version"] +``` + +The `get_version` function sends a HTTP GET to `api_demo_server` in the workload container, decodes the result JSON payload, and extracts the version string. + +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 the functions in the helper module to manage `api_demo_server` 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. @@ -189,6 +233,31 @@ 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 is after Pebble reevaluates its plan. We will use the `src/fastapi_demo.py` helper module for this step. + +In `src/charm.py`, add `import fastapi_demo` in the imports at the top of the file. Then modify the `_on_demo_server_pebble_ready` function: + +```python +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() + # Set the workload version of this charm. + version = fastapi_demo.get_version(port=8000) + self.unit.set_workload_version(version) + # Learn more about statuses at + # https://documentation.ubuntu.com/juju/3.6/reference/status/ + self.unit.status = ops.ActiveStatus() +``` + +We invoke `fastapi_demo.get_version` to get the workload version, and expose it to Juju with `self.unit.set_workload_version`. We use port 8000 as the app is deployed on this port, as seen in `_get_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`. @@ -246,14 +315,14 @@ 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 `1.0.4`, as seen in the `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 Unit Workload Agent Address Ports Message fastapi-demo/0* active idle 10.1.157.73 @@ -307,86 +376,6 @@ 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`). -### Set your workload version - -The workload version is the version of the application which our charm manages. In our case, it is the version of `fastapi_demo`, which is available at `10.1.157.73:8000/version`. To make things easier for Juju admins, our charm should expose the workload version to Juju. It will be visible in `juju status`. For more information, see {ref}`how-to-set-the-workload-version`. - -We first define a function to obtain `fastapi_demo` 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 installed. - - Args: - port: The port where fastapi_demo web server is listening. - """ - response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") - data = json.loads(response.read()) - return data["version"] -``` - -From the previous, we know that our workload server is deployed at `0.0.0.0:8000`. Because the charm container is in the same pod as the workload, the charm can reach `0.0.0.0:8000`. The rest of `get_version` function takes the HTTP response, decodes the JSON payload, and extracts the version string. - -In our charm, the workload is available after we tell Pebble to reevaluate its plan, which contain the command to run the server. We can expose the workload version to Juju after that step. - -Then, in `src/charm.py`, add `import fastapi_demo` in the imports at the top of the file. - -Modify the `_on_demo_server_pebble_ready` function: - -```python -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() - # Set the workload version of this charm. - version = fastapi_demo.get_version(port=8000) - self.unit.set_workload_version(version) - # Learn more about statuses at - # https://documentation.ubuntu.com/juju/3.6/reference/status/ - self.unit.status = ops.ActiveStatus() -``` - -You can then pack and refresh your charm - -``` -charmcraft pack -juju refresh fastapi-demo --force-units \ - --path ./fastapi-demo_amd64.charm \ - --resource demo-server-image=ghcr.io/canonical/api_demo_server:1.0.4 -``` - -```{tip} -`--force-units` ensures that the refresh succeeds even if the application is in an error state. This option is useful during development, when your charm code might be the cause of the error. You should avoid using `--force-units` when refreshing applications in production. -``` - -Monitor your deployment: - -```text -juju status --watch 1s -``` - -When all units are settled down, you can see the workload version in the `Version` column: - -```text -... - -App Version Status Scale Charm Channel Rev Address Exposed Message -fastapi-demo 1.0.4 active 1 fastapi-demo 0 10.152.183.215 no - -... -``` - (write-unit-tests-for-your-charm)= ## Write unit tests for your charm diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/study-your-application.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/study-your-application.md index 79615a06c..b0e069453 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/study-your-application.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/study-your-application.md @@ -51,6 +51,7 @@ The application is set up such that, once deployed, you can access the deployed |-|-| | To get Prometheus metrics: | `http://:8000/metrics` | | To get a Swagger UI to interact with API: |`http://:8000/docs`| +| To get the app's version |`http://:8000/version`| ## OCI image From 601f883b37ca893526e00cfab2dcbbbf38ed28d9 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Thu, 11 Jun 2026 17:02:16 +1000 Subject: [PATCH 06/26] rewrite the explanation about HTTP requests between workload and charm --- .../create-a-minimal-kubernetes-charm.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index 87300d5d6..740eea0df 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -11,7 +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. After that, our workload, the `api_demo_server` app, serves on the deployed IP on port 8000. The charm can reach that endpoint because our charm container and the workload container are deployed in the same pod. +1. Besides Pebble exec, the charm can communicate to the workload using HTTP requests. This is possible because they share the same pod (Kubernetes charms are deployed following sidecar pattern). > 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. From 1bf544f59f7481c1564a6ecca3f72c867f47be53 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Thu, 11 Jun 2026 23:13:57 +1000 Subject: [PATCH 07/26] WIP: port the grab workfload version code to tutorial 2->5 and use auto fixture for patching get_version in unit tests and also change localhost -> 0.0.0.0 in the fastapi_demo.py modules in examples --- .../create-a-minimal-kubernetes-charm.md | 8 +++- .../integrate-your-charm-with-postgresql.md | 25 ++++++++++++- .../make-your-charm-configurable.md | 20 ++++++++-- examples/k8s-1-minimal/src/fastapi_demo.py | 2 +- .../k8s-1-minimal/tests/unit/test_charm.py | 8 +++- examples/k8s-2-configurable/src/charm.py | 20 +++++++++- .../k8s-2-configurable/src/fastapi_demo.py | 37 +++++++++++++++++++ .../tests/integration/test_charm.py | 10 +++++ .../tests/unit/test_charm.py | 12 ++++++ examples/k8s-3-postgresql/src/charm.py | 16 ++++++++ examples/k8s-3-postgresql/src/fastapi_demo.py | 37 +++++++++++++++++++ .../tests/integration/test_charm.py | 9 ++++- .../k8s-3-postgresql/tests/unit/test_charm.py | 13 +++++++ examples/k8s-4-action/src/charm.py | 16 ++++++++ examples/k8s-4-action/src/fastapi_demo.py | 37 +++++++++++++++++++ .../tests/integration/test_charm.py | 7 ++++ .../k8s-4-action/tests/unit/test_charm.py | 19 +++++++++- examples/k8s-5-observe/src/charm.py | 16 ++++++++ examples/k8s-5-observe/src/fastapi_demo.py | 37 +++++++++++++++++++ .../tests/integration/test_charm.py | 7 ++++ .../k8s-5-observe/tests/unit/test_charm.py | 11 ++++++ 21 files changed, 353 insertions(+), 14 deletions(-) create mode 100644 examples/k8s-2-configurable/src/fastapi_demo.py create mode 100644 examples/k8s-3-postgresql/src/fastapi_demo.py create mode 100644 examples/k8s-4-action/src/fastapi_demo.py create mode 100644 examples/k8s-5-observe/src/fastapi_demo.py diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index 740eea0df..1c97178de 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -406,14 +406,18 @@ def mock_get_version(port: int): return "1.0.4" -def test_pebble_layer(monkeypatch: pytest.MonkeyPatch): +@pytest.fixture(autouse=True) +def patch_get_version(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) + + +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, ) - monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) state_out = ctx.run(ctx.on.pebble_ready(container), state_in) # Expected plan after Pebble ready with default config expected_plan = { diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md index 5ceb76822..3ed645399 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md @@ -236,9 +236,21 @@ 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 + + try: + version = fastapi_demo.get_version(config.server_port) + except (urllib.error.URLError, json.JSONDecodeError) as version_e: + logger.error( + "Failed to get version from the server: %s. Please double check your port config", + version_e, + ) + return + + 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. +We removed four `self.unit.status = ` lines from this version of the method. We'll handle replacing those shortly. Next, update `_get_pebble_layer()` to put the environment variables in the Pebble layer: @@ -331,6 +343,10 @@ self.unit.status = ops.MaintenanceStatus("Waiting for Pebble in workload contain self.unit.status = ops.BlockedStatus(str(e)) ``` +```python +self.unit.status = ops.BlockedStatus(str(version_e)) +``` + ## Validate your charm Time to check the results! @@ -522,6 +538,13 @@ 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["fastapi-demo"].version + # We'll need to update this version every time we upgrade to a new workload + # version. If the workload has an API or some other way of getting the + # version, the test should get it from there and use that to compare to the + # unit setting. + 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. diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md index ddc54fa73..8cfa4229a 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md @@ -59,7 +59,7 @@ class FastAPIConfig: raise ValueError("Invalid port number, 22 is reserved for SSH") ``` -Then, still in `src/charm.py`, add `import dataclasses` in the imports at the top of the file. +Then, still in `src/charm.py`, add `import dataclasses`, `import json`, and `import urllib.error` in the imports at the top of the file. We'll use [](CharmBase.load_config) to create an instance of your config class from the Juju config data. This allows IDEs to provide hints when we are accessing the configuration, and static type checkers are able to validate that we are using the config option correctly. @@ -127,15 +127,29 @@ 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 + + try: + version = fastapi_demo.get_version(config.server_port) + except (urllib.error.URLError, json.JSONDecodeError) as version_e: + logger.error( + "Failed to get version from the server: %s. Please double check your port config", + version_e, + ) + self.unit.status = ops.BlockedStatus(str(version_e)) + return + + self.unit.set_workload_version(version) + self.unit.status = ops.ActiveStatus() ``` 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. +We also add error handling for getting the workload version. If the charm fails to obtain a valid version number, we set the unit status to blocked. This lets the Juju user know that either the charm code or the workload application behaved unexpectedly. + Now, crucially, update the `_get_pebble_layer` method to make the layer definition dynamic, as shown below. This will replace the static port `8000` with the port passed to the method. ```python diff --git a/examples/k8s-1-minimal/src/fastapi_demo.py b/examples/k8s-1-minimal/src/fastapi_demo.py index 69c25b9ff..83dfd9df8 100644 --- a/examples/k8s-1-minimal/src/fastapi_demo.py +++ b/examples/k8s-1-minimal/src/fastapi_demo.py @@ -32,6 +32,6 @@ def get_version(port: int) -> str: Args: port: The port where fastapi_demo web server is listening. """ - response = urllib.request.urlopen(f"http://localhost:{port}/version") + response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") data = json.loads(response.read()) return data["version"] diff --git a/examples/k8s-1-minimal/tests/unit/test_charm.py b/examples/k8s-1-minimal/tests/unit/test_charm.py index 5a523f5af..1b72ea997 100644 --- a/examples/k8s-1-minimal/tests/unit/test_charm.py +++ b/examples/k8s-1-minimal/tests/unit/test_charm.py @@ -26,14 +26,18 @@ def mock_get_version(port: int): return "1.0.4" -def test_pebble_layer(monkeypatch: pytest.MonkeyPatch): +@pytest.fixture(autouse=True) +def patch_get_version(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) + + +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, ) - monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) state_out = ctx.run(ctx.on.pebble_ready(container), state_in) # Expected plan after Pebble ready with default config expected_plan = { diff --git a/examples/k8s-2-configurable/src/charm.py b/examples/k8s-2-configurable/src/charm.py index 9aab26aaa..9e8b86c39 100755 --- a/examples/k8s-2-configurable/src/charm.py +++ b/examples/k8s-2-configurable/src/charm.py @@ -17,10 +17,14 @@ """Kubernetes charm for a demo app.""" import dataclasses +import json import logging +import urllib.error import ops +import fastapi_demo + # Log messages can be retrieved using juju debug-log logger = logging.getLogger(__name__) @@ -85,11 +89,23 @@ 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 + + try: + version = fastapi_demo.get_version(config.server_port) + except (urllib.error.URLError, json.JSONDecodeError) as version_e: + logger.error( + "Failed to get version from the server: %s. Please double check your port config", + version_e, + ) + self.unit.status = ops.BlockedStatus(str(version_e)) + return + + self.unit.set_workload_version(version) + self.unit.status = ops.ActiveStatus() def _get_pebble_layer(self, port: int) -> ops.pebble.Layer: """Pebble layer for the FastAPI demo services.""" diff --git a/examples/k8s-2-configurable/src/fastapi_demo.py b/examples/k8s-2-configurable/src/fastapi_demo.py new file mode 100644 index 000000000..83dfd9df8 --- /dev/null +++ b/examples/k8s-2-configurable/src/fastapi_demo.py @@ -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 installed. + + Args: + port: The port where fastapi_demo web server is listening. + """ + response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + data = json.loads(response.read()) + return data["version"] diff --git a/examples/k8s-2-configurable/tests/integration/test_charm.py b/examples/k8s-2-configurable/tests/integration/test_charm.py index f3852e50f..446bc2453 100644 --- a/examples/k8s-2-configurable/tests/integration/test_charm.py +++ b/examples/k8s-2-configurable/tests/integration/test_charm.py @@ -39,3 +39,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["fastapi-demo"].version + # We'll need to update this version every time we upgrade to a new workload + # version. If the workload has an API or some other way of getting the + # version, the test should get it from there and use that to compare to the + # unit setting. + assert version == "1.0.4" diff --git a/examples/k8s-2-configurable/tests/unit/test_charm.py b/examples/k8s-2-configurable/tests/unit/test_charm.py index 66b54cfd2..4f1d0162d 100644 --- a/examples/k8s-2-configurable/tests/unit/test_charm.py +++ b/examples/k8s-2-configurable/tests/unit/test_charm.py @@ -15,11 +15,21 @@ # To learn more about testing, see https://documentation.ubuntu.com/ops/latest/explanation/testing/ import ops +import pytest from ops import testing from charm import FastAPIDemoCharm +def mock_get_version(port: int): + """Get a mock version string without executing the workload code.""" + return "1.0.4" + +@pytest.fixture(autouse=True) +def patch_get_version(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) + + def test_pebble_layer(): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) @@ -51,6 +61,8 @@ 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 def test_config_changed(): diff --git a/examples/k8s-3-postgresql/src/charm.py b/examples/k8s-3-postgresql/src/charm.py index ae131c8bb..d6e2563da 100755 --- a/examples/k8s-3-postgresql/src/charm.py +++ b/examples/k8s-3-postgresql/src/charm.py @@ -17,7 +17,9 @@ """Kubernetes charm for a demo app.""" import dataclasses +import json import logging +import urllib.error import ops @@ -30,6 +32,8 @@ DatabaseRequires, ) +import fastapi_demo + # Log messages can be retrieved using juju debug-log logger = logging.getLogger(__name__) @@ -133,6 +137,18 @@ 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 + + try: + version = fastapi_demo.get_version(config.server_port) + except (urllib.error.URLError, json.JSONDecodeError) as version_e: + logger.error( + "Failed to get version from the server: %s. Please double check your port config", + version_e, + ) + return + + self.unit.set_workload_version(version) def _get_pebble_layer(self, port: int, environment: dict[str, str]) -> ops.pebble.Layer: """Pebble layer for the FastAPI demo services.""" diff --git a/examples/k8s-3-postgresql/src/fastapi_demo.py b/examples/k8s-3-postgresql/src/fastapi_demo.py new file mode 100644 index 000000000..83dfd9df8 --- /dev/null +++ b/examples/k8s-3-postgresql/src/fastapi_demo.py @@ -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 installed. + + Args: + port: The port where fastapi_demo web server is listening. + """ + response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + data = json.loads(response.read()) + return data["version"] diff --git a/examples/k8s-3-postgresql/tests/integration/test_charm.py b/examples/k8s-3-postgresql/tests/integration/test_charm.py index 2553214d8..3820d3a96 100644 --- a/examples/k8s-3-postgresql/tests/integration/test_charm.py +++ b/examples/k8s-3-postgresql/tests/integration/test_charm.py @@ -54,4 +54,11 @@ 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) + juju.wait(jubilant.all_active, timeout=10 * 60) + + version = juju.status().apps["fastapi-demo"].version + # We'll need to update this version every time we upgrade to a new workload + # version. If the workload has an API or some other way of getting the + # version, the test should get it from there and use that to compare to the + # unit setting. + assert version == "1.0.4" diff --git a/examples/k8s-3-postgresql/tests/unit/test_charm.py b/examples/k8s-3-postgresql/tests/unit/test_charm.py index 0a4958928..6b5b98aa3 100644 --- a/examples/k8s-3-postgresql/tests/unit/test_charm.py +++ b/examples/k8s-3-postgresql/tests/unit/test_charm.py @@ -15,11 +15,22 @@ # To learn more about testing, see https://documentation.ubuntu.com/ops/latest/explanation/testing/ import ops +import pytest from ops import testing from charm import FastAPIDemoCharm +def mock_get_version(port: int): + """Get a mock version string without executing the workload code.""" + return "1.0.4" + + +@pytest.fixture(autouse=True) +def patch_get_version(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) + + def test_pebble_layer(): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) @@ -51,6 +62,8 @@ 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 def test_config_changed(): diff --git a/examples/k8s-4-action/src/charm.py b/examples/k8s-4-action/src/charm.py index c42bcc521..2181af1fa 100755 --- a/examples/k8s-4-action/src/charm.py +++ b/examples/k8s-4-action/src/charm.py @@ -17,7 +17,9 @@ """Kubernetes charm for a demo app.""" import dataclasses +import json import logging +import urllib.error import ops @@ -30,6 +32,8 @@ DatabaseRequires, ) +import fastapi_demo + # Log messages can be retrieved using juju debug-log logger = logging.getLogger(__name__) @@ -173,6 +177,18 @@ 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 + + try: + version = fastapi_demo.get_version(config.server_port) + except (urllib.error.URLError, json.JSONDecodeError) as version_e: + logger.error( + "Failed to get version from the server: %s. Please double check your port config", + version_e, + ) + return + + self.unit.set_workload_version(version) def _get_pebble_layer(self, port: int, environment: dict[str, str]) -> ops.pebble.Layer: """Pebble layer for the FastAPI demo services.""" diff --git a/examples/k8s-4-action/src/fastapi_demo.py b/examples/k8s-4-action/src/fastapi_demo.py new file mode 100644 index 000000000..83dfd9df8 --- /dev/null +++ b/examples/k8s-4-action/src/fastapi_demo.py @@ -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 installed. + + Args: + port: The port where fastapi_demo web server is listening. + """ + response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + data = json.loads(response.read()) + return data["version"] diff --git a/examples/k8s-4-action/tests/integration/test_charm.py b/examples/k8s-4-action/tests/integration/test_charm.py index 2553214d8..4a8f2af58 100644 --- a/examples/k8s-4-action/tests/integration/test_charm.py +++ b/examples/k8s-4-action/tests/integration/test_charm.py @@ -55,3 +55,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["fastapi-demo"].version + # We'll need to update this version every time we upgrade to a new workload + # version. If the workload has an API or some other way of getting the + # version, the test should get it from there and use that to compare to the + # unit setting. + assert version == "1.0.4" diff --git a/examples/k8s-4-action/tests/unit/test_charm.py b/examples/k8s-4-action/tests/unit/test_charm.py index f8df51b7e..8f6388b56 100644 --- a/examples/k8s-4-action/tests/unit/test_charm.py +++ b/examples/k8s-4-action/tests/unit/test_charm.py @@ -15,18 +15,30 @@ # To learn more about testing, see https://documentation.ubuntu.com/ops/latest/explanation/testing/ 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 "1.0.4" + + +@pytest.fixture(autouse=True) +def patch_get_version(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) + + +def test_pebble_layer(monkeypatch: pytest.MonkeyPatch): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( containers={container}, leader=True, ) + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) state_out = ctx.run(ctx.on.pebble_ready(container), state_in) # Expected plan after Pebble ready with default config expected_plan = { @@ -51,9 +63,11 @@ 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 -def test_config_changed(): +def test_config_changed(monkeypatch: pytest.MonkeyPatch): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -61,6 +75,7 @@ def test_config_changed(): config={"server-port": 8080}, leader=True, ) + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) state_out = ctx.run(ctx.on.config_changed(), state_in) command = ( state_out.get_container(container.name) diff --git a/examples/k8s-5-observe/src/charm.py b/examples/k8s-5-observe/src/charm.py index f636e82c4..451e47880 100755 --- a/examples/k8s-5-observe/src/charm.py +++ b/examples/k8s-5-observe/src/charm.py @@ -17,7 +17,9 @@ """Kubernetes charm for a demo app.""" import dataclasses +import json import logging +import urllib.error import ops @@ -33,6 +35,8 @@ from charms.loki_k8s.v1.loki_push_api import LogForwarder from charms.prometheus_k8s.v0.prometheus_scrape import MetricsEndpointProvider +import fastapi_demo + # Log messages can be retrieved using juju debug-log logger = logging.getLogger(__name__) @@ -194,6 +198,18 @@ 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 + + try: + version = fastapi_demo.get_version(config.server_port) + except (urllib.error.URLError, json.JSONDecodeError) as version_e: + logger.error( + "Failed to get version from the server: %s. Please double check your port config", + version_e, + ) + return + + self.unit.set_workload_version(version) def _get_pebble_layer(self, port: int, environment: dict[str, str]) -> ops.pebble.Layer: """Pebble layer for the FastAPI demo services.""" diff --git a/examples/k8s-5-observe/src/fastapi_demo.py b/examples/k8s-5-observe/src/fastapi_demo.py new file mode 100644 index 000000000..83dfd9df8 --- /dev/null +++ b/examples/k8s-5-observe/src/fastapi_demo.py @@ -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 installed. + + Args: + port: The port where fastapi_demo web server is listening. + """ + response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + data = json.loads(response.read()) + return data["version"] diff --git a/examples/k8s-5-observe/tests/integration/test_charm.py b/examples/k8s-5-observe/tests/integration/test_charm.py index 48a071fe6..d2108b2cb 100644 --- a/examples/k8s-5-observe/tests/integration/test_charm.py +++ b/examples/k8s-5-observe/tests/integration/test_charm.py @@ -60,6 +60,13 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): juju.integrate(APP_NAME, "postgresql-k8s") juju.wait(jubilant.all_active) + version = juju.status().apps["fastapi-demo"].version + # We'll need to update this version every time we upgrade to a new workload + # version. If the workload has an API or some other way of getting the + # version, the test should get it from there and use that to compare to the + # unit setting. + assert version == "1.0.4" + @pytest.fixture(scope="module") def cos(juju_factory: pytest_jubilant.JujuFactory): diff --git a/examples/k8s-5-observe/tests/unit/test_charm.py b/examples/k8s-5-observe/tests/unit/test_charm.py index f8df51b7e..94996cc8c 100644 --- a/examples/k8s-5-observe/tests/unit/test_charm.py +++ b/examples/k8s-5-observe/tests/unit/test_charm.py @@ -15,11 +15,22 @@ # To learn more about testing, see https://documentation.ubuntu.com/ops/latest/explanation/testing/ import ops +import pytest from ops import testing from charm import FastAPIDemoCharm +def mock_get_version(port: int): + """Get a mock version string without executing the workload code.""" + return "1.0.4" + + +@pytest.fixture(autouse=True) +def patch_get_version(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) + + def test_pebble_layer(): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) From f41826ec0d7b9aec742aa77d285cbb0e26b25a37 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Fri, 12 Jun 2026 09:10:42 +1000 Subject: [PATCH 08/26] fix format of k8s-2-configurable --- examples/k8s-2-configurable/tests/unit/test_charm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/k8s-2-configurable/tests/unit/test_charm.py b/examples/k8s-2-configurable/tests/unit/test_charm.py index 4f1d0162d..4e962bf3d 100644 --- a/examples/k8s-2-configurable/tests/unit/test_charm.py +++ b/examples/k8s-2-configurable/tests/unit/test_charm.py @@ -25,6 +25,7 @@ def mock_get_version(port: int): """Get a mock version string without executing the workload code.""" return "1.0.4" + @pytest.fixture(autouse=True) def patch_get_version(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) From e522d583e11810d08d76d2d50df4d5ec5117ba2c Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Mon, 15 Jun 2026 14:43:12 +1000 Subject: [PATCH 09/26] address feedback from Ben --- .../create-a-minimal-kubernetes-charm.md | 31 +++++++------------ .../expose-operational-tasks-via-actions.md | 4 +-- .../integrate-your-charm-with-postgresql.md | 14 ++++----- .../make-your-charm-configurable.md | 6 ++-- examples/k8s-1-minimal/src/charm.py | 6 ++-- .../tests/integration/test_charm.py | 10 +++--- .../k8s-1-minimal/tests/unit/test_charm.py | 6 ++-- examples/k8s-2-configurable/src/charm.py | 2 +- .../tests/integration/test_charm.py | 10 +++--- .../tests/unit/test_charm.py | 10 +++--- .../tests/integration/test_charm.py | 10 +++--- .../k8s-3-postgresql/tests/unit/test_charm.py | 14 ++++----- .../tests/integration/test_charm.py | 10 +++--- .../k8s-4-action/tests/unit/test_charm.py | 20 ++++++------ .../tests/integration/test_charm.py | 10 +++--- .../k8s-5-observe/tests/unit/test_charm.py | 18 +++++------ 16 files changed, 86 insertions(+), 95 deletions(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index 1c97178de..22a96b93c 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -237,23 +237,14 @@ def _get_pebble_layer(self) -> ops.pebble.Layer: The workload version is available after the workload starts, which is after Pebble reevaluates its plan. We will use the `src/fastapi_demo.py` helper module for this step. -In `src/charm.py`, add `import fastapi_demo` in the imports at the top of the file. Then modify the `_on_demo_server_pebble_ready` function: +In `src/charm.py`, add `import fastapi_demo` in the imports at the top of the file. Then append the following lines to the `_on_demo_server_pebble_ready` function: ```python 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() + # ... previous lines ... # Set the workload version of this charm. version = fastapi_demo.get_version(port=8000) self.unit.set_workload_version(version) - # Learn more about statuses at - # https://documentation.ubuntu.com/juju/3.6/reference/status/ - self.unit.status = ops.ActiveStatus() ``` We invoke `fastapi_demo.get_version` to get the workload version, and expose it to Juju with `self.unit.set_workload_version`. We use port 8000 as the app is deployed on this port, as seen in `_get_pebble_layer`. @@ -406,12 +397,12 @@ def mock_get_version(port: int): return "1.0.4" -@pytest.fixture(autouse=True) -def patch_get_version(monkeypatch: pytest.MonkeyPatch): +@pytest.fixture +def mock_version(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) -def test_pebble_layer(): +def test_pebble_layer(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -448,6 +439,8 @@ def test_pebble_layer(): 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 to keep the unit tests deterministic. The `get_version` method performs a HTTP call, which must be patched. We use the fixture `mock_version` to achieve this. From now on, any unit test on the charm needs to use fixture. + ### Run the test Run the following command from anywhere in the `~/fastapi-demo` directory: @@ -528,11 +521,11 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju): def test_workload_version_is_set(juju: jubilant.Juju): # Verify that the workload version has been set. version = juju.status().apps["fastapi-demo"].version - # We'll need to update this version every time we upgrade to a new workload - # version. If the workload has an API or some other way of getting the - # version, the test should get it from there and use that to compare to the - # unit setting. - assert version == "1.0.4" + # Ideally, the test should get the version directly from the workload application + # (for example, through an API call) and use that in this assertion. + # For simplicity, we hardcode the major version here to avoid updates + # if api_demo_server is updated with a minor or patch release. + assert version.startswith("1.") ``` This test depends on two fixtures: diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/expose-operational-tasks-via-actions.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/expose-operational-tasks-via-actions.md index 60dc9c22c..03b91d3a8 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/expose-operational-tasks-via-actions.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/expose-operational-tasks-via-actions.md @@ -156,7 +156,7 @@ Congratulations, you now know how to expose operational tasks via actions! Let's add a test to check the behaviour of the `get_db_info` action that we just set up. Our test sets up the context, defines the input state with a relation, then runs the action and checks whether the results match the expected values: ```python -def test_get_db_info_action(): +def test_get_db_info_action(mock_version): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", @@ -186,7 +186,7 @@ def test_get_db_info_action(): Since the `get_db_info` action has a parameter `show-password`, let's also add a test to cover the case where the user wants to show the password: ```python -def test_get_db_info_action_show_password(): +def test_get_db_info_action_show_password(mock_version): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md index 3ed645399..ec105cdad 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md @@ -435,7 +435,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", @@ -469,7 +469,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( @@ -540,11 +540,11 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): juju.wait(jubilant.all_active) version = juju.status().apps["fastapi-demo"].version - # We'll need to update this version every time we upgrade to a new workload - # version. If the workload has an API or some other way of getting the - # version, the test should get it from there and use that to compare to the - # unit setting. - assert version == "1.0.4" + # Ideally, the test should get the version directly from the workload application + # (for example, through an API call) and use that in this assertion. + # For simplicity, we hardcode the major version here to avoid updates + # if api_demo_server is updated with a minor or patch release. + assert version.startswith("1.") ``` This test depends on the `charm` fixture so that the test fails immediately if a `.charm` file isn't available. diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md index 8cfa4229a..170ebe8b2 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md @@ -142,8 +142,8 @@ def _replan_workload(self) -> None: self.unit.status = ops.BlockedStatus(str(version_e)) return - self.unit.set_workload_version(version) self.unit.status = ops.ActiveStatus() + self.unit.set_workload_version(version) ``` 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. @@ -256,7 +256,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): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -277,7 +277,7 @@ def test_config_changed(): 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( diff --git a/examples/k8s-1-minimal/src/charm.py b/examples/k8s-1-minimal/src/charm.py index cdf85238b..d8c0ad3b8 100755 --- a/examples/k8s-1-minimal/src/charm.py +++ b/examples/k8s-1-minimal/src/charm.py @@ -42,12 +42,12 @@ def _on_demo_server_pebble_ready(self, event: ops.PebbleReadyEvent) -> None: 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() - # Set the workload version of this charm. - version = fastapi_demo.get_version(port=8000) - self.unit.set_workload_version(version) # 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.""" diff --git a/examples/k8s-1-minimal/tests/integration/test_charm.py b/examples/k8s-1-minimal/tests/integration/test_charm.py index 446bc2453..c6fcbb3be 100644 --- a/examples/k8s-1-minimal/tests/integration/test_charm.py +++ b/examples/k8s-1-minimal/tests/integration/test_charm.py @@ -44,8 +44,8 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju): def test_workload_version_is_set(juju: jubilant.Juju): # Verify that the workload version has been set. version = juju.status().apps["fastapi-demo"].version - # We'll need to update this version every time we upgrade to a new workload - # version. If the workload has an API or some other way of getting the - # version, the test should get it from there and use that to compare to the - # unit setting. - assert version == "1.0.4" + # Ideally, the test should get the version directly from the workload application + # (for example, through an API call) and use that in this assertion. + # For simplicity, we hardcode the major version here to avoid updates + # if api_demo_server is updated with a minor or patch release. + assert version.startswith("1.") diff --git a/examples/k8s-1-minimal/tests/unit/test_charm.py b/examples/k8s-1-minimal/tests/unit/test_charm.py index 1b72ea997..97cd74a71 100644 --- a/examples/k8s-1-minimal/tests/unit/test_charm.py +++ b/examples/k8s-1-minimal/tests/unit/test_charm.py @@ -26,12 +26,12 @@ def mock_get_version(port: int): return "1.0.4" -@pytest.fixture(autouse=True) -def patch_get_version(monkeypatch: pytest.MonkeyPatch): +@pytest.fixture +def mock_version(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) -def test_pebble_layer(): +def test_pebble_layer(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( diff --git a/examples/k8s-2-configurable/src/charm.py b/examples/k8s-2-configurable/src/charm.py index 9e8b86c39..3ac7202af 100755 --- a/examples/k8s-2-configurable/src/charm.py +++ b/examples/k8s-2-configurable/src/charm.py @@ -104,8 +104,8 @@ def _replan_workload(self) -> None: self.unit.status = ops.BlockedStatus(str(version_e)) return - self.unit.set_workload_version(version) self.unit.status = ops.ActiveStatus() + self.unit.set_workload_version(version) def _get_pebble_layer(self, port: int) -> ops.pebble.Layer: """Pebble layer for the FastAPI demo services.""" diff --git a/examples/k8s-2-configurable/tests/integration/test_charm.py b/examples/k8s-2-configurable/tests/integration/test_charm.py index 446bc2453..c6fcbb3be 100644 --- a/examples/k8s-2-configurable/tests/integration/test_charm.py +++ b/examples/k8s-2-configurable/tests/integration/test_charm.py @@ -44,8 +44,8 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju): def test_workload_version_is_set(juju: jubilant.Juju): # Verify that the workload version has been set. version = juju.status().apps["fastapi-demo"].version - # We'll need to update this version every time we upgrade to a new workload - # version. If the workload has an API or some other way of getting the - # version, the test should get it from there and use that to compare to the - # unit setting. - assert version == "1.0.4" + # Ideally, the test should get the version directly from the workload application + # (for example, through an API call) and use that in this assertion. + # For simplicity, we hardcode the major version here to avoid updates + # if api_demo_server is updated with a minor or patch release. + assert version.startswith("1.") diff --git a/examples/k8s-2-configurable/tests/unit/test_charm.py b/examples/k8s-2-configurable/tests/unit/test_charm.py index 4e962bf3d..2f568d6d8 100644 --- a/examples/k8s-2-configurable/tests/unit/test_charm.py +++ b/examples/k8s-2-configurable/tests/unit/test_charm.py @@ -26,12 +26,12 @@ def mock_get_version(port: int): return "1.0.4" -@pytest.fixture(autouse=True) -def patch_get_version(monkeypatch: pytest.MonkeyPatch): +@pytest.fixture +def mock_version(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) -def test_pebble_layer(): +def test_pebble_layer(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -66,7 +66,7 @@ def test_pebble_layer(): assert state_out.workload_version is not None -def test_config_changed(): +def test_config_changed(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -84,7 +84,7 @@ def test_config_changed(): assert "--port=8080" in command -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( diff --git a/examples/k8s-3-postgresql/tests/integration/test_charm.py b/examples/k8s-3-postgresql/tests/integration/test_charm.py index 3820d3a96..c2c6e2df3 100644 --- a/examples/k8s-3-postgresql/tests/integration/test_charm.py +++ b/examples/k8s-3-postgresql/tests/integration/test_charm.py @@ -57,8 +57,8 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): juju.wait(jubilant.all_active, timeout=10 * 60) version = juju.status().apps["fastapi-demo"].version - # We'll need to update this version every time we upgrade to a new workload - # version. If the workload has an API or some other way of getting the - # version, the test should get it from there and use that to compare to the - # unit setting. - assert version == "1.0.4" + # Ideally, the test should get the version directly from the workload application + # (for example, through an API call) and use that in this assertion. + # For simplicity, we hardcode the major version here to avoid updates + # if api_demo_server is updated with a minor or patch release. + assert version.startswith("1.") diff --git a/examples/k8s-3-postgresql/tests/unit/test_charm.py b/examples/k8s-3-postgresql/tests/unit/test_charm.py index 6b5b98aa3..4aec6c0c1 100644 --- a/examples/k8s-3-postgresql/tests/unit/test_charm.py +++ b/examples/k8s-3-postgresql/tests/unit/test_charm.py @@ -26,12 +26,12 @@ def mock_get_version(port: int): return "1.0.4" -@pytest.fixture(autouse=True) -def patch_get_version(monkeypatch: pytest.MonkeyPatch): +@pytest.fixture +def mock_version(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) -def test_pebble_layer(): +def test_pebble_layer(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -66,7 +66,7 @@ def test_pebble_layer(): assert state_out.workload_version is not None -def test_config_changed(): +def test_config_changed(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -84,7 +84,7 @@ def test_config_changed(): assert "--port=8080" in command -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( @@ -98,7 +98,7 @@ def test_config_changed_invalid_port(): ) -def test_relation_data(): +def test_relation_data(mock_version): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", @@ -129,7 +129,7 @@ def test_relation_data(): } -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( diff --git a/examples/k8s-4-action/tests/integration/test_charm.py b/examples/k8s-4-action/tests/integration/test_charm.py index 4a8f2af58..58fa73a19 100644 --- a/examples/k8s-4-action/tests/integration/test_charm.py +++ b/examples/k8s-4-action/tests/integration/test_charm.py @@ -57,8 +57,8 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): juju.wait(jubilant.all_active) version = juju.status().apps["fastapi-demo"].version - # We'll need to update this version every time we upgrade to a new workload - # version. If the workload has an API or some other way of getting the - # version, the test should get it from there and use that to compare to the - # unit setting. - assert version == "1.0.4" + # Ideally, the test should get the version directly from the workload application + # (for example, through an API call) and use that in this assertion. + # For simplicity, we hardcode the major version here to avoid updates + # if api_demo_server is updated with a minor or patch release. + assert version.startswith("1.") diff --git a/examples/k8s-4-action/tests/unit/test_charm.py b/examples/k8s-4-action/tests/unit/test_charm.py index 8f6388b56..d0f8730cf 100644 --- a/examples/k8s-4-action/tests/unit/test_charm.py +++ b/examples/k8s-4-action/tests/unit/test_charm.py @@ -26,19 +26,18 @@ def mock_get_version(port: int): return "1.0.4" -@pytest.fixture(autouse=True) -def patch_get_version(monkeypatch: pytest.MonkeyPatch): +@pytest.fixture +def mock_version(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) -def test_pebble_layer(monkeypatch: pytest.MonkeyPatch): +def test_pebble_layer(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( containers={container}, leader=True, ) - monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) state_out = ctx.run(ctx.on.pebble_ready(container), state_in) # Expected plan after Pebble ready with default config expected_plan = { @@ -67,7 +66,7 @@ def test_pebble_layer(monkeypatch: pytest.MonkeyPatch): assert state_out.workload_version is not None -def test_config_changed(monkeypatch: pytest.MonkeyPatch): +def test_config_changed(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -75,7 +74,6 @@ def test_config_changed(monkeypatch: pytest.MonkeyPatch): config={"server-port": 8080}, leader=True, ) - monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) state_out = ctx.run(ctx.on.config_changed(), state_in) command = ( state_out.get_container(container.name) @@ -86,7 +84,7 @@ def test_config_changed(monkeypatch: pytest.MonkeyPatch): assert "--port=8080" in command -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( @@ -100,7 +98,7 @@ def test_config_changed_invalid_port(): ) -def test_relation_data(): +def test_relation_data(mock_version): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", @@ -131,7 +129,7 @@ def test_relation_data(): } -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( @@ -144,7 +142,7 @@ def test_no_database_blocked(): assert state_out.unit_status == testing.BlockedStatus("Waiting for database relation") -def test_get_db_info_action(): +def test_get_db_info_action(mock_version): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", @@ -171,7 +169,7 @@ def test_get_db_info_action(): } -def test_get_db_info_action_show_password(): +def test_get_db_info_action_show_password(mock_version): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", diff --git a/examples/k8s-5-observe/tests/integration/test_charm.py b/examples/k8s-5-observe/tests/integration/test_charm.py index d2108b2cb..485e2f8f3 100644 --- a/examples/k8s-5-observe/tests/integration/test_charm.py +++ b/examples/k8s-5-observe/tests/integration/test_charm.py @@ -61,11 +61,11 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): juju.wait(jubilant.all_active) version = juju.status().apps["fastapi-demo"].version - # We'll need to update this version every time we upgrade to a new workload - # version. If the workload has an API or some other way of getting the - # version, the test should get it from there and use that to compare to the - # unit setting. - assert version == "1.0.4" + # Ideally, the test should get the version directly from the workload application + # (for example, through an API call) and use that in this assertion. + # For simplicity, we hardcode the major version here to avoid updates + # if api_demo_server is updated with a minor or patch release. + assert version.startswith("1.") @pytest.fixture(scope="module") diff --git a/examples/k8s-5-observe/tests/unit/test_charm.py b/examples/k8s-5-observe/tests/unit/test_charm.py index 94996cc8c..488ce5798 100644 --- a/examples/k8s-5-observe/tests/unit/test_charm.py +++ b/examples/k8s-5-observe/tests/unit/test_charm.py @@ -26,12 +26,12 @@ def mock_get_version(port: int): return "1.0.4" -@pytest.fixture(autouse=True) -def patch_get_version(monkeypatch: pytest.MonkeyPatch): +@pytest.fixture +def mock_version(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) -def test_pebble_layer(): +def test_pebble_layer(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -64,7 +64,7 @@ def test_pebble_layer(): ) -def test_config_changed(): +def test_config_changed(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -82,7 +82,7 @@ def test_config_changed(): assert "--port=8080" in command -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( @@ -96,7 +96,7 @@ def test_config_changed_invalid_port(): ) -def test_relation_data(): +def test_relation_data(mock_version): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", @@ -127,7 +127,7 @@ def test_relation_data(): } -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( @@ -140,7 +140,7 @@ def test_no_database_blocked(): assert state_out.unit_status == testing.BlockedStatus("Waiting for database relation") -def test_get_db_info_action(): +def test_get_db_info_action(mock_version): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", @@ -167,7 +167,7 @@ def test_get_db_info_action(): } -def test_get_db_info_action_show_password(): +def test_get_db_info_action_show_password(mock_version): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", From 035a521cab7622f03509950b9aaefe3499f5bbd0 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Mon, 15 Jun 2026 16:44:49 +1000 Subject: [PATCH 10/26] remove hardcode version string --- .../create-a-minimal-kubernetes-charm.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index 22a96b93c..30b895024 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -306,7 +306,7 @@ 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. The workload version is `1.0.4`, as seen in the `Version` column. +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 `Version` column. ```text Model Controller Cloud/Region Version SLA Timestamp From 80bbc8316209d27306d1156b60d294a37573d7a8 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Tue, 16 Jun 2026 17:33:09 +1000 Subject: [PATCH 11/26] add instruction to add requests as a dependency in chapter 5 --- .../observe-your-charm-with-cos-lite.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/observe-your-charm-with-cos-lite.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/observe-your-charm-with-cos-lite.md index 2fb4fd6e2..66ec0bba0 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/observe-your-charm-with-cos-lite.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/observe-your-charm-with-cos-lite.md @@ -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 your charm. We also need a new dependency `requests`. Run the following command to add it: + +```text +uv add requests +``` + +Your imports should now look like this: ```python import json From 5ef30e9221e1cc705ae0a6167699708e834e5970 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Tue, 23 Jun 2026 08:42:36 +1000 Subject: [PATCH 12/26] fix tutorial chapter 1 wordings from PR feedback --- .../create-a-minimal-kubernetes-charm.md | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index 30b895024..7f9eee5e3 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -11,7 +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. Besides Pebble exec, the charm can communicate to the workload using HTTP requests. This is possible because they share the same pod (Kubernetes charms are deployed following sidecar pattern). +1. During operations, the charm may need to directly communicate with the workload using HTTP requests. This is possible because the charm container and workload container share the same pod. > 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. @@ -87,13 +87,13 @@ resources: ### Write a helper module -Your charm will interact with our workload application `api_demo_server`. It’s a good idea to write a helper module that wraps `api_demo_server`. Charmcraft created `src/fastapi_demo.py` as a placeholder helper module. +Your charm will interact with our workload application `api_demo_server`. It's a good idea to write a helper module that wraps `api_demo_server`. 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 `api_demo_server`. The server has an endpoint at `/version` that returns a JSON payload containing the version number (see {ref}`study-your-application`). +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 `api_demo_server`. The server has an endpoint at `/version` that returns a JSON payload containing the version number. This is called the workload version. -This is called the workload version. To make things easier for Juju admins, our charm should expose the workload version to Juju. It will be visible in `juju status`. For more information, see {ref}`how-to-set-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`. -We first define a function to get the workload version. Replace the content of `src/fastapi_demo.py` with: +Replace the content of `src/fastapi_demo.py` with: ```python import json @@ -104,7 +104,7 @@ logger = logging.getLogger(__name__) def get_version(port: int) -> str: - """Get the version of fastapi_demo installed. + """Get the version of fastapi_demo that is running. Args: port: The port where fastapi_demo web server is listening. @@ -114,12 +114,10 @@ def get_version(port: int) -> str: return data["version"] ``` -The `get_version` function sends a HTTP GET to `api_demo_server` in the workload container, decodes the result JSON payload, and extracts the version string. - 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 the functions in the helper module to manage `api_demo_server` and check its status. +2. Use Pebble calls and the helper module to manage `api_demo_server` and check its status. 3. Report the status back to Juju. ```{tip} @@ -235,19 +233,17 @@ def _get_pebble_layer(self) -> ops.pebble.Layer: ### Set the workload version -The workload version is available after the workload starts, which is after Pebble reevaluates its plan. We will use the `src/fastapi_demo.py` helper module for this step. +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`, add `import fastapi_demo` in the imports at the top of the file. Then append the following lines to the `_on_demo_server_pebble_ready` function: +In `src/charm.py`, append the following lines to the `_on_demo_server_pebble_ready` function: ```python -def _on_demo_server_pebble_ready(self, event: ops.PebbleReadyEvent) -> None: - # ... previous lines ... - # Set the workload version of this charm. - version = fastapi_demo.get_version(port=8000) - self.unit.set_workload_version(version) +# Set the workload version of this charm. +version = fastapi_demo.get_version(port=8000) +self.unit.set_workload_version(version) ``` -We invoke `fastapi_demo.get_version` to get the workload version, and expose it to Juju with `self.unit.set_workload_version`. We use port 8000 as the app is deployed on this port, as seen in `_get_pebble_layer`. +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. ### Add logger functionality @@ -306,7 +302,7 @@ 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. The workload version is located in the `Version` column. +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 @@ -439,7 +435,7 @@ def test_pebble_layer(mock_version): 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 to keep the unit tests deterministic. The `get_version` method performs a HTTP call, which must be patched. We use the fixture `mock_version` to achieve this. From now on, any unit test on the charm needs to use fixture. +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 From e25cec07553b6a453c26a5280c012b4dbede7ac5 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Tue, 23 Jun 2026 08:47:24 +1000 Subject: [PATCH 13/26] use an obvious mock version number in mock_get_version --- .../create-a-minimal-kubernetes-charm.md | 2 +- examples/k8s-1-minimal/tests/unit/test_charm.py | 2 +- examples/k8s-2-configurable/tests/unit/test_charm.py | 2 +- examples/k8s-4-action/tests/unit/test_charm.py | 2 +- examples/k8s-5-observe/tests/unit/test_charm.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index 7f9eee5e3..984e4c378 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -390,7 +390,7 @@ from charm import FastAPIDemoCharm def mock_get_version(port: int): """Get a mock version string without executing the workload code.""" - return "1.0.4" + return "0.0.1" @pytest.fixture diff --git a/examples/k8s-1-minimal/tests/unit/test_charm.py b/examples/k8s-1-minimal/tests/unit/test_charm.py index 97cd74a71..68ab4df3e 100644 --- a/examples/k8s-1-minimal/tests/unit/test_charm.py +++ b/examples/k8s-1-minimal/tests/unit/test_charm.py @@ -23,7 +23,7 @@ def mock_get_version(port: int): """Get a mock version string without executing the workload code.""" - return "1.0.4" + return "0.0.1" @pytest.fixture diff --git a/examples/k8s-2-configurable/tests/unit/test_charm.py b/examples/k8s-2-configurable/tests/unit/test_charm.py index 2f568d6d8..3748f53b2 100644 --- a/examples/k8s-2-configurable/tests/unit/test_charm.py +++ b/examples/k8s-2-configurable/tests/unit/test_charm.py @@ -23,7 +23,7 @@ def mock_get_version(port: int): """Get a mock version string without executing the workload code.""" - return "1.0.4" + return "0.0.1" @pytest.fixture diff --git a/examples/k8s-4-action/tests/unit/test_charm.py b/examples/k8s-4-action/tests/unit/test_charm.py index d0f8730cf..e9d26449b 100644 --- a/examples/k8s-4-action/tests/unit/test_charm.py +++ b/examples/k8s-4-action/tests/unit/test_charm.py @@ -23,7 +23,7 @@ def mock_get_version(port: int): """Get a mock version string without executing the workload code.""" - return "1.0.4" + return "0.0.1" @pytest.fixture diff --git a/examples/k8s-5-observe/tests/unit/test_charm.py b/examples/k8s-5-observe/tests/unit/test_charm.py index 488ce5798..6ede5cfc3 100644 --- a/examples/k8s-5-observe/tests/unit/test_charm.py +++ b/examples/k8s-5-observe/tests/unit/test_charm.py @@ -23,7 +23,7 @@ def mock_get_version(port: int): """Get a mock version string without executing the workload code.""" - return "1.0.4" + return "0.0.1" @pytest.fixture From e2cd1b6bf918bd1bcf0edba97c2c2ff6fe7fc545 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Tue, 23 Jun 2026 08:52:51 +1000 Subject: [PATCH 14/26] use direct version matching in workload version integration tests --- .../create-a-minimal-kubernetes-charm.md | 6 +++--- .../integrate-your-charm-with-postgresql.md | 6 +++--- examples/k8s-1-minimal/tests/integration/test_charm.py | 6 +++--- examples/k8s-2-configurable/tests/integration/test_charm.py | 6 +++--- examples/k8s-3-postgresql/tests/integration/test_charm.py | 6 +++--- examples/k8s-4-action/tests/integration/test_charm.py | 6 +++--- examples/k8s-5-observe/tests/integration/test_charm.py | 6 +++--- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index 984e4c378..5b1fa0062 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -519,9 +519,9 @@ def test_workload_version_is_set(juju: jubilant.Juju): version = juju.status().apps["fastapi-demo"].version # Ideally, the test should get the version directly from the workload application # (for example, through an API call) and use that in this assertion. - # For simplicity, we hardcode the major version here to avoid updates - # if api_demo_server is updated with a minor or patch release. - assert version.startswith("1.") + # For simplicity, we hardcode the version here. We update the tutorial whenever we + # release a new version of api_demo_server. + assert version == "1.0.4" ``` This test depends on two fixtures: diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md index ec105cdad..22ce57ce7 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md @@ -542,9 +542,9 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): version = juju.status().apps["fastapi-demo"].version # Ideally, the test should get the version directly from the workload application # (for example, through an API call) and use that in this assertion. - # For simplicity, we hardcode the major version here to avoid updates - # if api_demo_server is updated with a minor or patch release. - assert version.startswith("1.") + # For simplicity, we hardcode the version here. We update the tutorial whenever we + # release a new version of api_demo_server. + 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. diff --git a/examples/k8s-1-minimal/tests/integration/test_charm.py b/examples/k8s-1-minimal/tests/integration/test_charm.py index c6fcbb3be..d55afed74 100644 --- a/examples/k8s-1-minimal/tests/integration/test_charm.py +++ b/examples/k8s-1-minimal/tests/integration/test_charm.py @@ -46,6 +46,6 @@ def test_workload_version_is_set(juju: jubilant.Juju): version = juju.status().apps["fastapi-demo"].version # Ideally, the test should get the version directly from the workload application # (for example, through an API call) and use that in this assertion. - # For simplicity, we hardcode the major version here to avoid updates - # if api_demo_server is updated with a minor or patch release. - assert version.startswith("1.") + # For simplicity, we hardcode the version here. We update the tutorial whenever we + # release a new version of api_demo_server. + assert version == "1.0.4" diff --git a/examples/k8s-2-configurable/tests/integration/test_charm.py b/examples/k8s-2-configurable/tests/integration/test_charm.py index c6fcbb3be..d55afed74 100644 --- a/examples/k8s-2-configurable/tests/integration/test_charm.py +++ b/examples/k8s-2-configurable/tests/integration/test_charm.py @@ -46,6 +46,6 @@ def test_workload_version_is_set(juju: jubilant.Juju): version = juju.status().apps["fastapi-demo"].version # Ideally, the test should get the version directly from the workload application # (for example, through an API call) and use that in this assertion. - # For simplicity, we hardcode the major version here to avoid updates - # if api_demo_server is updated with a minor or patch release. - assert version.startswith("1.") + # For simplicity, we hardcode the version here. We update the tutorial whenever we + # release a new version of api_demo_server. + assert version == "1.0.4" diff --git a/examples/k8s-3-postgresql/tests/integration/test_charm.py b/examples/k8s-3-postgresql/tests/integration/test_charm.py index c2c6e2df3..71c79be95 100644 --- a/examples/k8s-3-postgresql/tests/integration/test_charm.py +++ b/examples/k8s-3-postgresql/tests/integration/test_charm.py @@ -59,6 +59,6 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): version = juju.status().apps["fastapi-demo"].version # Ideally, the test should get the version directly from the workload application # (for example, through an API call) and use that in this assertion. - # For simplicity, we hardcode the major version here to avoid updates - # if api_demo_server is updated with a minor or patch release. - assert version.startswith("1.") + # For simplicity, we hardcode the version here. We update the tutorial whenever we + # release a new version of api_demo_server. + assert version == "1.0.4" diff --git a/examples/k8s-4-action/tests/integration/test_charm.py b/examples/k8s-4-action/tests/integration/test_charm.py index 58fa73a19..0ae85612a 100644 --- a/examples/k8s-4-action/tests/integration/test_charm.py +++ b/examples/k8s-4-action/tests/integration/test_charm.py @@ -59,6 +59,6 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): version = juju.status().apps["fastapi-demo"].version # Ideally, the test should get the version directly from the workload application # (for example, through an API call) and use that in this assertion. - # For simplicity, we hardcode the major version here to avoid updates - # if api_demo_server is updated with a minor or patch release. - assert version.startswith("1.") + # For simplicity, we hardcode the version here. We update the tutorial whenever we + # release a new version of api_demo_server. + assert version == "1.0.4" diff --git a/examples/k8s-5-observe/tests/integration/test_charm.py b/examples/k8s-5-observe/tests/integration/test_charm.py index 485e2f8f3..6ea8c7c86 100644 --- a/examples/k8s-5-observe/tests/integration/test_charm.py +++ b/examples/k8s-5-observe/tests/integration/test_charm.py @@ -63,9 +63,9 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): version = juju.status().apps["fastapi-demo"].version # Ideally, the test should get the version directly from the workload application # (for example, through an API call) and use that in this assertion. - # For simplicity, we hardcode the major version here to avoid updates - # if api_demo_server is updated with a minor or patch release. - assert version.startswith("1.") + # For simplicity, we hardcode the version here. We update the tutorial whenever we + # release a new version of api_demo_server. + assert version == "1.0.4" @pytest.fixture(scope="module") From 54d186ea5aea4e1eacb92b0dd56faf74c95a4a72 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Tue, 23 Jun 2026 09:08:06 +1000 Subject: [PATCH 15/26] put error handling into get_version method; let JSONDecodeError bubble up; use a hard code message for BlockedStatus; add explanation on why mock_get_version is use --- .../create-a-minimal-kubernetes-charm.md | 11 ++++++++++- .../integrate-your-charm-with-postgresql.md | 11 +++++------ .../make-your-charm-configurable.md | 15 ++++++++------- examples/k8s-1-minimal/src/fastapi_demo.py | 11 +++++++++-- examples/k8s-2-configurable/src/charm.py | 11 ++++------- examples/k8s-2-configurable/src/fastapi_demo.py | 11 +++++++++-- examples/k8s-3-postgresql/src/charm.py | 9 ++------- examples/k8s-3-postgresql/src/fastapi_demo.py | 11 +++++++++-- .../k8s-3-postgresql/tests/unit/test_charm.py | 2 +- examples/k8s-4-action/src/charm.py | 9 ++------- examples/k8s-4-action/src/fastapi_demo.py | 11 +++++++++-- examples/k8s-5-observe/src/charm.py | 9 ++------- examples/k8s-5-observe/src/fastapi_demo.py | 13 ++++++++++--- 13 files changed, 80 insertions(+), 54 deletions(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index 5b1fa0062..39412a026 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -98,6 +98,7 @@ Replace the content of `src/fastapi_demo.py` with: ```python import json import logging +import urllib.error import urllib.request logger = logging.getLogger(__name__) @@ -108,8 +109,14 @@ def get_version(port: int) -> str: Args: port: The port where fastapi_demo web server is listening. + + Raises: + RuntimeError: If the server can't be reached, for example because of an invalid port. """ - response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + try: + response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + except urllib.error.URLError as e: + raise RuntimeError(f"Could not connect to the workload server on port {port}") from e data = json.loads(response.read()) return data["version"] ``` @@ -139,6 +146,8 @@ Replace the contents of `src/charm.py` with: import ops +import fastapi_demo + class FastAPIDemoCharm(ops.CharmBase): """Charm the service.""" diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md index 22ce57ce7..504bd1456 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md @@ -240,11 +240,8 @@ def _replan_workload(self) -> None: try: version = fastapi_demo.get_version(config.server_port) - except (urllib.error.URLError, json.JSONDecodeError) as version_e: - logger.error( - "Failed to get version from the server: %s. Please double check your port config", - version_e, - ) + except RuntimeError as version_e: + logger.error("Failed to get workload version: %s", version_e) return self.unit.set_workload_version(version) @@ -344,7 +341,9 @@ self.unit.status = ops.BlockedStatus(str(e)) ``` ```python -self.unit.status = ops.BlockedStatus(str(version_e)) +self.unit.status = ops.BlockedStatus( + "Failed to get version from server: check port config" +) ``` ## Validate your charm diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md index 170ebe8b2..0ba7f50eb 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md @@ -59,7 +59,7 @@ class FastAPIConfig: raise ValueError("Invalid port number, 22 is reserved for SSH") ``` -Then, still in `src/charm.py`, add `import dataclasses`, `import json`, and `import urllib.error` in the imports at the top of the file. +Then, still in `src/charm.py`, add `import dataclasses` in the imports at the top of the file. We'll use [](CharmBase.load_config) to create an instance of your config class from the Juju config data. This allows IDEs to provide hints when we are accessing the configuration, and static type checkers are able to validate that we are using the config option correctly. @@ -134,12 +134,11 @@ def _replan_workload(self) -> None: try: version = fastapi_demo.get_version(config.server_port) - except (urllib.error.URLError, json.JSONDecodeError) as version_e: - logger.error( - "Failed to get version from the server: %s. Please double check your port config", - version_e, + except RuntimeError as version_e: + logger.error("Failed to get workload version: %s", version_e) + self.unit.status = ops.BlockedStatus( + "Failed to get version from server: check port config" ) - self.unit.status = ops.BlockedStatus(str(version_e)) return self.unit.status = ops.ActiveStatus() @@ -148,7 +147,7 @@ def _replan_workload(self) -> None: 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. -We also add error handling for getting the workload version. If the charm fails to obtain a valid version number, we set the unit status to blocked. This lets the Juju user know that either the charm code or the workload application behaved unexpectedly. +We also add error handling for getting the workload version. If we cannot access the workload to get its version, a `RuntimeError` will be raised. The `_replan_workload` method will log the error, and set the unit status to `blocked` with a message. It lets the Juju user know that they need to check the port configuration. Now, crucially, update the `_get_pebble_layer` method to make the layer definition dynamic, as shown below. This will replace the static port `8000` with the port passed to the method. @@ -274,6 +273,8 @@ def test_config_changed(mock_version): assert "--port=8080" in command ``` +We needs the `mock_version` fixture because `_on_config_changed` calls `_replan_workload`, which gets the workload version by `get_version`. The fixture patches `get_version` to avoid making a real HTTP call and make this 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 diff --git a/examples/k8s-1-minimal/src/fastapi_demo.py b/examples/k8s-1-minimal/src/fastapi_demo.py index 83dfd9df8..0e3384d47 100644 --- a/examples/k8s-1-minimal/src/fastapi_demo.py +++ b/examples/k8s-1-minimal/src/fastapi_demo.py @@ -21,17 +21,24 @@ import json import logging +import urllib.error import urllib.request logger = logging.getLogger(__name__) def get_version(port: int) -> str: - """Get the version of fastapi_demo installed. + """Get the version of fastapi_demo that is running. Args: port: The port where fastapi_demo web server is listening. + + Raises: + RuntimeError: If the server can't be reached, for example because of an invalid port. """ - response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + try: + response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + except urllib.error.URLError as e: + raise RuntimeError(f"Could not connect to the workload server on port {port}") from e data = json.loads(response.read()) return data["version"] diff --git a/examples/k8s-2-configurable/src/charm.py b/examples/k8s-2-configurable/src/charm.py index 3ac7202af..444508cf7 100755 --- a/examples/k8s-2-configurable/src/charm.py +++ b/examples/k8s-2-configurable/src/charm.py @@ -17,9 +17,7 @@ """Kubernetes charm for a demo app.""" import dataclasses -import json import logging -import urllib.error import ops @@ -96,12 +94,11 @@ def _replan_workload(self) -> None: try: version = fastapi_demo.get_version(config.server_port) - except (urllib.error.URLError, json.JSONDecodeError) as version_e: - logger.error( - "Failed to get version from the server: %s. Please double check your port config", - version_e, + except RuntimeError as version_e: + logger.error("Failed to get workload version: %s", version_e) + self.unit.status = ops.BlockedStatus( + "Failed to get version from server: check port config" ) - self.unit.status = ops.BlockedStatus(str(version_e)) return self.unit.status = ops.ActiveStatus() diff --git a/examples/k8s-2-configurable/src/fastapi_demo.py b/examples/k8s-2-configurable/src/fastapi_demo.py index 83dfd9df8..0e3384d47 100644 --- a/examples/k8s-2-configurable/src/fastapi_demo.py +++ b/examples/k8s-2-configurable/src/fastapi_demo.py @@ -21,17 +21,24 @@ import json import logging +import urllib.error import urllib.request logger = logging.getLogger(__name__) def get_version(port: int) -> str: - """Get the version of fastapi_demo installed. + """Get the version of fastapi_demo that is running. Args: port: The port where fastapi_demo web server is listening. + + Raises: + RuntimeError: If the server can't be reached, for example because of an invalid port. """ - response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + try: + response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + except urllib.error.URLError as e: + raise RuntimeError(f"Could not connect to the workload server on port {port}") from e data = json.loads(response.read()) return data["version"] diff --git a/examples/k8s-3-postgresql/src/charm.py b/examples/k8s-3-postgresql/src/charm.py index d6e2563da..4614de34d 100755 --- a/examples/k8s-3-postgresql/src/charm.py +++ b/examples/k8s-3-postgresql/src/charm.py @@ -17,9 +17,7 @@ """Kubernetes charm for a demo app.""" import dataclasses -import json import logging -import urllib.error import ops @@ -141,11 +139,8 @@ def _replan_workload(self) -> None: try: version = fastapi_demo.get_version(config.server_port) - except (urllib.error.URLError, json.JSONDecodeError) as version_e: - logger.error( - "Failed to get version from the server: %s. Please double check your port config", - version_e, - ) + except RuntimeError as version_e: + logger.error("Failed to get workload version: %s", version_e) return self.unit.set_workload_version(version) diff --git a/examples/k8s-3-postgresql/src/fastapi_demo.py b/examples/k8s-3-postgresql/src/fastapi_demo.py index 83dfd9df8..0e3384d47 100644 --- a/examples/k8s-3-postgresql/src/fastapi_demo.py +++ b/examples/k8s-3-postgresql/src/fastapi_demo.py @@ -21,17 +21,24 @@ import json import logging +import urllib.error import urllib.request logger = logging.getLogger(__name__) def get_version(port: int) -> str: - """Get the version of fastapi_demo installed. + """Get the version of fastapi_demo that is running. Args: port: The port where fastapi_demo web server is listening. + + Raises: + RuntimeError: If the server can't be reached, for example because of an invalid port. """ - response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + try: + response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + except urllib.error.URLError as e: + raise RuntimeError(f"Could not connect to the workload server on port {port}") from e data = json.loads(response.read()) return data["version"] diff --git a/examples/k8s-3-postgresql/tests/unit/test_charm.py b/examples/k8s-3-postgresql/tests/unit/test_charm.py index 4aec6c0c1..4922d2a1b 100644 --- a/examples/k8s-3-postgresql/tests/unit/test_charm.py +++ b/examples/k8s-3-postgresql/tests/unit/test_charm.py @@ -23,7 +23,7 @@ def mock_get_version(port: int): """Get a mock version string without executing the workload code.""" - return "1.0.4" + return "0.0.1" @pytest.fixture diff --git a/examples/k8s-4-action/src/charm.py b/examples/k8s-4-action/src/charm.py index 2181af1fa..483286135 100755 --- a/examples/k8s-4-action/src/charm.py +++ b/examples/k8s-4-action/src/charm.py @@ -17,9 +17,7 @@ """Kubernetes charm for a demo app.""" import dataclasses -import json import logging -import urllib.error import ops @@ -181,11 +179,8 @@ def _replan_workload(self) -> None: try: version = fastapi_demo.get_version(config.server_port) - except (urllib.error.URLError, json.JSONDecodeError) as version_e: - logger.error( - "Failed to get version from the server: %s. Please double check your port config", - version_e, - ) + except RuntimeError as version_e: + logger.error("Failed to get workload version: %s", version_e) return self.unit.set_workload_version(version) diff --git a/examples/k8s-4-action/src/fastapi_demo.py b/examples/k8s-4-action/src/fastapi_demo.py index 83dfd9df8..0e3384d47 100644 --- a/examples/k8s-4-action/src/fastapi_demo.py +++ b/examples/k8s-4-action/src/fastapi_demo.py @@ -21,17 +21,24 @@ import json import logging +import urllib.error import urllib.request logger = logging.getLogger(__name__) def get_version(port: int) -> str: - """Get the version of fastapi_demo installed. + """Get the version of fastapi_demo that is running. Args: port: The port where fastapi_demo web server is listening. + + Raises: + RuntimeError: If the server can't be reached, for example because of an invalid port. """ - response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + try: + response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + except urllib.error.URLError as e: + raise RuntimeError(f"Could not connect to the workload server on port {port}") from e data = json.loads(response.read()) return data["version"] diff --git a/examples/k8s-5-observe/src/charm.py b/examples/k8s-5-observe/src/charm.py index 451e47880..a8d1e284a 100755 --- a/examples/k8s-5-observe/src/charm.py +++ b/examples/k8s-5-observe/src/charm.py @@ -17,9 +17,7 @@ """Kubernetes charm for a demo app.""" import dataclasses -import json import logging -import urllib.error import ops @@ -202,11 +200,8 @@ def _replan_workload(self) -> None: try: version = fastapi_demo.get_version(config.server_port) - except (urllib.error.URLError, json.JSONDecodeError) as version_e: - logger.error( - "Failed to get version from the server: %s. Please double check your port config", - version_e, - ) + except RuntimeError as version_e: + logger.error("Failed to get workload version: %s", version_e) return self.unit.set_workload_version(version) diff --git a/examples/k8s-5-observe/src/fastapi_demo.py b/examples/k8s-5-observe/src/fastapi_demo.py index 83dfd9df8..74b05a137 100644 --- a/examples/k8s-5-observe/src/fastapi_demo.py +++ b/examples/k8s-5-observe/src/fastapi_demo.py @@ -21,17 +21,24 @@ import json import logging +import urllib.error import urllib.request logger = logging.getLogger(__name__) def get_version(port: int) -> str: - """Get the version of fastapi_demo installed. + """Get the version of fastapi_demo that is running. Args: port: The port where fastapi_demo web server is listening. + + Raises: + RuntimeError: If the server can't be reached, for example because of an invalid port. """ - response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + try: + response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") + except urllib.error.URLError as e: + raise RuntimeError(f"Could not connect to the workload server on port {port}") from e data = json.loads(response.read()) - return data["version"] + return data["version"] \ No newline at end of file From 0908fdbb93a55c649beb1b3304db69ddd592bd09 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Tue, 23 Jun 2026 09:46:42 +1000 Subject: [PATCH 16/26] fix linting issues for chapter 5 example code --- examples/k8s-5-observe/src/fastapi_demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/k8s-5-observe/src/fastapi_demo.py b/examples/k8s-5-observe/src/fastapi_demo.py index 74b05a137..0e3384d47 100644 --- a/examples/k8s-5-observe/src/fastapi_demo.py +++ b/examples/k8s-5-observe/src/fastapi_demo.py @@ -41,4 +41,4 @@ def get_version(port: int) -> str: except urllib.error.URLError as e: raise RuntimeError(f"Could not connect to the workload server on port {port}") from e data = json.loads(response.read()) - return data["version"] \ No newline at end of file + return data["version"] From 8d596b3622ab68cda882197294a5224fb9236ae8 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Wed, 24 Jun 2026 09:39:24 +1000 Subject: [PATCH 17/26] remove mock_version from test_get_db_info* test functions --- .../expose-operational-tasks-via-actions.md | 4 ++-- examples/k8s-4-action/tests/unit/test_charm.py | 4 ++-- examples/k8s-5-observe/tests/unit/test_charm.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/expose-operational-tasks-via-actions.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/expose-operational-tasks-via-actions.md index 03b91d3a8..60dc9c22c 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/expose-operational-tasks-via-actions.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/expose-operational-tasks-via-actions.md @@ -156,7 +156,7 @@ Congratulations, you now know how to expose operational tasks via actions! Let's add a test to check the behaviour of the `get_db_info` action that we just set up. Our test sets up the context, defines the input state with a relation, then runs the action and checks whether the results match the expected values: ```python -def test_get_db_info_action(mock_version): +def test_get_db_info_action(): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", @@ -186,7 +186,7 @@ def test_get_db_info_action(mock_version): Since the `get_db_info` action has a parameter `show-password`, let's also add a test to cover the case where the user wants to show the password: ```python -def test_get_db_info_action_show_password(mock_version): +def test_get_db_info_action_show_password(): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", diff --git a/examples/k8s-4-action/tests/unit/test_charm.py b/examples/k8s-4-action/tests/unit/test_charm.py index e9d26449b..333b16576 100644 --- a/examples/k8s-4-action/tests/unit/test_charm.py +++ b/examples/k8s-4-action/tests/unit/test_charm.py @@ -142,7 +142,7 @@ def test_no_database_blocked(mock_version): assert state_out.unit_status == testing.BlockedStatus("Waiting for database relation") -def test_get_db_info_action(mock_version): +def test_get_db_info_action(): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", @@ -169,7 +169,7 @@ def test_get_db_info_action(mock_version): } -def test_get_db_info_action_show_password(mock_version): +def test_get_db_info_action_show_password(): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", diff --git a/examples/k8s-5-observe/tests/unit/test_charm.py b/examples/k8s-5-observe/tests/unit/test_charm.py index 6ede5cfc3..5908919ff 100644 --- a/examples/k8s-5-observe/tests/unit/test_charm.py +++ b/examples/k8s-5-observe/tests/unit/test_charm.py @@ -140,7 +140,7 @@ def test_no_database_blocked(mock_version): assert state_out.unit_status == testing.BlockedStatus("Waiting for database relation") -def test_get_db_info_action(mock_version): +def test_get_db_info_action(): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", @@ -167,7 +167,7 @@ def test_get_db_info_action(mock_version): } -def test_get_db_info_action_show_password(mock_version): +def test_get_db_info_action_show_password(): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", From 38fff1d6fc1c27af7642e18885e4fd262c7662ac Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Wed, 24 Jun 2026 09:50:07 +1000 Subject: [PATCH 18/26] update wording following pr feedbacks --- .../make-your-charm-configurable.md | 4 ++-- .../observe-your-charm-with-cos-lite.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md index 0ba7f50eb..69a8d02b8 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md @@ -147,7 +147,7 @@ def _replan_workload(self) -> None: 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. -We also add error handling for getting the workload version. If we cannot access the workload to get its version, a `RuntimeError` will be raised. The `_replan_workload` method will log the error, and set the unit status to `blocked` with a message. It lets the Juju user know that they need to check the port configuration. +We also add error handling for getting the workload version. If we cannot access the workload to get its version, a `RuntimeError` will be raised. The `_replan_workload` method will log the error, and set the unit status to `blocked` with a message. This lets the Juju user know that they need to check the port configuration. Now, crucially, update the `_get_pebble_layer` method to make the layer definition dynamic, as shown below. This will replace the static port `8000` with the port passed to the method. @@ -273,7 +273,7 @@ def test_config_changed(mock_version): assert "--port=8080" in command ``` -We needs the `mock_version` fixture because `_on_config_changed` calls `_replan_workload`, which gets the workload version by `get_version`. The fixture patches `get_version` to avoid making a real HTTP call and make this unit test deterministic. +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: diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/observe-your-charm-with-cos-lite.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/observe-your-charm-with-cos-lite.md index 66ec0bba0..4636cbfc1 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/observe-your-charm-with-cos-lite.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/observe-your-charm-with-cos-lite.md @@ -480,7 +480,7 @@ 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` and `pytest_jubilant`, which are already dependencies of your charm. We also need a new dependency `requests`. Run the following command to add it: +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 your charm. Also import `requests`, which is a new dependency. Run the following command to add `requests` as a dependency: ```text uv add requests From 49e6a120fd682e8fa0a8c1bae5113c402727201a Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Wed, 24 Jun 2026 09:51:44 +1000 Subject: [PATCH 19/26] remove newlines from _replan_workload functions --- .../integrate-your-charm-with-postgresql.md | 2 -- .../make-your-charm-configurable.md | 2 -- examples/k8s-2-configurable/src/charm.py | 2 -- examples/k8s-3-postgresql/src/charm.py | 2 -- examples/k8s-4-action/src/charm.py | 2 -- examples/k8s-5-observe/src/charm.py | 2 -- 6 files changed, 12 deletions(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md index 504bd1456..b5e448759 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md @@ -237,13 +237,11 @@ def _replan_workload(self) -> None: except (ops.pebble.APIError, ops.pebble.ConnectionError) as e: logger.info("Unable to connect to Pebble: %s", e) return - try: version = fastapi_demo.get_version(config.server_port) except RuntimeError as version_e: logger.error("Failed to get workload version: %s", version_e) return - self.unit.set_workload_version(version) ``` diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md index 69a8d02b8..a4d260410 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md @@ -131,7 +131,6 @@ def _replan_workload(self) -> None: logger.info("Unable to connect to Pebble: %s", e) self.unit.status = ops.MaintenanceStatus("Waiting for Pebble in workload container") return - try: version = fastapi_demo.get_version(config.server_port) except RuntimeError as version_e: @@ -140,7 +139,6 @@ def _replan_workload(self) -> None: "Failed to get version from server: check port config" ) return - self.unit.status = ops.ActiveStatus() self.unit.set_workload_version(version) ``` diff --git a/examples/k8s-2-configurable/src/charm.py b/examples/k8s-2-configurable/src/charm.py index 444508cf7..ddbac97ff 100755 --- a/examples/k8s-2-configurable/src/charm.py +++ b/examples/k8s-2-configurable/src/charm.py @@ -91,7 +91,6 @@ def _replan_workload(self) -> None: logger.info("Unable to connect to Pebble: %s", e) self.unit.status = ops.MaintenanceStatus("Waiting for Pebble in workload container") return - try: version = fastapi_demo.get_version(config.server_port) except RuntimeError as version_e: @@ -100,7 +99,6 @@ def _replan_workload(self) -> None: "Failed to get version from server: check port config" ) return - self.unit.status = ops.ActiveStatus() self.unit.set_workload_version(version) diff --git a/examples/k8s-3-postgresql/src/charm.py b/examples/k8s-3-postgresql/src/charm.py index 4614de34d..25a39b5d0 100755 --- a/examples/k8s-3-postgresql/src/charm.py +++ b/examples/k8s-3-postgresql/src/charm.py @@ -136,13 +136,11 @@ def _replan_workload(self) -> None: except (ops.pebble.APIError, ops.pebble.ConnectionError) as e: logger.info("Unable to connect to Pebble: %s", e) return - try: version = fastapi_demo.get_version(config.server_port) except RuntimeError as version_e: logger.error("Failed to get workload version: %s", version_e) return - self.unit.set_workload_version(version) def _get_pebble_layer(self, port: int, environment: dict[str, str]) -> ops.pebble.Layer: diff --git a/examples/k8s-4-action/src/charm.py b/examples/k8s-4-action/src/charm.py index 483286135..7a75c69bb 100755 --- a/examples/k8s-4-action/src/charm.py +++ b/examples/k8s-4-action/src/charm.py @@ -176,13 +176,11 @@ def _replan_workload(self) -> None: except (ops.pebble.APIError, ops.pebble.ConnectionError) as e: logger.info("Unable to connect to Pebble: %s", e) return - try: version = fastapi_demo.get_version(config.server_port) except RuntimeError as version_e: logger.error("Failed to get workload version: %s", version_e) return - self.unit.set_workload_version(version) def _get_pebble_layer(self, port: int, environment: dict[str, str]) -> ops.pebble.Layer: diff --git a/examples/k8s-5-observe/src/charm.py b/examples/k8s-5-observe/src/charm.py index a8d1e284a..09e028212 100755 --- a/examples/k8s-5-observe/src/charm.py +++ b/examples/k8s-5-observe/src/charm.py @@ -197,13 +197,11 @@ def _replan_workload(self) -> None: except (ops.pebble.APIError, ops.pebble.ConnectionError) as e: logger.info("Unable to connect to Pebble: %s", e) return - try: version = fastapi_demo.get_version(config.server_port) except RuntimeError as version_e: logger.error("Failed to get workload version: %s", version_e) return - self.unit.set_workload_version(version) def _get_pebble_layer(self, port: int, environment: dict[str, str]) -> ops.pebble.Layer: From 272592ca173e09bc9653f97f88446a9ea2b71be1 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Wed, 24 Jun 2026 09:58:56 +1000 Subject: [PATCH 20/26] return the BlockedStatus block to _replan_workload from chapter 3 onwards --- .../integrate-your-charm-with-postgresql.md | 9 +++------ examples/k8s-3-postgresql/src/charm.py | 3 +++ examples/k8s-4-action/src/charm.py | 3 +++ examples/k8s-5-observe/src/charm.py | 3 +++ 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md index b5e448759..6d5190956 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md @@ -241,6 +241,9 @@ def _replan_workload(self) -> None: version = fastapi_demo.get_version(config.server_port) except RuntimeError as version_e: logger.error("Failed to get workload version: %s", version_e) + self.unit.status = ops.BlockedStatus( + "Failed to get version from server: check port config" + ) return self.unit.set_workload_version(version) ``` @@ -338,12 +341,6 @@ self.unit.status = ops.MaintenanceStatus("Waiting for Pebble in workload contain self.unit.status = ops.BlockedStatus(str(e)) ``` -```python -self.unit.status = ops.BlockedStatus( - "Failed to get version from server: check port config" -) -``` - ## Validate your charm Time to check the results! diff --git a/examples/k8s-3-postgresql/src/charm.py b/examples/k8s-3-postgresql/src/charm.py index 25a39b5d0..8ec876475 100755 --- a/examples/k8s-3-postgresql/src/charm.py +++ b/examples/k8s-3-postgresql/src/charm.py @@ -140,6 +140,9 @@ def _replan_workload(self) -> None: version = fastapi_demo.get_version(config.server_port) except RuntimeError as version_e: logger.error("Failed to get workload version: %s", version_e) + self.unit.status = ops.BlockedStatus( + "Failed to get version from server: check port config" + ) return self.unit.set_workload_version(version) diff --git a/examples/k8s-4-action/src/charm.py b/examples/k8s-4-action/src/charm.py index 7a75c69bb..2c55eb669 100755 --- a/examples/k8s-4-action/src/charm.py +++ b/examples/k8s-4-action/src/charm.py @@ -180,6 +180,9 @@ def _replan_workload(self) -> None: version = fastapi_demo.get_version(config.server_port) except RuntimeError as version_e: logger.error("Failed to get workload version: %s", version_e) + self.unit.status = ops.BlockedStatus( + "Failed to get version from server: check port config" + ) return self.unit.set_workload_version(version) diff --git a/examples/k8s-5-observe/src/charm.py b/examples/k8s-5-observe/src/charm.py index 09e028212..f91eebc09 100755 --- a/examples/k8s-5-observe/src/charm.py +++ b/examples/k8s-5-observe/src/charm.py @@ -201,6 +201,9 @@ def _replan_workload(self) -> None: version = fastapi_demo.get_version(config.server_port) except RuntimeError as version_e: logger.error("Failed to get workload version: %s", version_e) + self.unit.status = ops.BlockedStatus( + "Failed to get version from server: check port config" + ) return self.unit.set_workload_version(version) From befcb27c85df565bc495add258818da5997a97db Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Wed, 24 Jun 2026 23:45:06 +1000 Subject: [PATCH 21/26] address PR feedback --- .../integrate-your-charm-with-postgresql.md | 2 +- .../observe-your-charm-with-cos-lite.md | 10 +++++----- examples/k8s-5-observe/pyproject.toml | 2 +- examples/k8s-5-observe/tests/unit/test_charm.py | 2 ++ examples/k8s-5-observe/uv.lock | 2 +- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md index 3b0c23222..5194ac95c 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md @@ -248,7 +248,7 @@ def _replan_workload(self) -> None: self.unit.set_workload_version(version) ``` -We removed four `self.unit.status = ` lines from this version of the method. We'll handle replacing those shortly. +We removed three `self.unit.status = ` lines from this version of the method. We'll handle replacing those shortly. Next, update `_get_pebble_layer()` to put the environment variables in the Pebble layer: diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/observe-your-charm-with-cos-lite.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/observe-your-charm-with-cos-lite.md index 4636cbfc1..f0192f8b8 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/observe-your-charm-with-cos-lite.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/observe-your-charm-with-cos-lite.md @@ -480,10 +480,10 @@ 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` and `pytest_jubilant`, which are already dependencies of your charm. Also import `requests`, which is a new dependency. Run the following command to add `requests` as a dependency: +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 requests +uv add --group integration requests ``` Your imports should now look like this: @@ -519,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): """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") @@ -539,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 diff --git a/examples/k8s-5-observe/pyproject.toml b/examples/k8s-5-observe/pyproject.toml index 1fa4bc875..dd2b4c757 100644 --- a/examples/k8s-5-observe/pyproject.toml +++ b/examples/k8s-5-observe/pyproject.toml @@ -24,7 +24,7 @@ requires-python = ">=3.10" # 'charm-libs' block in charmcraft.yaml, run `charmcraft fetch-libs` to download the libraries, # then inspect the libraries for dependencies specified in PYDEPS. List those dependencies here. dependencies = [ - "cosl>=1.9.1", + "cosl>=1.9.1,<2", "ops~=3.7", ] diff --git a/examples/k8s-5-observe/tests/unit/test_charm.py b/examples/k8s-5-observe/tests/unit/test_charm.py index 200f53120..c66a5b610 100644 --- a/examples/k8s-5-observe/tests/unit/test_charm.py +++ b/examples/k8s-5-observe/tests/unit/test_charm.py @@ -62,6 +62,8 @@ def test_pebble_layer(mock_version): 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 def test_config_changed(mock_version): diff --git a/examples/k8s-5-observe/uv.lock b/examples/k8s-5-observe/uv.lock index 3f1c08446..ff28cc1ae 100644 --- a/examples/k8s-5-observe/uv.lock +++ b/examples/k8s-5-observe/uv.lock @@ -275,7 +275,7 @@ unit = [ [package.metadata] requires-dist = [ - { name = "cosl", specifier = ">=1.9.1" }, + { name = "cosl", specifier = ">=1.9.1,<2" }, { name = "ops", specifier = "~=3.7" }, ] From 16190ec7cb28cc9516aa46b2cf68c00cd56c1111 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Sat, 27 Jun 2026 10:58:32 +1000 Subject: [PATCH 22/26] improve the text on how workload application and charm can communicate via localhost --- .../create-a-minimal-kubernetes-charm.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index 39412a026..423b921df 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -11,7 +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 using HTTP requests. This is possible because the charm container and workload container share the same pod. +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. From 790f3f229dd305be1d536d83e9c5cc163ed057c0 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Sat, 27 Jun 2026 11:20:59 +1000 Subject: [PATCH 23/26] use APP_NAME instead of haredcoding the charm name in integration tests --- .../create-a-minimal-kubernetes-charm.md | 2 +- .../integrate-your-charm-with-postgresql.md | 2 +- examples/k8s-1-minimal/tests/integration/test_charm.py | 2 +- examples/k8s-2-configurable/tests/integration/test_charm.py | 2 +- examples/k8s-3-postgresql/tests/integration/test_charm.py | 2 +- examples/k8s-4-action/tests/integration/test_charm.py | 2 +- examples/k8s-5-observe/tests/integration/test_charm.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index 423b921df..701ca2047 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -525,7 +525,7 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju): def test_workload_version_is_set(juju: jubilant.Juju): # Verify that the workload version has been set. - version = juju.status().apps["fastapi-demo"].version + version = juju.status().apps[APP_NAME].version # Ideally, the test should get the version directly from the workload application # (for example, through an API call) and use that in this assertion. # For simplicity, we hardcode the version here. We update the tutorial whenever we diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md index 5194ac95c..335d97e5a 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md @@ -533,7 +533,7 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): juju.integrate(APP_NAME, "postgresql-k8s") juju.wait(jubilant.all_active) - version = juju.status().apps["fastapi-demo"].version + version = juju.status().apps[APP_NAME].version # Ideally, the test should get the version directly from the workload application # (for example, through an API call) and use that in this assertion. # For simplicity, we hardcode the version here. We update the tutorial whenever we diff --git a/examples/k8s-1-minimal/tests/integration/test_charm.py b/examples/k8s-1-minimal/tests/integration/test_charm.py index f1424ee84..f5c9a2f3b 100644 --- a/examples/k8s-1-minimal/tests/integration/test_charm.py +++ b/examples/k8s-1-minimal/tests/integration/test_charm.py @@ -43,7 +43,7 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju): def test_workload_version_is_set(juju: jubilant.Juju): # Verify that the workload version has been set. - version = juju.status().apps["fastapi-demo"].version + version = juju.status().apps[APP_NAME].version # Ideally, the test should get the version directly from the workload application # (for example, through an API call) and use that in this assertion. # For simplicity, we hardcode the version here. We update the tutorial whenever we diff --git a/examples/k8s-2-configurable/tests/integration/test_charm.py b/examples/k8s-2-configurable/tests/integration/test_charm.py index f1424ee84..f5c9a2f3b 100644 --- a/examples/k8s-2-configurable/tests/integration/test_charm.py +++ b/examples/k8s-2-configurable/tests/integration/test_charm.py @@ -43,7 +43,7 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju): def test_workload_version_is_set(juju: jubilant.Juju): # Verify that the workload version has been set. - version = juju.status().apps["fastapi-demo"].version + version = juju.status().apps[APP_NAME].version # Ideally, the test should get the version directly from the workload application # (for example, through an API call) and use that in this assertion. # For simplicity, we hardcode the version here. We update the tutorial whenever we diff --git a/examples/k8s-3-postgresql/tests/integration/test_charm.py b/examples/k8s-3-postgresql/tests/integration/test_charm.py index 244be25ea..d47f5dbe5 100644 --- a/examples/k8s-3-postgresql/tests/integration/test_charm.py +++ b/examples/k8s-3-postgresql/tests/integration/test_charm.py @@ -56,7 +56,7 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): juju.integrate(APP_NAME, "postgresql-k8s") juju.wait(jubilant.all_active, timeout=10 * 60) - version = juju.status().apps["fastapi-demo"].version + version = juju.status().apps[APP_NAME].version # Ideally, the test should get the version directly from the workload application # (for example, through an API call) and use that in this assertion. # For simplicity, we hardcode the version here. We update the tutorial whenever we diff --git a/examples/k8s-4-action/tests/integration/test_charm.py b/examples/k8s-4-action/tests/integration/test_charm.py index 1577075f9..e42c4a091 100644 --- a/examples/k8s-4-action/tests/integration/test_charm.py +++ b/examples/k8s-4-action/tests/integration/test_charm.py @@ -56,7 +56,7 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): juju.integrate(APP_NAME, "postgresql-k8s") juju.wait(jubilant.all_active) - version = juju.status().apps["fastapi-demo"].version + version = juju.status().apps[APP_NAME].version # Ideally, the test should get the version directly from the workload application # (for example, through an API call) and use that in this assertion. # For simplicity, we hardcode the version here. We update the tutorial whenever we diff --git a/examples/k8s-5-observe/tests/integration/test_charm.py b/examples/k8s-5-observe/tests/integration/test_charm.py index e185c9498..95c8e2edb 100644 --- a/examples/k8s-5-observe/tests/integration/test_charm.py +++ b/examples/k8s-5-observe/tests/integration/test_charm.py @@ -60,7 +60,7 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): juju.integrate(APP_NAME, "postgresql-k8s") juju.wait(jubilant.all_active) - version = juju.status().apps["fastapi-demo"].version + version = juju.status().apps[APP_NAME].version # Ideally, the test should get the version directly from the workload application # (for example, through an API call) and use that in this assertion. # For simplicity, we hardcode the version here. We update the tutorial whenever we From 9efb148b58d98b00cba48239e8271cbe570219fd Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Sat, 27 Jun 2026 11:23:30 +1000 Subject: [PATCH 24/26] use 'workload application' instead of api_demo_server to prevent confusion --- .../create-a-minimal-kubernetes-charm.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index 701ca2047..a45171f5e 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -87,9 +87,9 @@ resources: ### Write a helper module -Your charm will interact with our workload application `api_demo_server`. It's a good idea to write a helper module that wraps `api_demo_server`. Charmcraft created `src/fastapi_demo.py` as a placeholder 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 `api_demo_server`. The server has an endpoint at `/version` that returns a JSON payload containing the version number. This is called the workload version. +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`. @@ -124,7 +124,7 @@ def get_version(port: int) -> str: 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 `api_demo_server` and check its status. +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} From 1844a5b1e0d7680d58d68820ec173c030deeb618 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Sat, 27 Jun 2026 11:38:09 +1000 Subject: [PATCH 25/26] simplify the comment about hardcoding version in integration tests --- .../create-a-minimal-kubernetes-charm.md | 5 +---- .../integrate-your-charm-with-postgresql.md | 5 +---- examples/k8s-1-minimal/tests/integration/test_charm.py | 5 +---- examples/k8s-2-configurable/tests/integration/test_charm.py | 5 +---- examples/k8s-3-postgresql/tests/integration/test_charm.py | 5 +---- examples/k8s-4-action/tests/integration/test_charm.py | 5 +---- examples/k8s-5-observe/tests/integration/test_charm.py | 5 +---- 7 files changed, 7 insertions(+), 28 deletions(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index a45171f5e..bcd766bfc 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -526,10 +526,7 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju): def test_workload_version_is_set(juju: jubilant.Juju): # Verify that the workload version has been set. version = juju.status().apps[APP_NAME].version - # Ideally, the test should get the version directly from the workload application - # (for example, through an API call) and use that in this assertion. - # For simplicity, we hardcode the version here. We update the tutorial whenever we - # release a new version of api_demo_server. + # Hardcoded version for simplicity. Ideally we'd get the version directly from the workload. assert version == "1.0.4" ``` diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md index 335d97e5a..cbf7899a0 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md @@ -534,10 +534,7 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): juju.wait(jubilant.all_active) version = juju.status().apps[APP_NAME].version - # Ideally, the test should get the version directly from the workload application - # (for example, through an API call) and use that in this assertion. - # For simplicity, we hardcode the version here. We update the tutorial whenever we - # release a new version of api_demo_server. + # Hardcoded version for simplicity. Ideally we'd get the version directly from the workload. assert version == "1.0.4" ``` diff --git a/examples/k8s-1-minimal/tests/integration/test_charm.py b/examples/k8s-1-minimal/tests/integration/test_charm.py index f5c9a2f3b..06b601fc8 100644 --- a/examples/k8s-1-minimal/tests/integration/test_charm.py +++ b/examples/k8s-1-minimal/tests/integration/test_charm.py @@ -44,8 +44,5 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju): def test_workload_version_is_set(juju: jubilant.Juju): # Verify that the workload version has been set. version = juju.status().apps[APP_NAME].version - # Ideally, the test should get the version directly from the workload application - # (for example, through an API call) and use that in this assertion. - # For simplicity, we hardcode the version here. We update the tutorial whenever we - # release a new version of api_demo_server. + # Hardcoded version for simplicity. Ideally we'd get the version directly from the workload. assert version == "1.0.4" diff --git a/examples/k8s-2-configurable/tests/integration/test_charm.py b/examples/k8s-2-configurable/tests/integration/test_charm.py index f5c9a2f3b..06b601fc8 100644 --- a/examples/k8s-2-configurable/tests/integration/test_charm.py +++ b/examples/k8s-2-configurable/tests/integration/test_charm.py @@ -44,8 +44,5 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju): def test_workload_version_is_set(juju: jubilant.Juju): # Verify that the workload version has been set. version = juju.status().apps[APP_NAME].version - # Ideally, the test should get the version directly from the workload application - # (for example, through an API call) and use that in this assertion. - # For simplicity, we hardcode the version here. We update the tutorial whenever we - # release a new version of api_demo_server. + # Hardcoded version for simplicity. Ideally we'd get the version directly from the workload. assert version == "1.0.4" diff --git a/examples/k8s-3-postgresql/tests/integration/test_charm.py b/examples/k8s-3-postgresql/tests/integration/test_charm.py index d47f5dbe5..010fec6b8 100644 --- a/examples/k8s-3-postgresql/tests/integration/test_charm.py +++ b/examples/k8s-3-postgresql/tests/integration/test_charm.py @@ -57,8 +57,5 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): juju.wait(jubilant.all_active, timeout=10 * 60) version = juju.status().apps[APP_NAME].version - # Ideally, the test should get the version directly from the workload application - # (for example, through an API call) and use that in this assertion. - # For simplicity, we hardcode the version here. We update the tutorial whenever we - # release a new version of api_demo_server. + # Hardcoded version for simplicity. Ideally we'd get the version directly from the workload. assert version == "1.0.4" diff --git a/examples/k8s-4-action/tests/integration/test_charm.py b/examples/k8s-4-action/tests/integration/test_charm.py index e42c4a091..e6263b7e3 100644 --- a/examples/k8s-4-action/tests/integration/test_charm.py +++ b/examples/k8s-4-action/tests/integration/test_charm.py @@ -57,8 +57,5 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): juju.wait(jubilant.all_active) version = juju.status().apps[APP_NAME].version - # Ideally, the test should get the version directly from the workload application - # (for example, through an API call) and use that in this assertion. - # For simplicity, we hardcode the version here. We update the tutorial whenever we - # release a new version of api_demo_server. + # Hardcoded version for simplicity. Ideally we'd get the version directly from the workload. assert version == "1.0.4" diff --git a/examples/k8s-5-observe/tests/integration/test_charm.py b/examples/k8s-5-observe/tests/integration/test_charm.py index 95c8e2edb..19ea1aa6c 100644 --- a/examples/k8s-5-observe/tests/integration/test_charm.py +++ b/examples/k8s-5-observe/tests/integration/test_charm.py @@ -61,10 +61,7 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): juju.wait(jubilant.all_active) version = juju.status().apps[APP_NAME].version - # Ideally, the test should get the version directly from the workload application - # (for example, through an API call) and use that in this assertion. - # For simplicity, we hardcode the version here. We update the tutorial whenever we - # release a new version of api_demo_server. + # Hardcoded version for simplicity. Ideally we'd get the version directly from the workload. assert version == "1.0.4" From c97ebc4ba075d387bdacde9183b96d51b0faa357 Mon Sep 17 00:00:00 2001 From: Trong Nhan Mai Date: Sat, 27 Jun 2026 11:52:41 +1000 Subject: [PATCH 26/26] let the errors from get_version bubble up; change from 0.0.0.0 to localhost in get_version --- .../create-a-minimal-kubernetes-charm.md | 11 ++--------- .../integrate-your-charm-with-postgresql.md | 9 +-------- .../make-your-charm-configurable.md | 11 +---------- examples/k8s-1-minimal/src/fastapi_demo.py | 9 +-------- examples/k8s-2-configurable/src/charm.py | 9 +-------- examples/k8s-2-configurable/src/fastapi_demo.py | 9 +-------- examples/k8s-3-postgresql/src/charm.py | 9 +-------- examples/k8s-3-postgresql/src/fastapi_demo.py | 9 +-------- examples/k8s-4-action/src/charm.py | 9 +-------- examples/k8s-4-action/src/fastapi_demo.py | 9 +-------- examples/k8s-5-observe/src/charm.py | 9 +-------- examples/k8s-5-observe/src/fastapi_demo.py | 9 +-------- 12 files changed, 13 insertions(+), 99 deletions(-) diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md index bcd766bfc..e538fc404 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -98,7 +98,6 @@ Replace the content of `src/fastapi_demo.py` with: ```python import json import logging -import urllib.error import urllib.request logger = logging.getLogger(__name__) @@ -109,14 +108,8 @@ def get_version(port: int) -> str: Args: port: The port where fastapi_demo web server is listening. - - Raises: - RuntimeError: If the server can't be reached, for example because of an invalid port. """ - try: - response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") - except urllib.error.URLError as e: - raise RuntimeError(f"Could not connect to the workload server on port {port}") from e + response = urllib.request.urlopen(f"http://localhost:{port}/version") data = json.loads(response.read()) return data["version"] ``` @@ -252,7 +245,7 @@ version = fastapi_demo.get_version(port=8000) self.unit.set_workload_version(version) ``` -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. +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 diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md index cbf7899a0..2d9fd9cd1 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md @@ -237,14 +237,7 @@ def _replan_workload(self) -> None: except (ops.pebble.APIError, ops.pebble.ConnectionError) as e: logger.info("Unable to connect to Pebble: %s", e) return - try: - version = fastapi_demo.get_version(config.server_port) - except RuntimeError as version_e: - logger.error("Failed to get workload version: %s", version_e) - self.unit.status = ops.BlockedStatus( - "Failed to get version from server: check port config" - ) - return + version = fastapi_demo.get_version(config.server_port) self.unit.set_workload_version(version) ``` diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md index e01345b12..dd4d6b123 100644 --- a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md @@ -131,22 +131,13 @@ def _replan_workload(self) -> None: logger.info("Unable to connect to Pebble: %s", e) self.unit.status = ops.MaintenanceStatus("Waiting for Pebble in workload container") return - try: - version = fastapi_demo.get_version(config.server_port) - except RuntimeError as version_e: - logger.error("Failed to get workload version: %s", version_e) - self.unit.status = ops.BlockedStatus( - "Failed to get version from server: check port config" - ) - return self.unit.status = ops.ActiveStatus() + version = fastapi_demo.get_version(config.server_port) self.unit.set_workload_version(version) ``` 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. -We also add error handling for getting the workload version. If we cannot access the workload to get its version, a `RuntimeError` will be raised. The `_replan_workload` method will log the error, and set the unit status to `blocked` with a message. This lets the Juju user know that they need to check the port configuration. - Now, crucially, update the `_get_pebble_layer` method to make the layer definition dynamic, as shown below. This will replace the static port `8000` with the port passed to the method. ```python diff --git a/examples/k8s-1-minimal/src/fastapi_demo.py b/examples/k8s-1-minimal/src/fastapi_demo.py index 0e3384d47..fbcb8424b 100644 --- a/examples/k8s-1-minimal/src/fastapi_demo.py +++ b/examples/k8s-1-minimal/src/fastapi_demo.py @@ -21,7 +21,6 @@ import json import logging -import urllib.error import urllib.request logger = logging.getLogger(__name__) @@ -32,13 +31,7 @@ def get_version(port: int) -> str: Args: port: The port where fastapi_demo web server is listening. - - Raises: - RuntimeError: If the server can't be reached, for example because of an invalid port. """ - try: - response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") - except urllib.error.URLError as e: - raise RuntimeError(f"Could not connect to the workload server on port {port}") from e + response = urllib.request.urlopen(f"http://localhost:{port}/version") data = json.loads(response.read()) return data["version"] diff --git a/examples/k8s-2-configurable/src/charm.py b/examples/k8s-2-configurable/src/charm.py index fd3f968d6..5193a8c96 100755 --- a/examples/k8s-2-configurable/src/charm.py +++ b/examples/k8s-2-configurable/src/charm.py @@ -91,15 +91,8 @@ def _replan_workload(self) -> None: logger.info("Unable to connect to Pebble: %s", e) self.unit.status = ops.MaintenanceStatus("Waiting for Pebble in workload container") return - try: - version = fastapi_demo.get_version(config.server_port) - except RuntimeError as version_e: - logger.error("Failed to get workload version: %s", version_e) - self.unit.status = ops.BlockedStatus( - "Failed to get version from server: check port config" - ) - return self.unit.status = ops.ActiveStatus() + version = fastapi_demo.get_version(config.server_port) self.unit.set_workload_version(version) def _get_pebble_layer(self, port: int) -> ops.pebble.Layer: diff --git a/examples/k8s-2-configurable/src/fastapi_demo.py b/examples/k8s-2-configurable/src/fastapi_demo.py index 0e3384d47..fbcb8424b 100644 --- a/examples/k8s-2-configurable/src/fastapi_demo.py +++ b/examples/k8s-2-configurable/src/fastapi_demo.py @@ -21,7 +21,6 @@ import json import logging -import urllib.error import urllib.request logger = logging.getLogger(__name__) @@ -32,13 +31,7 @@ def get_version(port: int) -> str: Args: port: The port where fastapi_demo web server is listening. - - Raises: - RuntimeError: If the server can't be reached, for example because of an invalid port. """ - try: - response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") - except urllib.error.URLError as e: - raise RuntimeError(f"Could not connect to the workload server on port {port}") from e + response = urllib.request.urlopen(f"http://localhost:{port}/version") data = json.loads(response.read()) return data["version"] diff --git a/examples/k8s-3-postgresql/src/charm.py b/examples/k8s-3-postgresql/src/charm.py index b47e9b878..bd3936a3e 100755 --- a/examples/k8s-3-postgresql/src/charm.py +++ b/examples/k8s-3-postgresql/src/charm.py @@ -136,14 +136,7 @@ def _replan_workload(self) -> None: except (ops.pebble.APIError, ops.pebble.ConnectionError) as e: logger.info("Unable to connect to Pebble: %s", e) return - try: - version = fastapi_demo.get_version(config.server_port) - except RuntimeError as version_e: - logger.error("Failed to get workload version: %s", version_e) - self.unit.status = ops.BlockedStatus( - "Failed to get version from server: check port config" - ) - return + version = fastapi_demo.get_version(config.server_port) self.unit.set_workload_version(version) def _get_pebble_layer(self, port: int, environment: dict[str, str]) -> ops.pebble.Layer: diff --git a/examples/k8s-3-postgresql/src/fastapi_demo.py b/examples/k8s-3-postgresql/src/fastapi_demo.py index 0e3384d47..fbcb8424b 100644 --- a/examples/k8s-3-postgresql/src/fastapi_demo.py +++ b/examples/k8s-3-postgresql/src/fastapi_demo.py @@ -21,7 +21,6 @@ import json import logging -import urllib.error import urllib.request logger = logging.getLogger(__name__) @@ -32,13 +31,7 @@ def get_version(port: int) -> str: Args: port: The port where fastapi_demo web server is listening. - - Raises: - RuntimeError: If the server can't be reached, for example because of an invalid port. """ - try: - response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") - except urllib.error.URLError as e: - raise RuntimeError(f"Could not connect to the workload server on port {port}") from e + response = urllib.request.urlopen(f"http://localhost:{port}/version") data = json.loads(response.read()) return data["version"] diff --git a/examples/k8s-4-action/src/charm.py b/examples/k8s-4-action/src/charm.py index 674f73881..a72c9c20d 100755 --- a/examples/k8s-4-action/src/charm.py +++ b/examples/k8s-4-action/src/charm.py @@ -176,14 +176,7 @@ def _replan_workload(self) -> None: except (ops.pebble.APIError, ops.pebble.ConnectionError) as e: logger.info("Unable to connect to Pebble: %s", e) return - try: - version = fastapi_demo.get_version(config.server_port) - except RuntimeError as version_e: - logger.error("Failed to get workload version: %s", version_e) - self.unit.status = ops.BlockedStatus( - "Failed to get version from server: check port config" - ) - return + version = fastapi_demo.get_version(config.server_port) self.unit.set_workload_version(version) def _get_pebble_layer(self, port: int, environment: dict[str, str]) -> ops.pebble.Layer: diff --git a/examples/k8s-4-action/src/fastapi_demo.py b/examples/k8s-4-action/src/fastapi_demo.py index 0e3384d47..fbcb8424b 100644 --- a/examples/k8s-4-action/src/fastapi_demo.py +++ b/examples/k8s-4-action/src/fastapi_demo.py @@ -21,7 +21,6 @@ import json import logging -import urllib.error import urllib.request logger = logging.getLogger(__name__) @@ -32,13 +31,7 @@ def get_version(port: int) -> str: Args: port: The port where fastapi_demo web server is listening. - - Raises: - RuntimeError: If the server can't be reached, for example because of an invalid port. """ - try: - response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") - except urllib.error.URLError as e: - raise RuntimeError(f"Could not connect to the workload server on port {port}") from e + response = urllib.request.urlopen(f"http://localhost:{port}/version") data = json.loads(response.read()) return data["version"] diff --git a/examples/k8s-5-observe/src/charm.py b/examples/k8s-5-observe/src/charm.py index a13deb45a..89056db7e 100755 --- a/examples/k8s-5-observe/src/charm.py +++ b/examples/k8s-5-observe/src/charm.py @@ -197,14 +197,7 @@ def _replan_workload(self) -> None: except (ops.pebble.APIError, ops.pebble.ConnectionError) as e: logger.info("Unable to connect to Pebble: %s", e) return - try: - version = fastapi_demo.get_version(config.server_port) - except RuntimeError as version_e: - logger.error("Failed to get workload version: %s", version_e) - self.unit.status = ops.BlockedStatus( - "Failed to get version from server: check port config" - ) - return + version = fastapi_demo.get_version(config.server_port) self.unit.set_workload_version(version) def _get_pebble_layer(self, port: int, environment: dict[str, str]) -> ops.pebble.Layer: diff --git a/examples/k8s-5-observe/src/fastapi_demo.py b/examples/k8s-5-observe/src/fastapi_demo.py index 0e3384d47..fbcb8424b 100644 --- a/examples/k8s-5-observe/src/fastapi_demo.py +++ b/examples/k8s-5-observe/src/fastapi_demo.py @@ -21,7 +21,6 @@ import json import logging -import urllib.error import urllib.request logger = logging.getLogger(__name__) @@ -32,13 +31,7 @@ def get_version(port: int) -> str: Args: port: The port where fastapi_demo web server is listening. - - Raises: - RuntimeError: If the server can't be reached, for example because of an invalid port. """ - try: - response = urllib.request.urlopen(f"http://0.0.0.0:{port}/version") - except urllib.error.URLError as e: - raise RuntimeError(f"Could not connect to the workload server on port {port}") from e + response = urllib.request.urlopen(f"http://localhost:{port}/version") data = json.loads(response.read()) return data["version"]