Skip to content
Draft
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 @@ -54,33 +54,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 +57 to 61

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 Repository
# The upstream-source field is ignored by Charmcraft and Juju, but it can be
# useful to developers in identifying the source of the OCI image. It is also
# used by the 'canonical/charming-actions' GitHub action for automated releases.
# The test_deploy function in tests/integration/test_charm.py reads upstream-source
# to determine which OCI image to use when running the charm's integration tests.
upstream-source: ghcr.io/canonical/api_demo_server:1.0.4
```{literalinclude} ../../../examples/k8s-1-minimal/charmcraft.yaml
:language: yaml
:start-at: 'containers:'
:end-at: 'upstream-source: ghcr.io/canonical/api_demo_server:1.0.4'
```

### Define the charm class
Expand Down Expand Up @@ -114,8 +101,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"]
:dedent:
```


Expand All @@ -138,65 +128,39 @@ We'll use the `ActiveStatus` class to set the charm status to active. Note that
Use `ActiveStatus` as well as further Ops constructs to define the event handler, as below. As you can see, what is happening is that, from the `event` argument, you extract the workload container object in which you add a custom layer. Once the layer is set you replan your service and set the charm status to active.


```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()
# Learn more about statuses at
# https://documentation.ubuntu.com/juju/3.6/reference/status/
self.unit.status = ops.ActiveStatus()
```{literalinclude} ../../../examples/k8s-1-minimal/src/charm.py
:language: python
:pyobject: FastAPIDemoCharm._on_demo_server_pebble_ready
:dedent:
```

The custom Pebble layer that you just added is defined in the `self._get_pebble_layer()` method. We'll now add this method.

In the `__init__` method of your charm class, name your service to `fastapi-service` and add it as a class attribute:

```python
self.pebble_service_name = "fastapi-service"
```{literalinclude} ../../../examples/k8s-1-minimal/src/charm.py
:language: python
:start-at: self.pebble_service_name = "fastapi-service"
:end-at: self.pebble_service_name = "fastapi-service"
:dedent:
```

Finally, define the `_get_pebble_layer` function as below. The `command` variable represents a command line that should be executed in order to start our application.

```python
def _get_pebble_layer(self) -> ops.pebble.Layer:
"""Pebble layer for the FastAPI demo services."""
command = " ".join(
[
"uvicorn",
"api_demo_server.app:app",
"--host=0.0.0.0",
"--port=8000",
]
)
pebble_layer: ops.pebble.LayerDict = {
"summary": "FastAPI demo service",
"description": "pebble config layer for FastAPI demo server",
"services": {
self.pebble_service_name: {
"override": "replace",
"summary": "fastapi demo",
"command": command,
"startup": "enabled",
}
},
}
return ops.pebble.Layer(pebble_layer)
```{literalinclude} ../../../examples/k8s-1-minimal/src/charm.py
:language: python
:pyobject: FastAPIDemoCharm._get_pebble_layer
:dedent:
```

### 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`.

```python
import logging

# Log messages can be retrieved using juju debug-log
logger = logging.getLogger(__name__)
```{literalinclude} ../../../examples/k8s-1-minimal/src/charm.py
:language: python
:start-at: import logging
:end-at: logger = logging.getLogger(__name__)
```

## Try your charm
Expand Down Expand Up @@ -323,44 +287,9 @@ In this section we'll write a test to check that Pebble is configured as expecte

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

```python
import ops
from ops import testing

from charm import FastAPIDemoCharm


def test_pebble_layer():
ctx = testing.Context(FastAPIDemoCharm)
container = testing.Container(name="demo-server", can_connect=True)
state_in = testing.State(
containers={container},
leader=True,
)
state_out = ctx.run(ctx.on.pebble_ready(container), state_in)
# Expected plan after Pebble ready with default config
expected_plan = {
"services": {
"fastapi-service": {
"override": "replace",
"summary": "fastapi demo",
"command": "uvicorn api_demo_server.app:app --host=0.0.0.0 --port=8000",
"startup": "enabled",
# Since the environment is empty, Layer.to_dict() will not
# include it.
}
}
}

# Check that we have the plan we expected:
assert state_out.get_container(container.name).plan == expected_plan
# Check the unit is active:
assert state_out.unit_status == testing.ActiveStatus()
# Check the service was started:
assert (
state_out.get_container(container.name).service_statuses["fastapi-service"]
== ops.pebble.ServiceStatus.ACTIVE
)
```{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 @@ -418,28 +347,9 @@ Let's write the simplest possible integration test, a [smoke test](https://en.wi

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)
```{literalinclude} ../../../examples/k8s-1-minimal/tests/integration/test_charm.py
:language: python
:start-at: import logging
```

This test depends 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://documentation.ubuntu.com/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,64 +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)
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)
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
Loading