Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,33 +50,20 @@ These files currently contain placeholder code and configuration.

Open `~/k8s-tutorial/charmcraft.yaml` in your usual text editor or IDE, then change the values of `title`, `summary`, and `description` to:

```yaml
title: Web Server Demo
summary: A demo charm that operates a small Python FastAPI server.
description: |
This charm demonstrates how to write a Kubernetes charm with Ops.
```{literalinclude} ../../../examples/k8s-1-minimal/charmcraft.yaml
:language: yaml
:start-at: 'title: Web Server Demo'
:end-at: how to write a Kubernetes charm with Ops.
```
Comment on lines +53 to 57

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We could use start-after and end-after with comment anchors in cases where we worry that this won't be robust. Thinking about this case specifically, I guess Sphinx would yell at us if we changed the end line of the description so there was no match, so this seems great as-is.


Next, describe the workload container and its OCI image.

In `charmcraft.yaml`, replace the `containers` and `resources` blocks with:

```yaml
containers:
demo-server:
resource: demo-server-image

resources:
# An OCI image resource for the container listed above.
demo-server-image:
type: oci-image
description: OCI image from GitHub Container registry
# The upstream-source field is ignored by Charmcraft and Juju, but it can be
# useful to developers in identifying the source of the OCI image. It is also
# used by the 'canonical/charming-actions' GitHub action for automated releases.
# The test_deploy function in tests/integration/test_charm.py reads upstream-source
# to determine which OCI image to use when running the charm's integration tests.
upstream-source: ghcr.io/canonical/api_demo_server/api-demo-server:2.1.0
```{literalinclude} ../../../examples/k8s-1-minimal/charmcraft.yaml
:language: yaml
:start-at: 'containers:'
:end-at: 'upstream-source: ghcr.io/canonical/api_demo_server/api-demo-server:2.1.0'
```

### Write a helper module
Expand All @@ -89,23 +76,9 @@ To make things easier for Juju users, your charm should expose the workload vers

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"]
```{literalinclude} ../../../examples/k8s-1-minimal/src/fastapi_demo.py
:language: python
:start-at: import json
```

