Open a Kubernetes port in your charm

From Zero to Hero: Write your first Kubernetes charm > Open a Kubernetes port in your charm

See previous: Write integration tests for your charm

This document is part of a series, and we recommend you follow it in sequence. However, you can also jump straight in by checking out the code from the previous branches:

git clone https://github.com/canonical/juju-sdk-tutorial-k8s.git
cd juju-sdk-tutorial-k8s
git checkout 10_integration_testing
git checkout -b 11_open_port_k8s_service

A deployed charm should be consistently accessible via a stable URL on a cloud.

However, our charm is currently accessible only at the IP pod address and, if the pod gets recycled, the IP address will change as well.

See earlier chapter: Make your charm configurable

In Kubernetes you can make a service permanently reachable under a stable URL on the cluster by exposing a service port via the ClusterIP. In Juju 3.1+, you can take advantage of this by using the open-port feature added to ops since 2.1.0 with the Unit.set_ports() method available since 2.7.0.

Read more: ClusterIP

In this part chapter of the tutorial you will extend the existing server-port configuration option to use Juju open-port functionality to expose a Kubernetes service port. Building on your experience from the previous testing chapters, you will also write tests to check that the new feature you’ve added works as intended.

Contents:

  1. Add a Kubernetes service port to your charm
  2. Test the new feature
  3. Validate your charm
  4. Review the final code

Add a Kubernetes service port to your charm

In your project root’s existing requirements.txt file, update the required version of ops as below:

ops >= 2.7

In your src/charm.py file, do all of the following:

In the _on_config_changed method, add a new method:

self._handle_ports()

Then, in the definition of the FastAPIDemoCharm class, define the method:

    def _handle_ports(self):
        port = int(self.config["server-port"])
        self.unit.set_ports(port)

See more: ops.Unit.set_ports

Test the new feature

Write a unit test

If you’ve skipped straight to this chapter:
Note that it builds on the earlier unit testing chapter. To catch up, see: Write unit tests for your charm.

Let’s write a unit test to verify that the port is opened. For that we will extend our existing test_pebble_layer unit test to verify that port is opened. Open tests/unit/test_charm.py and add the following helper method to the TestCharm class:

    def _assert_port_configuration(self, port_number, expected_status):

        # Update the server-port configuration with the specified port number
        self.harness.update_config({"server-port": port_number})

        # Get the set of opened ports from the juju unit
        currently_opened_ports = self.harness.model.unit.opened_ports()

        # Extract the port numbers from the opened ports set
        port_numbers = {port.port for port in currently_opened_ports}

        # Retrieve the updated server port configuration from the charm config
        server_port_config = self.harness.model.config.get("server-port")

        # Check if the server port is 22 (reserved for SSH)
        if port_number == 22:
            # Assert that the SSH port is not in the set of opened ports
            self.assertNotIn(server_port_config, port_numbers)
        else:
            # Assert that the specified port is in the set of opened ports
            self.assertIn(server_port_config, port_numbers)

        # Assert that the unit status matches the expected status
        self.assertEqual(self.harness.model.unit.status, expected_status)

Now, at the end of test_pebble_layer method, add the lines below:

        # Check port configuration for SSH (port 22) and assert BlockedStatus
        self._assert_port_configuration(22, ops.BlockedStatus('Invalid port number, 22 is reserved for SSH'))
        # Check port configuration for a custom port (e.g., 1234) and assert ActiveStatus 
        self._assert_port_configuration(1234, ops.ActiveStatus())

Time to run the test!

In your Multipass Ubuntu VM shell, run the unit test:

ubuntu@charm-dev:~/fastapi-demo$ tox -re unit

If successful, you should get an output similar to the one below:

$ tox -re unit
unit: remove tox env folder /home/ubuntu/juju-sdk-tutorial-k8s/.tox/unit
unit: install_deps> python -I -m pip install cosl 'coverage[toml]' pytest -r /home/ubuntu/juju-sdk-tutorial-k8s/requirements.txt
unit: commands[0]> coverage run --source=/home/ubuntu/juju-sdk-tutorial-k8s/src -m pytest --tb native -v -s /home/ubuntu/juju-sdk-tutorial-k8s/tests/unit
========================================= test session starts =========================================
platform linux -- Python 3.10.12, pytest-7.4.2, pluggy-1.3.0 -- /home/ubuntu/juju-sdk-tutorial-k8s/.tox/unit/bin/python
cachedir: .tox/unit/.pytest_cache
rootdir: /home/ubuntu/juju-sdk-tutorial-k8s
collected 1 item                                                                                      

