Skip to content

Commit

Permalink
Merge pull request #59 from lsst-sqre/tickets/DM-33153
Browse files Browse the repository at this point in the history
[DM-33153] Add Kubernetes support code
  • Loading branch information
rra authored Jan 13, 2022
2 parents 382d892 + f0ac09c commit 3a1f054
Show file tree
Hide file tree
Showing 14 changed files with 604 additions and 2 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ Change log
.. Headline template:
X.Y.Z (YYYY-MM-DD)
2.4.0 (2022-01-13)
==================

- Add an ``initialize_kubernetes`` helper function to load Kubernetes configuration.
Add the ``safir.testing.kubernetes`` module, which can be used to mock the Kubernetes API for testing.
To use the new Kubernetes support, depend on ``safir[kubernetes]``.

2.3.0 (2021-12-13)
==================

Expand Down
6 changes: 6 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ API reference
.. automodapi:: safir.dependencies.logger
:include-all-objects:

.. automodapi:: safir.kubernetes
:include-all-objects:

.. automodapi:: safir.logging
:include-all-objects:

Expand All @@ -22,3 +25,6 @@ API reference

.. automodapi:: safir.middleware.x_forwarded
:include-all-objects:

.. automodapi:: safir.testing.kubernetes
:include-all-objects:
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Guides
:maxdepth: 2

http-client
kubernetes
logging
x-forwarded

Expand Down
104 changes: 104 additions & 0 deletions docs/kubernetes.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
########################
Using the Kubernetes API
########################

Safir-based applications are encouraged to use the `kubernetes-asyncio <https://github.com/tomplus/kubernetes_asyncio>`__ Python module.
It provides an async API for Kubernetes that will work naturally with FastAPI applications.

Most Kubernetes work can be done by calling that API directly, with no need for Safir wrapper functions.
Safir provides a convenient `~safir.kubernetes.initialize_kubernetes` function that chooses the correct way to load the Kubernetes configuration depending on whether the code is running from within or outside of a cluster, and a framework for mocking the Kubernetes API for tests.

Kubernetes support in Safir is optional.
To use it, depend on ``safir[kuberentes]``.

Initializing Kubernetes
=======================

A Kubernetes configuration must be loaded before making the first API call.
Safir provides the `~safir.kubernetes.initialize_kubernetes` async function to do this.
It doesn't take any arguments.
The Kubernetes configuration will be loaded from the in-cluster configuration path if the environment variable ``KUBERNETES_PORT`` is set, which will be set inside a cluster, and otherwise attempts to load configuration from the user's home directory.

A FastAPI application that uses Kubernetes from inside route handlers should normally call this function during application startup.
For example:

.. code-block:: python
from safir.kubernetes import initialize_kubernetes
@app.on_event("startup")
async def startup_event() -> None:
await initialize_kubernetes()
Testing with mock Kubernetes
============================

The `safir.testing.kubernetes` module provides a mock Kubernetes API with a limited implementation the API, and some utility functions to use it.

Applications that want to run tests with the mock Kubernetes API should define a fixture (in ``conftest.py``) as follows:

.. code-block:: python
from typing import Iterator
import pytest
from safir.testing.kubernetes import MockKubernetesApi, patch_kubernetes
@pytest.fixture
def mock_kubernetes() -> Iterator[MockKubernetesApi]:
yield from patch_kubernetes()
Then, when initializing Kubernetes, be sure not to import ``ApiClient``, ``CoreV1Api``, or ``CustomObjectsApi`` directly into a module.
Instead, use:

.. code-block:: python
from kubernetes_asyncio import client
and then use ``client.ApiClient``, ``client.CoreV1Api``, and ``client.CustomObjectsApi``.
This will ensure that the Kubernetes API is mocked properly.

You can then use ``mock_kubernetes`` as a fixture.
The resulting object supports a limited subset of the ``client.CoreV1Api`` and ``client.CustomObjectsApi`` method calls for creating, retrieving, modifying, and deleting objects.
The objects created by either the test or by the application code under test will be stored in memory inside the ``mock_kubernetes`` object.

You can use the `~safir.testing.kubernetes.MockKubernetesApi.get_all_objects_for_test` method to retrieve all objects of a given kind, allowing comparisons against an expected list of objects.

Testing error handling
----------------------

The ``mock_kubernetes`` fixture supports error injection by setting the ``error_callback`` attribute on the object to a callable.
If this is set, that callable will be called at the start of every mocked Kubernetes API call.
It will receive the method name as its first argument and the arguments to the method as its subsequent arguments.

Inside that callable, the test may, for example, make assertions about the arguments passed in to that method or raise exceptions to simulate errors from the Kubernetes API.

Here is a simplified example from `Gafaelfawr <https://gafaelfawr.lsst.io/>`__ that tests error handling for a command-line invocation when the Kubernetes API is not available:

.. code-block:: python
def test_update_service_tokens_error(
mock_kubernetes: MockKubernetesApi,
caplog: LogCaptureFixture,
) -> None:
caplog.clear()
def error_callback(method: str, *args: Any) -> None:
if method == "list_cluster_custom_object":
raise ApiException(status=500, reason="Some error")
mock_kubernetes.error_callback = error_callback
runner = CliRunner()
result = runner.invoke(main, ["update-service-tokens"])
assert result.exit_code == 1
assert parse_log(caplog) == [
{
"event": "Unable to list GafaelfawrServiceToken objects",
"error": "Kubernetes API error: (500)\nReason: Some error\n",
"severity": "error",
},
]
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ isolated_build = True
description = Run pytest against {envname}.
extras =
dev
kubernetes
commands=
coverage run -m pytest {posargs}
Expand Down Expand Up @@ -90,6 +91,7 @@ exclude = '''
# Multi-line strings are implicitly treated by black as regular expressions

[tool.isort]
include_trailing_comma = true
multi_line_output = 3
known_first_party = ["safir", "tests"]
skip = ["docs/conf.py"]
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ dev =
lsst-sphinx-bootstrap-theme<0.3
sphinx-automodapi==0.14.0
sphinx-prompt
kubernetes =
kubernetes_asyncio

[flake8]
max-line-length = 79
Expand Down
4 changes: 2 additions & 2 deletions src/safir/dependencies/logger.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Logger dependency for FastAPI.
Provides a :py:mod:`structlog` logger as a FastAPI dependency. The logger
will incorporate information from the request in its bound context.
Provides a `structlog` logger as a FastAPI dependency. The logger will
incorporate information from the request in its bound context.
"""

import uuid
Expand Down
28 changes: 28 additions & 0 deletions src/safir/kubernetes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Utilities for configuring a Kubernetes client."""

from __future__ import annotations

import os

from kubernetes_asyncio import config


async def initialize_kubernetes() -> None:
"""Load the Kubernetes configuration.
This has to be run once per process and should be run during application
startup. This function handles Kubernetes configuration independent of
any given Kubernetes client so that clients can be created for each
request.
Notes
-----
If ``KUBERNETES_PORT`` is set in the environment, this will use
``load_incluster_config`` to get configuration information from the local
pod metadata. Otherwise, it will use ``load_kube_config`` to read
configuration from the user's home directory.
"""
if "KUBERNETES_PORT" in os.environ:
config.load_incluster_config()
else:
await config.load_kube_config()
Empty file added src/safir/testing/__init__.py
Empty file.
Loading

0 comments on commit 3a1f054

Please sign in to comment.