Notice that the helper module is stateless. In fact, your charm as a whole will be stateless. The main logic of your charm will:
Expand Down Expand Up @@ -153,8 +126,11 @@ As you can see, a charm is a pure Python class that inherits from the [`CharmBas

In the `__init__` function of your charm class, we'll tell Ops which method of your charm class to run for each event. Let's start with when the Juju controller tells us that the workload container's Pebble is up and running.

```python
framework.observe(self.on["demo-server"].pebble_ready, self._on_demo_server_pebble_ready)
```{literalinclude} ../../../examples/k8s-1-minimal/src/charm.py
:language: python
:start-at: framework.observe(self.on["demo-server"]
:end-at: framework.observe(self.on["demo-server"]
Comment on lines +131 to +132

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This line is too long and requires scrolling in the docs. I wonder if we should adopt a lower line length for the example charms now that we're using them this way.

:dedent:
```


Expand Down Expand Up @@ -211,10 +187,11 @@ The workload version is available after the workload starts, which happens after

In `src/charm.py`, add the following lines to the `_on_demo_server_pebble_ready` function before the final `self.unit.status = ops.ActiveStatus()`:

```python
# Set the workload version of this charm.
version = fastapi_demo.get_version(port=8000)
self.unit.set_workload_version(version)
```{literalinclude} ../../../examples/k8s-1-minimal/src/charm.py
:language: python
:start-at: "# Set the workload version of this charm."
:end-at: self.unit.set_workload_version(version)
:dedent:
```

We get the workload version over port 8000 because the `fastapi` service runs 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.
Expand Down Expand Up @@ -354,66 +331,9 @@ In this section we'll write a test to check that the `fastapi` service is starte

Replace the contents of `tests/unit/test_charm.py` with:

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

from charm import FastAPIDemoCharm

# The default Pebble layer in the application image.
# Defined in https://github.com/canonical/api_demo_server/blob/master/rockcraft.yaml
ROCK_LAYER = ops.pebble.Layer(
{
"services": {
"fastapi": {
"override": "replace",
"summary": "FastAPI demo server",
"command": "/bin/uvicorn api_demo_server.app:app --host 0.0.0.0 --port 8000",
"startup": "enabled",
"environment": {"DEMO_SERVER_LOGFILE": "/tmp/demo_server.log"},
"on-success": "shutdown",
"on-failure": "shutdown",
}
},
}
)


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, layers={"rock": ROCK_LAYER}
)
state_in = testing.State(
containers={container},
leader=True,
)
state_out = ctx.run(ctx.on.pebble_ready(container), state_in)
# Expected plan after Pebble ready (our charm doesn't add any layers).
expected_plan = ops.pebble.Plan(ROCK_LAYER.to_dict())

# Check that we have the plan we expected:
assert state_out.get_container(container.name).plan == expected_plan
# Check the unit is active:
assert state_out.unit_status == testing.ActiveStatus()
# Check the service was started:
assert (
state_out.get_container(container.name).service_statuses["fastapi"]
== ops.pebble.ServiceStatus.ACTIVE
)
# Check the workload version is set:
assert state_out.workload_version == "0.0.1"
```{literalinclude} ../../../examples/k8s-1-minimal/tests/unit/test_charm.py
:language: python
:start-at: import ops
```

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.
Expand Down Expand Up @@ -473,34 +393,9 @@ Let's write some integration tests as [smoke tests](https://en.wikipedia.org/wik

Replace the contents of `tests/integration/test_charm.py` with:

```python
import logging
import pathlib

import jubilant
import pytest
import yaml

logger = logging.getLogger(__name__)

METADATA = yaml.safe_load(pathlib.Path("charmcraft.yaml").read_text())
APP_NAME = METADATA["name"]


@pytest.mark.juju_setup
def test_deploy(charm: pathlib.Path, juju: jubilant.Juju):
"""Deploy the charm under test."""
resources = {
"demo-server-image": METADATA["resources"]["demo-server-image"]["upstream-source"]
}
juju.deploy(charm, app=APP_NAME, resources=resources)
juju.wait(jubilant.all_active)


def test_workload_version_is_set(charm: pathlib.Path, juju: jubilant.Juju):
"""Verify that the workload version has been set."""
expected_version = "2.1.0" # Hardcoded for simplicity.
juju.wait(lambda status: status.apps[APP_NAME].version == expected_version)
```{literalinclude} ../../../examples/k8s-1-minimal/tests/integration/test_charm.py
:language: python
:start-at: import logging
```

These tests depend on two fixtures:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,29 +28,19 @@ In this part of the tutorial we will follow this process to add an action that w

Open the `charmcraft.yaml` file and add to it a block defining an action, as below. As you can see, the action is called `get-db-info` and it is intended to help the user access database authentication information. The action has a single parameter, `show-password`; if set to `True`, it will show the username and the password.

```yaml
actions:
get-db-info:
description: Fetches database authentication information
params:
show-password:
description: Show username and password in output information
type: boolean
default: false
additionalProperties: false
```{literalinclude} ../../../examples/k8s-4-action/charmcraft.yaml
:language: yaml
:start-at: 'actions:'
:end-at: 'additionalProperties: false'
```

## Define an action class

Open your `src/charm.py` file, and add an action class that matches the definition you used in `charmcraft.yaml`:

```python
@dataclasses.dataclass(frozen=True, kw_only=True)
class GetDbInfoAction:
"""Fetches database authentication information."""

show_password: bool
"""Show username and password in output information."""
```{literalinclude} ../../../examples/k8s-4-action/src/charm.py
:language: python
:pyobject: GetDbInfoAction
```

We'll use [](ActionEvent.load_params) to create an instance of your config class from the Juju action event. This allows IDEs to provide hints when we are accessing the action parameter, and static type checkers are able to validate that we are using the parameter correctly.
Expand All @@ -61,45 +51,21 @@ Open the `src/charm.py` file.

In the charm `__init__` method, add an action event observer, as below. As you can see, the name of the event consists of the name defined in the `charmcraft.yaml` file (`get-db-info`) and the word `action`.

```python
# Events on charm actions that are run via 'juju run'.
framework.observe(self.on.get_db_info_action, self._on_get_db_info_action)
```{literalinclude} ../../../examples/k8s-4-action/src/charm.py
:language: python
:start-at: "# Events on charm actions that are run via 'juju run'."
:end-at: framework.observe(self.on.get_db_info_action
:dedent:
```

Now, define the action event handler, as below: First, read the value of the parameter defined in the `charmcraft.yaml` file (`show-password`). Then, use the `fetch_database_relation_data` method (that we defined in a previous chapter) to read the contents of the database relation data and, if the parameter value read earlier is `True`, add the username and password to the output. Finally, use `event.set_results` to attach the results to the event that has called the action; this will print the output to the terminal.

If we are not able to get the data (for example, if the charm has not yet been integrated with the postgresql-k8s application) then we use the `fail` method of the event to let the user know.

```python
def _on_get_db_info_action(self, event: ops.ActionEvent) -> None:
"""Return information about the integrated database.

This method is called when "get_db_info" action is called. It shows information about
database access points by calling the `fetch_database_relation_data` method and creates
an output dictionary containing the host, port, if show_password is True, then include
username, and password of the database.

If the PostgreSQL charm is not integrated, the output is set to "No database connected".

Learn more about actions at https://canonical.com/juju/docs/ops/latest/howto/manage-actions/
"""
params = event.load_params(GetDbInfoAction, errors="fail")
db_data = self.fetch_database_relation_data()
if not db_data:
event.fail("No database connected")
return
output = {
"db-host": db_data.get("db_host", None),
"db-port": db_data.get("db_port", None),
}
if params.show_password:
output.update(
{
"db-username": db_data.get("db_username", None),
"db-password": db_data.get("db_password", None),
}
)
event.set_results(output)
```{literalinclude} ../../../examples/k8s-4-action/src/charm.py
:language: python
:pyobject: FastAPIDemoCharm._on_get_db_info_action
:dedent:
```

## Validate your charm
Expand Down Expand Up @@ -155,68 +121,16 @@ 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():
ctx = testing.Context(FastAPIDemoCharm)
relation = testing.Relation(
endpoint="database",
interface="postgresql_client",
remote_app_name="postgresql-k8s",
remote_app_data={
"endpoints": "example.com:5432",
"username": "foo",
"password": "bar",
},
)
container = testing.Container(
name="demo-server", can_connect=True, layers={"rock": ROCK_LAYER}
)
state_in = testing.State(
containers={container},
relations={relation},
leader=True,
)

ctx.run(ctx.on.action("get-db-info", params={"show-password": False}), state_in)

assert ctx.action_results == {
"db-host": "example.com",
"db-port": "5432",
}
```{literalinclude} ../../../examples/k8s-4-action/tests/unit/test_charm.py
:language: python
:pyobject: 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():
ctx = testing.Context(FastAPIDemoCharm)
relation = testing.Relation(
endpoint="database",
interface="postgresql_client",
remote_app_name="postgresql-k8s",
remote_app_data={
"endpoints": "example.com:5432",
"username": "foo",
"password": "bar",
},
)
container = testing.Container(
name="demo-server", can_connect=True, layers={"rock": ROCK_LAYER}
)
state_in = testing.State(
containers={container},
relations={relation},
leader=True,
)

ctx.run(ctx.on.action("get-db-info", params={"show-password": True}), state_in)

assert ctx.action_results == {
"db-host": "example.com",
"db-port": "5432",
"db-username": "foo",
"db-password": "bar",
}
```{literalinclude} ../../../examples/k8s-4-action/tests/unit/test_charm.py
:language: python
:pyobject: test_get_db_info_action_show_password
```

Run `tox -e unit` to check that all tests pass.
Expand Down
Loading