tests/unit/test_charm.py::TestCharm::test_pebble_layer PASSED

========================================== 1 passed in 0.21s ==========================================
unit: commands[1]> coverage report
Name           Stmts   Miss  Cover
----------------------------------
src/charm.py     122     43    65%
----------------------------------
TOTAL            122     43    65%
  unit: OK (6.00=setup[5.43]+cmd[0.49,0.09] seconds)
  congratulations :) (6.04 seconds)

Write a scenario test

Let’s also write a scenario test!

In tests/scenario/test_charm.py, update the scenario import line to include PeerRelation:

from scenario import Relation, State, Context, Container, Action, PeerRelation

See also: PeerRelation (ops-scenario)

Now, in the TestCharm class, add the test case below:

    @unittest.mock.patch("charm.LogProxyConsumer")
    @unittest.mock.patch("charm.MetricsEndpointProvider")
    @unittest.mock.patch("charm.GrafanaDashboardProvider")
    def test_open_port(self, *_):
        # use scenario.Context to declare what charm we are testing
        ctx = Context(
            FastAPIDemoCharm,
            meta={
                "name": "demo-api-charm",
                "containers": {"demo-server": {}},
                "peers": {"fastapi-peer": {"interface": "fastapi_demo_peers"}},
                "requires": {
                    "database": {
                        "interface": "postgresql_client",
                    }
                },
            },
            config={
                "options": {
                    "server-port": {
                        "default": 8000,
                    }
                }
            },
            actions={
                "get-db-info": {
                    "params": {"show-password": {"default": False, "type": "boolean"}}
                }
            },
        )

        state_in = State(
            leader=True,
            relations=[
                Relation(
                    endpoint="database",
                    interface="postgresql_client",
                    remote_app_name="postgresql-k8s",
                    local_unit_data={},
                    remote_app_data={
                        "endpoints": "127.0.0.1:5432",
                        "username": "foo",
                        "password": "bar",
                    },
                ),
                PeerRelation(
                    endpoint="fastapi-peer",
                    peers_data={"unit_stats": {"started_counter": "0"}},
                )
            ],
            containers=[
                Container(name="demo-server", can_connect=True),
            ],
        )

        state1 = ctx.run("config_changed", state_in)

        assert len(state1.opened_ports) == 1
        assert state1.opened_ports[0].port == 8000
        assert state1.opened_ports[0].protocol == "tcp"

In your Multipass Ubuntu VM shell, run your scenario test as below:

ubuntu@charm-dev:~/fastapi-demo$ tox -re scenario     

If successful, this should yield:

scenario: remove tox env folder /home/ubuntu/juju-sdk-tutorial-k8s/.tox/scenario
scenario: install_deps> python -I -m pip install cosl 'coverage[toml]' ops-scenario pytest -r /home/ubuntu/juju-sdk-tutorial-k8s/requirements.txt
scenario: commands[0]> coverage run --source=/home/ubuntu/juju-sdk-tutorial-k8s/src -m pytest --tb native -v -s /home/ubuntu/juju-sdk-tutorial-k8s/tests/scenario
========================================= test session starts =========================================
platform linux -- Python 3.10.12, pytest-7.4.2, pluggy-1.3.0 -- /home/ubuntu/juju-sdk-tutorial-k8s/.tox/scenario/bin/python
cachedir: .tox/scenario/.pytest_cache
rootdir: /home/ubuntu/juju-sdk-tutorial-k8s
collected 2 items                                                                                     

tests/scenario/test_charm.py::TestCharm::test_get_db_info_action PASSED
tests/scenario/test_charm.py::TestCharm::test_open_port PASSED

========================================== 2 passed in 0.31s ==========================================
scenario: commands[1]> coverage report
Name           Stmts   Miss  Cover
----------------------------------
src/charm.py     122     22    82%
----------------------------------
TOTAL            122     22    82%
  scenario: OK (6.66=setup[5.98]+cmd[0.59,0.09] seconds)
  congratulations :) (6.69 seconds)

Write an integration test

