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..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 @@ -11,6 +11,7 @@ When you deploy a Kubernetes charm, the following things happen: 1. The same Juju controller injects Pebble -- a lightweight, API-driven process supervisor -- into each workload container and overrides the container entrypoint so that Pebble starts when the container is ready. 1. When the Kubernetes API reports that a workload container is ready, the Juju controller informs the charm that the instance of Pebble in that container is ready. At that point, the charm knows that it can start communicating with Pebble. 1. Typically, at this point the charm will make calls to Pebble so that Pebble can configure and start the workload and begin operations. +1. During operations, the charm may need to directly communicate with the workload application. The charm container and workload container can communicate via `localhost` because they share the same pod, and containers in the same pod share the same network namespace. > Note: In the past, the containers were specified in a `metadata.yaml` file, but the modern practice is that all charm specification is in a single `charmcraft.yaml` file. @@ -22,7 +23,7 @@ All subsequent workload management happens in the same way -- the Juju controlle As a charm developer, your first job is to use this knowledge to create the basic structure and content for your charm: - - descriptive files (e.g., YAML configuration files like the `charmcraft.yaml` file mentioned above) that give Juju, Python, or Charmcraft various bits of information about your charm, and +- descriptive files (e.g., YAML configuration files like the `charmcraft.yaml` file mentioned above) that give Juju, Python, or Charmcraft various bits of information about your charm, and - executable files (like the `src/charm.py` file that we will see shortly) where you will use Ops-enriched Python to write all the logic of your charm. ## Create a charm project @@ -45,6 +46,7 @@ Charmcraft created several files, including: - `charmcraft.yaml` - Metadata about your charm. Used by Juju and Charmcraft. - `pyproject.toml` - Python project configuration. Lists the dependencies of your charm. - `src/charm.py` - The Python file that will contain the logic of your charm. +- `src/fastapi_demo.py` - A helper module that will contain functions for interacting with your workload application. These files currently contain placeholder code and configuration. @@ -83,6 +85,47 @@ resources: upstream-source: ghcr.io/canonical/api_demo_server:1.0.4 ``` +### Write a helper module + +Your charm will interact with our workload application. It's a good idea to write a helper module that wraps the workload application. Charmcraft created `src/fastapi_demo.py` as a placeholder helper module. + +The helper module will be independent of the main logic of your charm. This will make it easier to test your charm. In this tutorial, the helper module only contains the logic to get the version of the workload application. The server has an endpoint at `/version` that returns a JSON payload containing the version number. This is called the workload version. + +To make things easier for Juju users, your charm should expose the workload version to Juju. It will be visible in Juju's status output. For more information, see {ref}`how-to-set-the-workload-version`. + +Replace the content of `src/fastapi_demo.py` with: + +```python +import json +import logging +import urllib.request + +logger = logging.getLogger(__name__) + + +def get_version(port: int) -> str: + """Get the version of fastapi_demo that is running. + + Args: + port: The port where fastapi_demo web server is listening. + """ + response = urllib.request.urlopen(f"http://localhost:{port}/version") + data = json.loads(response.read()) + return data["version"] +``` + +Notice that the helper module is stateless. In fact, your charm as a whole will be stateless. The main logic of your charm will: + +1. Receive an event from Juju. +2. Use Pebble calls and the helper module to manage the workload application and check its status. +3. Report the status back to Juju. + +```{tip} +After adding code to your charm, run `tox -e format` to format the code. Then run `tox -e lint` to check the code against coding style standards and run static checks. You can run these commands from anywhere in the `~/fastapi-demo` directory in your virtual machine. + +You can also run these commands in `~/k8s-tutorial` if uv and tox are available on your host machine. However, be careful when running the same tox command inside and outside your virtual machine. If tox fails with an error related to the `.tox` directory, use `-re` instead of `-e` in the commands. This recreates the tox environment. +``` + ### Define the charm class We'll now write the charm code that handles events from Juju. Charmcraft created `src/charm.py` as the location for this logic. @@ -96,6 +139,8 @@ Replace the contents of `src/charm.py` with: import ops +import fastapi_demo + class FastAPIDemoCharm(ops.CharmBase): """Charm the service.""" @@ -188,6 +233,20 @@ def _get_pebble_layer(self) -> ops.pebble.Layer: return ops.pebble.Layer(pebble_layer) ``` +### Set the workload version + +The workload version is available after the workload starts, which happens after Pebble reevaluates its plan. We'll use the `src/fastapi_demo.py` helper module for this step. + +In `src/charm.py`, append the following lines to the `_on_demo_server_pebble_ready` function: + +```python +# Set the workload version of this charm. +version = fastapi_demo.get_version(port=8000) +self.unit.set_workload_version(version) +``` + +We get the workload version over port 8000 because `_get_pebble_layer` deploys the app on this port. Then `self.unit.set_workload_version` exposes the workload version to Juju. If the `get_version` call fails (for example, an `URLError` exception is raised), the charm will go into error status. The Juju logs will show the error message, to help you debug the error. + ### Add logger functionality In the imports section of `src/charm.py`, import the Python `logging` module and define a logger object, as below. This will allow you to read log data in `juju`. @@ -245,14 +304,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 located in the app's `Version` column. ```text Model Controller Cloud/Region Version SLA Timestamp testing concierge-k8s k8s 3.6.13 unsupported 13:38:19+01:00 App Version Status Scale Charm Channel Rev Address Exposed Message -fastapi-demo active 1 fastapi-demo 0 10.152.183.215 no +fastapi-demo 1.0.4 active 1 fastapi-demo 0 10.152.183.215 no Unit Workload Agent Address Ports Message fastapi-demo/0* active idle 10.1.157.73 @@ -260,7 +319,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 @@ -325,12 +384,23 @@ Replace the contents of `tests/unit/test_charm.py` with: ```python import ops +import pytest from ops import testing from charm import FastAPIDemoCharm -def test_pebble_layer(): +def mock_get_version(port: int): + """Get a mock version string without executing the workload code.""" + return "0.0.1" + + +@pytest.fixture +def mock_version(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) + + +def test_pebble_layer(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -361,10 +431,14 @@ def test_pebble_layer(): state_out.get_container(container.name).service_statuses["fastapi-service"] == ops.pebble.ServiceStatus.ACTIVE ) + # Check the workload version is set + assert state_out.workload_version is not None ``` This test checks the behaviour of the `_on_demo_server_pebble_ready` function that you set up earlier. The test simulates your charm receiving the pebble-ready event, then checks that the unit and workload container have the correct state. +In unit tests, we avoid any interaction with the outside world. The `get_version` method performs an HTTP call, which must be patched. We use the `mock_version` fixture to achieve this. + ### Run the test Run the following command from anywhere in the `~/fastapi-demo` directory: @@ -390,10 +464,10 @@ tests/unit/test_charm.py::test_pebble_layer PASSED unit: commands[1]> coverage report Name Stmts Miss Branch BrPart Cover Missing ----------------------------------------------------------------- -src/charm.py 17 0 0 0 100% -src/fastapi_demo.py 4 4 0 0 0% 9-20 +src/charm.py 20 0 0 0 100% +src/fastapi_demo.py 8 3 0 0 62% 35-37 ----------------------------------------------------------------- -TOTAL 21 4 0 0 81% +TOTAL 28 3 0 0 89% unit: OK (1.91=setup[0.09]+cmd[1.54,0.28] seconds) congratulations :) (1.93 seconds) ``` @@ -440,6 +514,13 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju): } juju.deploy(charm, app=APP_NAME, resources=resources) juju.wait(jubilant.all_active) + + +def test_workload_version_is_set(juju: jubilant.Juju): + # Verify that the workload version has been set. + version = juju.status().apps[APP_NAME].version + # Hardcoded version for simplicity. Ideally we'd get the version directly from the workload. + assert version == "1.0.4" ``` This test depends on two fixtures: 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 b8c0ba487..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 @@ -236,6 +236,9 @@ def _replan_workload(self) -> None: logger.info(f"Replanned with '{self.pebble_service_name}' service") except (ops.pebble.APIError, ops.pebble.ConnectionError) as e: logger.info("Unable to connect to Pebble: %s", e) + return + version = fastapi_demo.get_version(config.server_port) + self.unit.set_workload_version(version) ``` We removed three `self.unit.status = ` lines from this version of the method. We'll handle replacing those shortly. @@ -419,7 +422,7 @@ Congratulations, your relation with PostgreSQL is functional! Now that our charm uses `fetch_database_relation_data` to extract database authentication data and endpoint information from the relation data, we should write a test for the feature. Here, we're not testing the `fetch_database_relation_data` function directly, but rather, we're checking that the response to a Juju event is what it should be: ```python -def test_relation_data(): +def test_relation_data(mock_version): ctx = testing.Context(FastAPIDemoCharm) relation = testing.Relation( endpoint="database", @@ -453,7 +456,7 @@ def test_relation_data(): In this chapter, we also defined a new method `_on_collect_status` that checks various things, including whether the required database relation exists. If the relation doesn't exist, we wait and set the unit status to `blocked`. We can also add a test to cover this behaviour: ```python -def test_no_database_blocked(): +def test_no_database_blocked(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -522,6 +525,10 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): juju.deploy("postgresql-k8s", channel="14/stable", trust=True) juju.integrate(APP_NAME, "postgresql-k8s") juju.wait(jubilant.all_active) + + version = juju.status().apps[APP_NAME].version + # Hardcoded version for simplicity. Ideally we'd get the version directly from the workload. + assert version == "1.0.4" ``` This test depends on the `charm` fixture so that the test fails immediately if a `.charm` file isn't available. 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 f5070484b..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 @@ -127,11 +127,13 @@ def _replan_workload(self) -> None: # service if required. self.container.replan() logger.info(f"Replanned with '{self.pebble_service_name}' service") - - self.unit.status = ops.ActiveStatus() except (ops.pebble.APIError, ops.pebble.ConnectionError) as e: logger.info("Unable to connect to Pebble: %s", e) self.unit.status = ops.MaintenanceStatus("Waiting for Pebble in workload container") + return + self.unit.status = ops.ActiveStatus() + version = fastapi_demo.get_version(config.server_port) + self.unit.set_workload_version(version) ``` 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. @@ -242,7 +244,7 @@ Since we added a new feature to configure `server-port` and use it in the Pebble First, we'll add a test that sets the port in the input state and asserts that the port is used in the service's command in the container layer: ```python -def test_config_changed(): +def test_config_changed(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -260,10 +262,12 @@ def test_config_changed(): assert "--port=8080" in command ``` +We need the `mock_version` fixture because `_on_config_changed` calls `_replan_workload`, which gets the workload version using `fastapi_demo.get_version`. The fixture patches `get_version` to avoid making a real HTTP call, so that the unit test deterministic. + In `_on_config_changed`, we specifically don't allow port 22 to be used. If port 22 is configured, we set the unit status to `blocked`. So, we can add a test to cover this behaviour by setting the port to 22 in the input state and asserting that the unit status is blocked: ```python -def test_config_changed_invalid_port(): +def test_config_changed_invalid_port(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( 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 51c0aa6ce..1cae022f3 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 the integration tests. Also import `requests`, which is a new dependency. Run the following command to add `requests` as a dependency: + +```text +uv add --group integration requests +``` + +Your imports should now look like this: ```python import json @@ -513,14 +519,14 @@ Add two test functions to `tests/integration/test_charm.py`: ```python @pytest.mark.juju_setup -def test_deploy_cos(cos: jubilant.Juju): +def test_deploy_cos(charm: pathlib.Path, cos: jubilant.Juju): """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") @@ -533,7 +539,7 @@ def test_integrate_loki(juju: jubilant.Juju, cos: jubilant.Juju): Add a test function to `tests/integration/test_charm.py`: ```python -def test_loki_data(cos: jubilant.Juju): +def test_loki_data(charm: pathlib.Path, cos: jubilant.Juju): """Use Loki's HTTP API to verify that Loki has a label for our app. COS Lite exposes Loki's API through the Traefik load balancer. Traefik comes with an action 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 diff --git a/examples/k8s-1-minimal/src/charm.py b/examples/k8s-1-minimal/src/charm.py index 9d670a353..d8c0ad3b8 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__) @@ -43,6 +45,9 @@ def _on_demo_server_pebble_ready(self, event: ops.PebbleReadyEvent) -> None: # Learn more about statuses at # https://documentation.ubuntu.com/juju/3.6/reference/status/ self.unit.status = ops.ActiveStatus() + # Set the workload version of this charm. + version = fastapi_demo.get_version(port=8000) + self.unit.set_workload_version(version) def _get_pebble_layer(self) -> ops.pebble.Layer: """Pebble layer for the FastAPI demo services.""" 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..fbcb8424b --- /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 that is running. + + Args: + port: The port where fastapi_demo web server is listening. + """ + response = urllib.request.urlopen(f"http://localhost:{port}/version") + data = json.loads(response.read()) + return data["version"] diff --git a/examples/k8s-1-minimal/tests/integration/test_charm.py b/examples/k8s-1-minimal/tests/integration/test_charm.py index 650e2f923..06b601fc8 100644 --- a/examples/k8s-1-minimal/tests/integration/test_charm.py +++ b/examples/k8s-1-minimal/tests/integration/test_charm.py @@ -39,3 +39,10 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju): } juju.deploy(charm, app=APP_NAME, resources=resources) juju.wait(jubilant.all_active) + + +def test_workload_version_is_set(juju: jubilant.Juju): + # Verify that the workload version has been set. + version = juju.status().apps[APP_NAME].version + # Hardcoded version for simplicity. Ideally we'd get the version directly from the workload. + assert version == "1.0.4" diff --git a/examples/k8s-1-minimal/tests/unit/test_charm.py b/examples/k8s-1-minimal/tests/unit/test_charm.py index 5c22bc8dd..732fc17ad 100644 --- a/examples/k8s-1-minimal/tests/unit/test_charm.py +++ b/examples/k8s-1-minimal/tests/unit/test_charm.py @@ -15,12 +15,23 @@ # To learn more about testing, see https://canonical.com/juju/docs/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 "0.0.1" + + +@pytest.fixture +def mock_version(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) + + +def test_pebble_layer(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -51,3 +62,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 diff --git a/examples/k8s-2-configurable/src/charm.py b/examples/k8s-2-configurable/src/charm.py index 44401002a..5193a8c96 100755 --- a/examples/k8s-2-configurable/src/charm.py +++ b/examples/k8s-2-configurable/src/charm.py @@ -21,6 +21,8 @@ import ops +import fastapi_demo + # Log messages can be retrieved using juju debug-log logger = logging.getLogger(__name__) @@ -85,11 +87,13 @@ def _replan_workload(self) -> None: # service if required. self.container.replan() logger.info(f"Replanned with '{self.pebble_service_name}' service") - - self.unit.status = ops.ActiveStatus() except (ops.pebble.APIError, ops.pebble.ConnectionError) as e: logger.info("Unable to connect to Pebble: %s", e) self.unit.status = ops.MaintenanceStatus("Waiting for Pebble in workload container") + return + self.unit.status = ops.ActiveStatus() + version = fastapi_demo.get_version(config.server_port) + self.unit.set_workload_version(version) 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..fbcb8424b --- /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 that is running. + + Args: + port: The port where fastapi_demo web server is listening. + """ + response = urllib.request.urlopen(f"http://localhost:{port}/version") + data = json.loads(response.read()) + return data["version"] diff --git a/examples/k8s-2-configurable/tests/integration/test_charm.py b/examples/k8s-2-configurable/tests/integration/test_charm.py index 650e2f923..06b601fc8 100644 --- a/examples/k8s-2-configurable/tests/integration/test_charm.py +++ b/examples/k8s-2-configurable/tests/integration/test_charm.py @@ -39,3 +39,10 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju): } juju.deploy(charm, app=APP_NAME, resources=resources) juju.wait(jubilant.all_active) + + +def test_workload_version_is_set(juju: jubilant.Juju): + # Verify that the workload version has been set. + version = juju.status().apps[APP_NAME].version + # Hardcoded version for simplicity. Ideally we'd get the version directly from the workload. + assert version == "1.0.4" diff --git a/examples/k8s-2-configurable/tests/unit/test_charm.py b/examples/k8s-2-configurable/tests/unit/test_charm.py index b7d94f9da..74c2378e0 100644 --- a/examples/k8s-2-configurable/tests/unit/test_charm.py +++ b/examples/k8s-2-configurable/tests/unit/test_charm.py @@ -15,12 +15,23 @@ # To learn more about testing, see https://canonical.com/juju/docs/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 "0.0.1" + + +@pytest.fixture +def mock_version(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) + + +def test_pebble_layer(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -51,9 +62,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(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -71,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/src/charm.py b/examples/k8s-3-postgresql/src/charm.py index eaa4d582d..bd3936a3e 100755 --- a/examples/k8s-3-postgresql/src/charm.py +++ b/examples/k8s-3-postgresql/src/charm.py @@ -30,6 +30,8 @@ DatabaseRequires, ) +import fastapi_demo + # Log messages can be retrieved using juju debug-log logger = logging.getLogger(__name__) @@ -133,6 +135,9 @@ def _replan_workload(self) -> None: logger.info(f"Replanned with '{self.pebble_service_name}' service") except (ops.pebble.APIError, ops.pebble.ConnectionError) as e: logger.info("Unable to connect to Pebble: %s", e) + return + version = fastapi_demo.get_version(config.server_port) + self.unit.set_workload_version(version) 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..fbcb8424b --- /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 that is running. + + Args: + port: The port where fastapi_demo web server is listening. + """ + response = urllib.request.urlopen(f"http://localhost:{port}/version") + data = json.loads(response.read()) + return data["version"] diff --git a/examples/k8s-3-postgresql/tests/integration/test_charm.py b/examples/k8s-3-postgresql/tests/integration/test_charm.py index 74f972956..010fec6b8 100644 --- a/examples/k8s-3-postgresql/tests/integration/test_charm.py +++ b/examples/k8s-3-postgresql/tests/integration/test_charm.py @@ -54,4 +54,8 @@ 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[APP_NAME].version + # 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/unit/test_charm.py b/examples/k8s-3-postgresql/tests/unit/test_charm.py index d7179aef4..578776231 100644 --- a/examples/k8s-3-postgresql/tests/unit/test_charm.py +++ b/examples/k8s-3-postgresql/tests/unit/test_charm.py @@ -15,12 +15,23 @@ # To learn more about testing, see https://canonical.com/juju/docs/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 "0.0.1" + + +@pytest.fixture +def mock_version(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) + + +def test_pebble_layer(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -51,9 +62,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(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -71,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( @@ -85,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", @@ -116,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/src/charm.py b/examples/k8s-4-action/src/charm.py index cf5e86de3..a72c9c20d 100755 --- a/examples/k8s-4-action/src/charm.py +++ b/examples/k8s-4-action/src/charm.py @@ -30,6 +30,8 @@ DatabaseRequires, ) +import fastapi_demo + # Log messages can be retrieved using juju debug-log logger = logging.getLogger(__name__) @@ -173,6 +175,9 @@ def _replan_workload(self) -> None: logger.info(f"Replanned with '{self.pebble_service_name}' service") except (ops.pebble.APIError, ops.pebble.ConnectionError) as e: logger.info("Unable to connect to Pebble: %s", e) + return + version = fastapi_demo.get_version(config.server_port) + self.unit.set_workload_version(version) 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..fbcb8424b --- /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 that is running. + + Args: + port: The port where fastapi_demo web server is listening. + """ + response = urllib.request.urlopen(f"http://localhost:{port}/version") + data = json.loads(response.read()) + return data["version"] diff --git a/examples/k8s-4-action/tests/integration/test_charm.py b/examples/k8s-4-action/tests/integration/test_charm.py index 74f972956..e6263b7e3 100644 --- a/examples/k8s-4-action/tests/integration/test_charm.py +++ b/examples/k8s-4-action/tests/integration/test_charm.py @@ -55,3 +55,7 @@ def test_database_integration(charm: pathlib.Path, juju: jubilant.Juju): juju.deploy("postgresql-k8s", channel="14/stable", trust=True) juju.integrate(APP_NAME, "postgresql-k8s") juju.wait(jubilant.all_active) + + version = juju.status().apps[APP_NAME].version + # Hardcoded version for simplicity. Ideally we'd get the version directly from the workload. + assert version == "1.0.4" diff --git a/examples/k8s-4-action/tests/unit/test_charm.py b/examples/k8s-4-action/tests/unit/test_charm.py index dcf6619b6..c66a5b610 100644 --- a/examples/k8s-4-action/tests/unit/test_charm.py +++ b/examples/k8s-4-action/tests/unit/test_charm.py @@ -15,12 +15,23 @@ # To learn more about testing, see https://canonical.com/juju/docs/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 "0.0.1" + + +@pytest.fixture +def mock_version(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) + + +def test_pebble_layer(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -51,9 +62,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(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -71,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( @@ -85,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", @@ -116,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-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/src/charm.py b/examples/k8s-5-observe/src/charm.py index e5047d9fb..89056db7e 100755 --- a/examples/k8s-5-observe/src/charm.py +++ b/examples/k8s-5-observe/src/charm.py @@ -33,6 +33,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 +196,9 @@ def _replan_workload(self) -> None: logger.info(f"Replanned with '{self.pebble_service_name}' service") except (ops.pebble.APIError, ops.pebble.ConnectionError) as e: logger.info("Unable to connect to Pebble: %s", e) + return + version = fastapi_demo.get_version(config.server_port) + self.unit.set_workload_version(version) 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..fbcb8424b --- /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 that is running. + + Args: + port: The port where fastapi_demo web server is listening. + """ + response = urllib.request.urlopen(f"http://localhost:{port}/version") + data = json.loads(response.read()) + return data["version"] diff --git a/examples/k8s-5-observe/tests/integration/test_charm.py b/examples/k8s-5-observe/tests/integration/test_charm.py index beb8a80d7..19c3dc0a8 100644 --- a/examples/k8s-5-observe/tests/integration/test_charm.py +++ b/examples/k8s-5-observe/tests/integration/test_charm.py @@ -60,6 +60,10 @@ 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[APP_NAME].version + # Hardcoded version for simplicity. Ideally we'd get the version directly from the workload. + 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 dcf6619b6..c66a5b610 100644 --- a/examples/k8s-5-observe/tests/unit/test_charm.py +++ b/examples/k8s-5-observe/tests/unit/test_charm.py @@ -15,12 +15,23 @@ # To learn more about testing, see https://canonical.com/juju/docs/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 "0.0.1" + + +@pytest.fixture +def mock_version(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("fastapi_demo.get_version", mock_get_version) + + +def test_pebble_layer(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -51,9 +62,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(mock_version): ctx = testing.Context(FastAPIDemoCharm) container = testing.Container(name="demo-server", can_connect=True) state_in = testing.State( @@ -71,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( @@ -85,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", @@ -116,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-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" }, ]