Skip to content

Commit

Permalink
Add basic configuration module (#15)
Browse files Browse the repository at this point in the history
* Add pydantic-settings dependency

* Add basic types to represent configuration

* Change minimum python version to 3.11

* Read configuration from file and add basic exception type

* Add pyfakefs as test dependency

* Add tomli-w test dependency

* Add tests for config parsing

* Implement TOML parsing with passing tests

* Add docstring to `__init__`

* Add tests for initializing FRR & Cabourotte configurations from configuration

* Implement `from_configuration` classmethod for `FRRPrefix` passing tests

* Implement `from_configuration` for `CabourotteHealthcheck`

* Use seconds as unit for interval configuration

* Add docstrings

* Add raised exceptions to `from_configuration` docstrings

* Remove pydantic-settings
Only needed for settings from env vars which we do not support(yet)

* Rename `sample_configuration` fixture to `sample_configuration_file` and move it to conftest

* Add sample configuration fixture

* Add basic test for creating a configuration from a TOML file

* Add basic config initialization implementation passing tests

* Add private marker to configuration module while removing it from all internal modules

* Move ServiceConfiguration to module

* Move healthcheck and prefix configurations into modules

* Organize tests

* Add `get_type_by_name` to get configuration types by their name

* Use `get_type_by_name` within `ServiceConfiguration`

* Add missing type hint

* Generalize healthcheck and prefix configuration types

* Upgrade pydantic to `2.5.2`

* Add new parent class for sub-configurations, creating a generic `from_configuration` method

* Flatten and simplify configuration module

* Expose `MainConfiguration` and `ConfigurationError` from configuration module

* Flatten and DRY tests

* Add some tests for `MainConfiguration`

* Add test for configuration exception

* Handle all possible `Exception` types in `ConfigurationError`

* Add type ignore unused for pylance error

* Add comment about type ignore

* Fix double punctuation typo

* Fix typo "sucessfully"

* Add example configuration schema to README
  • Loading branch information
SRv6d authored Nov 28, 2023
1 parent dc04930 commit 9f4f518
Show file tree
Hide file tree
Showing 19 changed files with 712 additions and 110 deletions.
1 change: 0 additions & 1 deletion .copier-answers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ project_license: Apache License 2.0
project_name: anycastd
project_slug: anycastd
python_versions:
- '3.10'
- '3.11'
- '3.12'
repository_namespace: gecio
Expand Down
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "Python 3",
"image": "mcr.microsoft.com/devcontainers/python:0-3.10",
"image": "mcr.microsoft.com/devcontainers/python:0-3.11",
"features": {
"ghcr.io/devcontainers-contrib/features/pdm:2": {},
"ghcr.io/devcontainers-contrib/features/nox:2": {}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
python_version: ["3.10", "3.11", "3.12"]
python_version: ["3.11", "3.12"]
nox_session:
- ruff
- mypy
Expand Down
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<img src="https://img.shields.io/badge/Types-Mypy-blue.svg" alt="typecheck">
</a>
<a>
<img src="https://img.shields.io/badge/v3.10+-black?style=flat&color=FFFF00&label=Python" alt="python version">
<img src="https://img.shields.io/badge/v3.11+-black?style=flat&color=FFFF00&label=Python" alt="python version">
</a>
<a href="https://pdm.fming.dev">
<img src="https://img.shields.io/badge/pdm-managed-blueviolet" alt="pdm">
Expand All @@ -20,3 +20,35 @@
<br>

A daemon to manage anycasted services based on status checks.

# Configuration

`anycastd` can be configured using a TOML configuration file located at `/etc/anycastd/config.toml`, or a path specified through the `--configuration` parameter.

A configuration for two dual-stacked services commonly run on the same host, both using [FRRouting] for BGP announcements and running health checks through [Cabourotte] could look like the following:

```toml
[services]

[services.dns]
prefixes.frrouting = ["2001:db8::b19:bad:53", "203.0.113.53"]
checks.cabourotte = ["dns_v6", "dns_v4"]

[services.ntp]
prefixes.frrouting = [
{ "prefix" = "2001:db8::123:7e11:713e", "vrf" = 123 },
{ "prefix" = "203.0.113.123", "vrf" = 123 },
]
checks.cabourotte = [
{ "name" = "ntp_v6", "interval" = 1 },
{ "name" = "ntp_v4", "interval" = 1 },
]
```

The first service, aptly named "dns", simply configures a DNS resolver service that announces the prefixes `2001:db8::b19:bad:53/128` & `203.0.113.53/32` through [FRRouting] as long as both [Cabourotte] health checks, `dns_v6` & `dns_v4` are reported as healthy.

The second service, "ntp" is similar in functionality, although it's configuration is a bit more verbose. Rather than omitting values that have a preconfigured default, a [VRF] as well as a health check interval are explicitly specified.

[FRRouting]: https://github.com/FRRouting/frr
[Cabourotte]: https://github.com/appclacks/cabourotte
[VRF]: https://en.wikipedia.org/wiki/Virtual_routing_and_forwarding
2 changes: 1 addition & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import nox

CI = bool(os.getenv("CI"))
PYTHON = ["3.10", "3.11", "3.12"] if not CI else None
PYTHON = ["3.11", "3.12"] if not CI else None
SESSIONS = "ruff", "mypy", "lockfile", "pytest"

nox.options.sessions = SESSIONS
Expand Down
179 changes: 78 additions & 101 deletions pdm.lock

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,16 @@ classifiers = [
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries :: Python Modules",
"Typing :: Typed",
]
keywords = []

requires-python = ">=3.10"
requires-python = ">=3.11"
dependencies = [
"pydantic>=2.4.0",
"pydantic>=2.5.2",
"httpx>=0.25.0",
]

Expand Down Expand Up @@ -57,14 +56,16 @@ test = [
"pytest-asyncio>=0.21.1",
"respx>=0.20.2",
"pytest-docker>=2.0.1",
"pyfakefs>=5.3.1",
"tomli-w>=1.0.0",
]
jupyter = [
"jupyter",
"notebook",
]

[tool.black]
target-version = ["py310", "py311", "py312"]
target-version = ["py311", "py312"]
include = '\.pyi?$'

[tool.ruff]
Expand Down
2 changes: 2 additions & 0 deletions src/anycastd/_configuration/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from anycastd._configuration.exceptions import ConfigurationError
from anycastd._configuration.main import MainConfiguration
37 changes: 37 additions & 0 deletions src/anycastd/_configuration/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import tomllib
from pathlib import Path

from pydantic import ValidationError


class ConfigurationError(Exception):
"""There was an error with the configuration file."""

def __init__(
self,
path: Path,
exc: OSError
| tomllib.TOMLDecodeError
| KeyError
| ValueError
| ValidationError
| TypeError,
):
msg = f"Could not read from configuration file {path}"
match exc:
case OSError():
msg += f" due to an I/O error: {exc}"
case tomllib.TOMLDecodeError():
msg += f" due to a TOML syntax error: {exc}"
case KeyError():
msg += f" due to missing required key: {exc}"
case ValueError() | ValidationError() | TypeError():
msg += f": {exc}"
case _:
msg += f", an unexpected exception occurred: {exc!r}"
raise TypeError(msg) from exc # type: ignore[unused-ignore]
# Pylance sees the above as a type error, while mypy does not.
# Unfortunately, Pylance does not implement its own per-line
# ignore mechanism, so the generic type ignore is used here.

super().__init__(msg)
47 changes: 47 additions & 0 deletions src/anycastd/_configuration/healthcheck.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import datetime
from typing import Literal, TypeAlias

from anycastd._configuration.sub import SubConfiguration


class HealthcheckConfiguration(SubConfiguration):
"""A generic class to group all healtcheck configuration classes."""


class CabourotteHealthcheck(HealthcheckConfiguration):
"""The configuration for a Cabourotte healthcheck.
Attributes:
name: The name of the healthcheck.
url: The URL of the cabourotte http endpoint.
interval: The interval in seconds at which the healthcheck should be executed.
"""

name: str
url: str = "http://127.0.0.1:9013"
interval: datetime.timedelta = datetime.timedelta(seconds=5)


Name: TypeAlias = Literal["cabourotte"]

_type_by_name: dict[Name, type[HealthcheckConfiguration]] = {
"cabourotte": CabourotteHealthcheck,
}


def get_type_by_name(name: Name) -> type[HealthcheckConfiguration]:
"""Get a healthcheck configuration class by it's name as used in the configuration.
Args:
name: The name of the healtcheck type.
Returns:
The confiuration class for the type of healthcheck.
Raises:
ValueError: There is no healthcheck type with the given name.
"""
try:
return _type_by_name[name]
except KeyError as exc:
raise ValueError(f"Unknown healthcheck type: {name}") from exc
58 changes: 58 additions & 0 deletions src/anycastd/_configuration/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Self

from pydantic import ValidationError

from anycastd._configuration.exceptions import ConfigurationError
from anycastd._configuration.service import ServiceConfiguration


@dataclass
class MainConfiguration:
"""The top-level configuration object."""

services: tuple[ServiceConfiguration, ...]

@classmethod
def from_toml_file(cls, path: Path) -> Self:
"""Create a configuration instance from a TOML configuration file.
Args:
path: The path to the configuration file.
Raises:
ConfigurationError: The configuration could not be read or parsed.
"""
config = _read_toml_configuration(path)
try:
return cls(
services=tuple(
ServiceConfiguration.from_name_and_options(name, options)
for name, options in config["services"].items()
)
)
except (KeyError, ValueError, TypeError, ValidationError) as exc:
raise ConfigurationError(path, exc) from exc


def _read_toml_configuration(path: Path) -> dict:
"""Read a TOML configuration file.
Args:
path: The path to the configuration file.
Returns:
The parsed configuration data.
Raises:
ConfigurationError: The configuration could not be read or parsed.
"""
try:
with path.open("rb") as f:
data = tomllib.load(f)
except (OSError, tomllib.TOMLDecodeError) as exc:
raise ConfigurationError(path, exc) from exc

return data
48 changes: 48 additions & 0 deletions src/anycastd/_configuration/prefix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from ipaddress import IPv4Network, IPv6Network
from pathlib import Path
from typing import Literal, TypeAlias

from anycastd._configuration.sub import SubConfiguration


class PrefixConfiguration(SubConfiguration):
"""A generic class to group all prefix configuration classes."""


class FRRPrefix(PrefixConfiguration):
"""The configuration for a FRRouting prefix.
Attributes:
prefix: The prefix to advertise.
vrf: The VRF to advertise the prefix in.
vtysh: The path to the vtysh binary.
"""

prefix: IPv4Network | IPv6Network
vrf: int | None = None
vtysh: Path = Path("/usr/bin/vtysh")


Name: TypeAlias = Literal["frrouting"]

_type_by_name: dict[Name, type[PrefixConfiguration]] = {
"frrouting": FRRPrefix,
}


def get_type_by_name(name: Name) -> type[PrefixConfiguration]:
"""Get a prefix configuration class by it's name as used in the configuration.
Args:
name: The name of the prefix type.
Returns:
The confiuration class for the type of prefix.
Raises:
ValueError: There is no prefix type with the given name.
"""
try:
return _type_by_name[name]
except KeyError as exc:
raise ValueError(f"Unknown prefix type: {name}") from exc
48 changes: 48 additions & 0 deletions src/anycastd/_configuration/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from dataclasses import dataclass
from typing import Self

from anycastd._configuration import healthcheck, prefix
from anycastd._configuration.healthcheck import HealthcheckConfiguration
from anycastd._configuration.prefix import PrefixConfiguration


@dataclass
class ServiceConfiguration:
"""The configuration for a service."""

name: str
prefixes: tuple[PrefixConfiguration, ...]
checks: tuple[HealthcheckConfiguration, ...]

@classmethod
def from_name_and_options(cls, name: str, options: dict) -> Self:
"""Create an instance from the configuration format.
Args:
name: The name of the service.
options: The configuration options for the service.
Returns:
A new ServiceConfiguration instance.
Raises:
KeyError: The configuration is missing a required key.
ValueError: The configuration contains an invalid value.
TypeError: The configuration has an invalid type.
ValidationError: Failed to validate the configuration.
"""
prefixes = []
for prefix_type, prefix_configs in options["prefixes"].items():
prefix_class = prefix.get_type_by_name(prefix_type)

for config in prefix_configs:
prefixes.append(prefix_class.from_configuration(config))

checks = []
for check_type, check_configs in options["checks"].items():
check_class = healthcheck.get_type_by_name(check_type)

for config in check_configs:
checks.append(check_class.from_configuration(config))

return cls(name=name, prefixes=tuple(prefixes), checks=tuple(checks))
Loading

0 comments on commit 9f4f518

Please sign in to comment.