In your tests/integration directory, create a helpers.py file with the following contents:

import socket
from pytest_operator.plugin import OpsTest


async def get_address(ops_test: OpsTest, app_name, unit_num=0) -> str:
    """Get the address for a the k8s service for an app."""
    status = await ops_test.model.get_status()
    k8s_service_address = status["applications"][app_name].public_address
    return k8s_service_address

def is_port_open(host: str, port: int) -> bool:
    """check if a port is opened in a particular host"""
    try:
        with socket.create_connection((host, port), timeout=5):
            return True  # If connection succeeds, the port is open
    except (ConnectionRefusedError, TimeoutError):
        return False  # If connection fails, the port is closed

In your existing tests/integration/test_charm.py file, import the methods defined in helpers.py:

from helpers import is_port_open, get_address

Now add the test case that will cover open ports:

@pytest.mark.abort_on_fail
async def test_open_ports(ops_test: OpsTest):
    """Verify that setting the server-port in charm's config correctly adjust k8s service
    Assert blocked status in case of port 22 and active status for others
    """
    app = ops_test.model.applications.get("demo-api-charm")

    # Get the k8s service address of the app
    address = await get_address(ops_test=ops_test, app_name=APP_NAME)
    # Validate that initial port is opened
    assert is_port_open(address, 8000)

    # Set Port to 22 and validate app going to blocked status with port not opened
    await app.set_config({"server-port": "22"})
    await ops_test.model.wait_for_idle(
        apps=[APP_NAME], status="blocked", timeout=1000
    ),
    assert not is_port_open(address, 22)

    # Set Port to 6789 "Dummy port" and validate app going to active status with port opened
    await app.set_config({"server-port": "6789"})
    await ops_test.model.wait_for_idle(
        apps=[APP_NAME], status="active", timeout=1000
    ),
    assert is_port_open(address, 6789)

In your Multipass Ubuntu VM shell, run the test as below:

ubuntu@charm-dev:~/fastapi-demo$ tox -re integration     

This test will take longer as a new model needs to be created. If successful, it should yield something similar to the output below:

==================================== 3 passed in 234.15s (0:03:54) ====================================
  integration: OK (254.77=setup[19.55]+cmd[235.22] seconds)
  congratulations :) (254.80 seconds)

Validate your charm

Congratulations, you’ve added a new feature to your charm, and also written tests to ensure that it will work properly. Time to give this feature a test drive!

In your Multipass VM, repack and refresh your charm as below:

ubuntu@charm-dev:~/fastapi-demo$ charmcraft pack
juju refresh \
  --path="./demo-api-charm_ubuntu-22.04-amd64.charm" \
  demo-api-charm --force-units --resource \
  demo-server-image=ghcr.io/canonical/api_demo_server:1.0.0

Watch your charm deployment status change until deployment settles down:

juju status --watch 1s

Use kubectl to list the available services and verify that demo-api-charm service exposes the ClusterIP on the expected port:

$ kubectl get services -n charm-model
NAME                            TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)              AGE
modeloperator                   ClusterIP   10.152.183.231   <none>        17071/TCP            34m
demo-api-charm-endpoints        ClusterIP   None             <none>        <none>               19m
demo-api-charm                  ClusterIP   10.152.183.92    <none>        65535/TCP,8000/TCP   19m
postgresql-k8s-endpoints        ClusterIP   None             <none>        <none>               18m
postgresql-k8s                  ClusterIP   10.152.183.162   <none>        5432/TCP,8008/TCP    18m
postgresql-k8s-primary          ClusterIP   10.152.183.109   <none>        8008/TCP,5432/TCP    18m
postgresql-k8s-replicas         ClusterIP   10.152.183.29    <none>        8008/TCP,5432/TCP    18m
patroni-postgresql-k8s-config   ClusterIP   None             <none>        <none>               17m

Finally, curl the ClusterIP to verify that the version endpoint responds on the expected port:

$ curl 10.152.183.92:8000/version
{"version":"1.0.0"}

Congratulations, your service now exposes an external port that is independent of any pod / node restarts!

Review the final code

For the full code see: 11_open_port_k8s_service

For a comparative view of the code before and after this doc see: Comparison

See next: Publish your charm on Charmhub

Contributors: @mmkay @ibraaoad

1 Like

typo?

@tmihoc

Seems so, updated.

1 Like