From 18f57b98bccfb43d1b5989e85be21a829627a8d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 23:13:51 +0000 Subject: [PATCH 01/48] Initial plan From d1db162d881905edbbf2a7f1f603e6de4c2ad217 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 23:30:21 +0000 Subject: [PATCH 02/48] Add haproxy-spoe-auth-operator charm with uv+ruff Co-authored-by: Thanhphan1147 <42444001+Thanhphan1147@users.noreply.github.com> --- haproxy_spoe_auth_operator/README.md | 67 +++++++++ haproxy_spoe_auth_operator/charmcraft.yaml | 56 ++++++++ haproxy_spoe_auth_operator/pyproject.toml | 111 +++++++++++++++ haproxy_spoe_auth_operator/src/charm.py | 132 +++++++++++++++++ .../src/spoe_auth_service.py | 129 +++++++++++++++++ .../src/state/__init__.py | 4 + .../src/state/charm_state.py | 43 ++++++ .../src/state/exception.py | 8 ++ haproxy_spoe_auth_operator/src/state/oauth.py | 72 ++++++++++ .../templates/config.yaml.j2 | 13 ++ .../tests/integration/__init__.py | 4 + .../tests/integration/conftest.py | 46 ++++++ .../tests/integration/test_charm.py | 64 +++++++++ .../tests/unit/__init__.py | 4 + .../tests/unit/conftest.py | 68 +++++++++ .../tests/unit/test_charm.py | 123 ++++++++++++++++ haproxy_spoe_auth_operator/tox.toml | 134 ++++++++++++++++++ pyproject.toml | 6 +- tox.ini | 11 +- 19 files changed, 1088 insertions(+), 7 deletions(-) create mode 100644 haproxy_spoe_auth_operator/README.md create mode 100644 haproxy_spoe_auth_operator/charmcraft.yaml create mode 100644 haproxy_spoe_auth_operator/pyproject.toml create mode 100644 haproxy_spoe_auth_operator/src/charm.py create mode 100644 haproxy_spoe_auth_operator/src/spoe_auth_service.py create mode 100644 haproxy_spoe_auth_operator/src/state/__init__.py create mode 100644 haproxy_spoe_auth_operator/src/state/charm_state.py create mode 100644 haproxy_spoe_auth_operator/src/state/exception.py create mode 100644 haproxy_spoe_auth_operator/src/state/oauth.py create mode 100644 haproxy_spoe_auth_operator/templates/config.yaml.j2 create mode 100644 haproxy_spoe_auth_operator/tests/integration/__init__.py create mode 100644 haproxy_spoe_auth_operator/tests/integration/conftest.py create mode 100644 haproxy_spoe_auth_operator/tests/integration/test_charm.py create mode 100644 haproxy_spoe_auth_operator/tests/unit/__init__.py create mode 100644 haproxy_spoe_auth_operator/tests/unit/conftest.py create mode 100644 haproxy_spoe_auth_operator/tests/unit/test_charm.py create mode 100644 haproxy_spoe_auth_operator/tox.toml diff --git a/haproxy_spoe_auth_operator/README.md b/haproxy_spoe_auth_operator/README.md new file mode 100644 index 000000000..3359f7257 --- /dev/null +++ b/haproxy_spoe_auth_operator/README.md @@ -0,0 +1,67 @@ +# HAProxy SPOE Auth Operator + +A Juju charm that deploys and manages HAProxy SPOE Auth on machines. + +## Overview + +HAProxy SPOE Auth is a Stream Processing Offload Engine (SPOE) agent for HAProxy that provides authentication capabilities. This charm simplifies the deployment and management of the agent. + +## Features + +- **OAuth Authentication**: Support for OAuth-based authentication +- **Snap-based Deployment**: Uses the haproxy-spoe-auth snap for easy installation and updates +- **Configuration Management**: Automated configuration file management via Jinja2 templates + +## Usage + +Deploy the charm: + +```bash +juju deploy ./haproxy_spoe_auth_operator +``` + +Configure the SPOE address: + +```bash +juju config haproxy-spoe-auth spoe-address="127.0.0.1:3000" +``` + +Integrate with an OAuth provider: + +```bash +juju integrate haproxy-spoe-auth oauth-provider +``` + +## Configuration + +- `spoe-address`: Address for SPOE agent to listen on (default: "127.0.0.1:3000") + +## Relations + +- `oauth` (requires): OAuth authentication provider integration + +## Development + +This charm is part of the haproxy-operator monorepo. + +### Testing + +Run unit tests: +```bash +tox -e unit +``` + +Run integration tests: +```bash +tox -e integration +``` + +Run linting: +```bash +tox -e lint +``` + +## Project Information + +- [Source Code](https://github.com/canonical/haproxy-operator) +- [Issue Tracker](https://github.com/canonical/haproxy-operator/issues) diff --git a/haproxy_spoe_auth_operator/charmcraft.yaml b/haproxy_spoe_auth_operator/charmcraft.yaml new file mode 100644 index 000000000..f75800d0c --- /dev/null +++ b/haproxy_spoe_auth_operator/charmcraft.yaml @@ -0,0 +1,56 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 + +platforms: + amd64: + +parts: + charm: + source: . + plugin: uv + build-snaps: + - astral-uv + build-packages: + - git + templates: + plugin: dump + source: . + stage: + - templates + +name: haproxy-spoe-auth +title: HAProxy SPOE Auth charm +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + deploying and managing [HAProxy SPOE Auth](https://github.com/Thanhphan1147/haproxy-spoe-auth) on machines. + + HAProxy SPOE Auth is a Stream Processing Offload Engine agent for HAProxy that provides + authentication capabilities including OAuth support. + + This charm simplifies initial deployment and "day N" operations of HAProxy SPOE Auth on + VMs and bare metal. +summary: HAProxy SPOE authentication agent +links: + documentation: https://discourse.charmhub.io/t/haproxy-spoe-auth-documentation-overview/ + issues: https://github.com/canonical/haproxy-operator/issues + source: https://github.com/canonical/haproxy-operator + contact: + - https://launchpad.net/~canonical-is-devops + +assumes: +- juju >= 3.3 + +requires: + oauth: + interface: oauth + description: OAuth authentication provider integration + +config: + options: + spoe-address: + default: "127.0.0.1:3000" + type: string + description: Address for SPOE agent to listen on diff --git a/haproxy_spoe_auth_operator/pyproject.toml b/haproxy_spoe_auth_operator/pyproject.toml new file mode 100644 index 000000000..239549f42 --- /dev/null +++ b/haproxy_spoe_auth_operator/pyproject.toml @@ -0,0 +1,111 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +[project] +name = "haproxy-spoe-auth-operator" +version = "0.0.0" +description = "HAProxy SPOE authentication agent" +readme = "README.md" +requires-python = ">=3.12" +classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "jinja2==3.1.6", + "ops==3.3.1", + "pydantic==2.12.4", +] + +[dependency-groups] +fmt = [ "ruff" ] +lint = [ + "codespell", + "mypy", + "ops[testing]", + "pytest", + "ruff", + "types-pyyaml", +] +unit = [ "coverage[toml]", "ops[testing]", "pytest" ] +coverage-report = [ "coverage[toml]", "pytest" ] +static = [ "bandit[toml]" ] +integration = [ + "jubilant==1.1.1", + "juju==3.6.1.0", + "pytest", + "pytest-asyncio", +] + +[tool.uv] +package = false + +[tool.ruff] +target-version = "py310" +line-length = 99 + +lint.select = [ "A", "B", "C", "CPY", "D", "E", "F", "I", "N", "RUF", "S", "SIM", "TC", "UP", "W" ] +lint.ignore = [ + "B904", + "D107", + "D203", + "D204", + "D205", + "D213", + "D215", + "D400", + "D404", + "D406", + "D407", + "D408", + "D409", + "D413", + "E501", + "S105", + "S603", + "S607", + "TC002", + "TC006", + "UP006", + "UP007", + "UP035", + "UP045", +] +lint.per-file-ignores."tests/*" = [ "B011", "D100", "D101", "D102", "D103", "D104", "D212", "D415", "D417", "S" ] +lint.flake8-copyright.author = "Canonical Ltd." +lint.flake8-copyright.min-file-size = 1 +lint.flake8-copyright.notice-rgx = "Copyright\\s\\d{4}([-,]\\d{4})*\\s+" +lint.mccabe.max-complexity = 10 +lint.pydocstyle.convention = "google" + +[tool.codespell] +skip = "build,lib,venv,icon.svg,.tox,.git,.mypy_cache,.ruff_cache,.coverage,htmlcov,uv.lock" + +[tool.pytest.ini_options] +minversion = "6.0" +log_cli_level = "INFO" + +[tool.coverage.run] +branch = true + +[tool.coverage.report] +show_missing = true + +[tool.mypy] +check_untyped_defs = true +disallow_untyped_defs = true +explicit_package_bases = true +ignore_missing_imports = true +namespace_packages = true + +[[tool.mypy.overrides]] +disallow_untyped_defs = false +module = "tests.*" + +[tool.bandit] +exclude_dirs = [ "/venv/" ] + +[tool.bandit.assert_used] +skips = [ "*/*test.py", "*/test_*.py", "*tests/*.py" ] diff --git a/haproxy_spoe_auth_operator/src/charm.py b/haproxy_spoe_auth_operator/src/charm.py new file mode 100644 index 000000000..0725ad13c --- /dev/null +++ b/haproxy_spoe_auth_operator/src/charm.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""haproxy-spoe-auth-operator charm file.""" + +import logging +import typing + +import ops + +from spoe_auth_service import ( + SpoeAuthService, + SpoeAuthServiceConfigError, + SpoeAuthServiceInstallError, +) +from state.charm_state import CharmState, InvalidCharmConfigError, ProxyMode +from state.exception import CharmStateValidationBaseError +from state.oauth import OAuthInformation + +logger = logging.getLogger(__name__) + +OAUTH_RELATION = "oauth" + + +class HaproxySpoeAuthCharm(ops.CharmBase): + """Charm haproxy-spoe-auth.""" + + def __init__(self, *args: typing.Any): + """Initialize the charm and register event handlers. + + Args: + args: Arguments to pass to the CharmBase parent constructor. + """ + super().__init__(*args) + self.service = SpoeAuthService() + + self.framework.observe(self.on.install, self._on_install) + self.framework.observe(self.on.config_changed, self._on_config_changed) + self.framework.observe( + self.on[OAUTH_RELATION].relation_changed, self._on_oauth_relation_changed + ) + self.framework.observe( + self.on[OAUTH_RELATION].relation_broken, self._on_oauth_relation_broken + ) + + def _on_install(self, _event: ops.InstallEvent) -> None: + """Handle install event. + + Args: + _event: The install event. + """ + try: + self.service.install() + self.unit.status = ops.MaintenanceStatus("Service installed") + except SpoeAuthServiceInstallError as exc: + logger.exception("Failed to install service") + self.unit.status = ops.BlockedStatus(f"Installation failed: {exc}") + return + + self._reconcile() + + def _on_config_changed(self, _event: ops.ConfigChangedEvent) -> None: + """Handle config changed event. + + Args: + _event: The config changed event. + """ + self._reconcile() + + def _on_oauth_relation_changed(self, _event: ops.RelationChangedEvent) -> None: + """Handle oauth relation changed event. + + Args: + _event: The relation changed event. + """ + self._reconcile() + + def _on_oauth_relation_broken(self, _event: ops.RelationBrokenEvent) -> None: + """Handle oauth relation broken event. + + Args: + _event: The relation broken event. + """ + self._reconcile() + + def _reconcile(self) -> None: + """Reconcile the charm state and service configuration.""" + try: + charm_state = self._get_charm_state() + oauth_info = OAuthInformation.from_charm(self) + + self.service.reconcile(charm_state, oauth_info) + + if charm_state.mode == ProxyMode.OAUTH: + self.unit.status = ops.ActiveStatus("OAuth authentication enabled") + else: + self.unit.status = ops.ActiveStatus("Service running without authentication") + + except CharmStateValidationBaseError as exc: + logger.exception("Charm state validation failed") + self.unit.status = ops.BlockedStatus(f"Configuration error: {exc}") + except SpoeAuthServiceConfigError as exc: + logger.exception("Service configuration failed") + self.unit.status = ops.BlockedStatus(f"Service configuration failed: {exc}") + except Exception as exc: + logger.exception("Unexpected error during reconciliation") + self.unit.status = ops.BlockedStatus(f"Unexpected error: {exc}") + + def _get_charm_state(self) -> CharmState: + """Get the current charm state. + + Returns: + CharmState: The current charm state. + + Raises: + InvalidCharmConfigError: When the charm configuration is invalid. + """ + try: + oauth_relation = self.model.get_relation(OAUTH_RELATION) + mode = ProxyMode.OAUTH if oauth_relation else ProxyMode.NOAUTH + + spoe_address = self.config.get("spoe-address", "127.0.0.1:3000") + + return CharmState(mode=mode, spoe_address=spoe_address) + except Exception as exc: + raise InvalidCharmConfigError(f"Invalid charm configuration: {exc}") from exc + + +if __name__ == "__main__": # pragma: nocover + ops.main(HaproxySpoeAuthCharm) diff --git a/haproxy_spoe_auth_operator/src/spoe_auth_service.py b/haproxy_spoe_auth_operator/src/spoe_auth_service.py new file mode 100644 index 000000000..55680d5c2 --- /dev/null +++ b/haproxy_spoe_auth_operator/src/spoe_auth_service.py @@ -0,0 +1,129 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""The haproxy-spoe-auth service module.""" + +import logging +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader, select_autoescape + +from state.charm_state import CharmState +from state.oauth import OAuthInformation + +SNAP_NAME = "haproxy-spoe-auth" +SERVICE_NAME = "haproxy-spoe-auth" +CONFIG_PATH = Path("/var/snap/haproxy-spoe-auth/current/config.yaml") +CONFIG_TEMPLATE = "config.yaml.j2" + +logger = logging.getLogger(__name__) + + +class SpoeAuthServiceInstallError(Exception): + """Exception raised when snap installation fails.""" + + +class SpoeAuthServiceConfigError(Exception): + """Exception raised when service configuration fails.""" + + +class SpoeAuthService: + """HAProxy SPOE Auth service class.""" + + def __init__(self) -> None: + """Initialize the service.""" + self._template_dir = Path(__file__).parent.parent / "templates" + + def install(self) -> None: + """Install the haproxy-spoe-auth snap. + + Raises: + SpoeAuthServiceInstallError: When snap installation fails. + """ + try: + # Import here to avoid issues when snap module is not available + import subprocess # nosec B404 + + subprocess.run( # nosec B603 + ["snap", "install", SNAP_NAME, "--edge"], + check=True, + capture_output=True, + text=True, + ) + logger.info("Successfully installed %s snap", SNAP_NAME) + except Exception as exc: + raise SpoeAuthServiceInstallError( + f"Failed to install {SNAP_NAME} snap: {exc}" + ) from exc + + def is_active(self) -> bool: + """Check if the service is active. + + Returns: + True if the service is running, False otherwise. + """ + try: + import subprocess # nosec B404 + + result = subprocess.run( # nosec B603 + ["snap", "services", SNAP_NAME], + check=True, + capture_output=True, + text=True, + ) + return "active" in result.stdout + except Exception: + return False + + def reconcile(self, charm_state: CharmState, oauth_info: OAuthInformation) -> None: + """Reconcile the service configuration. + + Args: + charm_state: The current charm state. + oauth_info: OAuth integration information. + + Raises: + SpoeAuthServiceConfigError: When configuration fails. + """ + try: + self._render_config(charm_state, oauth_info) + self._restart_service() + logger.info("Successfully reconciled %s service", SERVICE_NAME) + except Exception as exc: + raise SpoeAuthServiceConfigError(f"Failed to reconcile service: {exc}") from exc + + def _render_config(self, charm_state: CharmState, oauth_info: OAuthInformation) -> None: + """Render the configuration file. + + Args: + charm_state: The current charm state. + oauth_info: OAuth integration information. + """ + env = Environment( + loader=FileSystemLoader(str(self._template_dir)), + autoescape=select_autoescape(), + ) + template = env.get_template(CONFIG_TEMPLATE) + + config_content = template.render( + spoe_address=charm_state.spoe_address, + oauth_enabled=oauth_info.oauth_data is not None, + oauth_data=oauth_info.oauth_data if oauth_info.oauth_data else {}, + ) + + # Ensure parent directory exists + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text(config_content, encoding="utf-8") + logger.info("Configuration written to %s", CONFIG_PATH) + + def _restart_service(self) -> None: + """Restart the service.""" + import subprocess # nosec B404 + + subprocess.run( # nosec B603 + ["snap", "restart", SNAP_NAME], + check=True, + capture_output=True, + text=True, + ) + logger.info("Service %s restarted", SERVICE_NAME) diff --git a/haproxy_spoe_auth_operator/src/state/__init__.py b/haproxy_spoe_auth_operator/src/state/__init__.py new file mode 100644 index 000000000..8eccbc7f7 --- /dev/null +++ b/haproxy_spoe_auth_operator/src/state/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""State module for haproxy-spoe-auth-operator.""" diff --git a/haproxy_spoe_auth_operator/src/state/charm_state.py b/haproxy_spoe_auth_operator/src/state/charm_state.py new file mode 100644 index 000000000..8cd7f83bf --- /dev/null +++ b/haproxy_spoe_auth_operator/src/state/charm_state.py @@ -0,0 +1,43 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""haproxy-spoe-auth-operator charm state.""" + +import logging +from enum import StrEnum + +from pydantic import Field +from pydantic.dataclasses import dataclass + +from .exception import CharmStateValidationBaseError + +logger = logging.getLogger(__name__) + + +class InvalidCharmConfigError(CharmStateValidationBaseError): + """Exception raised when a charm configuration is found to be invalid.""" + + +class ProxyMode(StrEnum): + """StrEnum of possible authentication modes. + + Attrs: + OAUTH: When oauth relation is established. + NOAUTH: When no authentication is configured. + """ + + OAUTH = "oauth" + NOAUTH = "noauth" + + +@dataclass(frozen=True) +class CharmState: + """A component of charm state that contains the charm's configuration and mode. + + Attributes: + mode: The current authentication mode of the charm. + spoe_address: The address for SPOE agent to listen on. + """ + + mode: ProxyMode + spoe_address: str = Field(alias="spoe_address") diff --git a/haproxy_spoe_auth_operator/src/state/exception.py b/haproxy_spoe_auth_operator/src/state/exception.py new file mode 100644 index 000000000..89a9a8526 --- /dev/null +++ b/haproxy_spoe_auth_operator/src/state/exception.py @@ -0,0 +1,8 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Exceptions for charm state.""" + + +class CharmStateValidationBaseError(Exception): + """Base exception for charm state validation errors.""" diff --git a/haproxy_spoe_auth_operator/src/state/oauth.py b/haproxy_spoe_auth_operator/src/state/oauth.py new file mode 100644 index 000000000..aeab64dce --- /dev/null +++ b/haproxy_spoe_auth_operator/src/state/oauth.py @@ -0,0 +1,72 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OAuth relation state management.""" + +import logging +from typing import Optional + +from pydantic import Field, ValidationError +from pydantic.dataclasses import dataclass + +from .exception import CharmStateValidationBaseError + +logger = logging.getLogger(__name__) + + +class OAuthDataValidationError(CharmStateValidationBaseError): + """Exception raised when OAuth relation data validation fails.""" + + +@dataclass(frozen=True) +class OAuthData: + """OAuth relation data. + + Attributes: + client_id: The OAuth client ID. + client_secret: The OAuth client secret. + provider_url: The OAuth provider URL. + """ + + client_id: str = Field(min_length=1) + client_secret: str = Field(min_length=1) + provider_url: str = Field(min_length=1) + + +@dataclass(frozen=True) +class OAuthInformation: + """OAuth integration information. + + Attributes: + oauth_data: OAuth configuration data. + """ + + oauth_data: Optional[OAuthData] + + @classmethod + def from_charm(cls, charm: "ops.CharmBase") -> "OAuthInformation": # type: ignore # noqa: F821 + """Initialize OAuthInformation from charm. + + Args: + charm: The charm instance. + + Returns: + OAuthInformation instance. + + Raises: + OAuthDataValidationError: When OAuth data validation fails. + """ + oauth_relation = charm.model.get_relation("oauth") + if not oauth_relation: + return cls(oauth_data=None) + + try: + relation_data = oauth_relation.data[oauth_relation.app] + oauth_data = OAuthData( + client_id=relation_data.get("client_id", ""), + client_secret=relation_data.get("client_secret", ""), + provider_url=relation_data.get("provider_url", ""), + ) + return cls(oauth_data=oauth_data) + except (ValidationError, KeyError) as exc: + raise OAuthDataValidationError(f"Invalid OAuth relation data: {exc}") from exc diff --git a/haproxy_spoe_auth_operator/templates/config.yaml.j2 b/haproxy_spoe_auth_operator/templates/config.yaml.j2 new file mode 100644 index 000000000..3d0ae3dd6 --- /dev/null +++ b/haproxy_spoe_auth_operator/templates/config.yaml.j2 @@ -0,0 +1,13 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +# HAProxy SPOE Auth Configuration +spoe: + address: {{ spoe_address }} + +{% if oauth_enabled %} +oauth: + client_id: {{ oauth_data.client_id }} + client_secret: {{ oauth_data.client_secret }} + provider_url: {{ oauth_data.provider_url }} +{% endif %} diff --git a/haproxy_spoe_auth_operator/tests/integration/__init__.py b/haproxy_spoe_auth_operator/tests/integration/__init__.py new file mode 100644 index 000000000..53159b284 --- /dev/null +++ b/haproxy_spoe_auth_operator/tests/integration/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Integration tests for haproxy-spoe-auth-operator.""" diff --git a/haproxy_spoe_auth_operator/tests/integration/conftest.py b/haproxy_spoe_auth_operator/tests/integration/conftest.py new file mode 100644 index 000000000..b5e8887b4 --- /dev/null +++ b/haproxy_spoe_auth_operator/tests/integration/conftest.py @@ -0,0 +1,46 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Integration test fixtures for haproxy-spoe-auth-operator.""" + +import pathlib + +import jubilant +import pytest + + +@pytest.fixture(name="juju", scope="module") +def fixture_juju() -> jubilant.Juju: + """Create a Juju instance for integration testing. + + Returns: + A Juju instance. + """ + return jubilant.Juju(model_config={"logging-config": "=INFO"}) + + +@pytest.fixture(name="charm_path", scope="module") +def fixture_charm_path() -> str: + """Return the path to the charm directory. + + Returns: + Path to the charm directory. + """ + return str(pathlib.Path(__file__).parent.parent.parent) + + +@pytest.fixture(name="application", scope="module") +def fixture_application(juju: jubilant.Juju, charm_path: str) -> str: + """Deploy the haproxy-spoe-auth charm. + + Args: + juju: The Juju instance. + charm_path: Path to the charm. + + Returns: + The application name. + """ + app_name = "haproxy-spoe-auth" + juju.deploy(charm_path, app_name) + juju.wait(lambda status: jubilant.all_active(status, app_name)) + return app_name diff --git a/haproxy_spoe_auth_operator/tests/integration/test_charm.py b/haproxy_spoe_auth_operator/tests/integration/test_charm.py new file mode 100644 index 000000000..95384bbda --- /dev/null +++ b/haproxy_spoe_auth_operator/tests/integration/test_charm.py @@ -0,0 +1,64 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Basic integration tests for the haproxy-spoe-auth charm.""" + +import jubilant +import pytest + + +@pytest.mark.abort_on_fail +def test_deploy_and_status(application: str, juju: jubilant.Juju): + """Test charm deployment and basic status. + + Args: + application: The application name. + juju: The Juju instance. + + Assert: + The charm deploys successfully and reaches active status. + """ + status = juju.status() + assert application in status["applications"] + app_status = status["applications"][application] + + # Check that we have at least one unit + assert len(app_status["units"]) > 0 + + # Check the unit status + unit_name = f"{application}/0" + unit = app_status["units"][unit_name] + assert unit["workload-status"]["current"] == "active" + + +@pytest.mark.abort_on_fail +def test_snap_installed(application: str, juju: jubilant.Juju): + """Test that the haproxy-spoe-auth snap is installed. + + Args: + application: The application name. + juju: The Juju instance. + + Assert: + The snap is installed on the unit. + """ + result = juju.ssh(f"{application}/0", "snap list haproxy-spoe-auth") + assert "haproxy-spoe-auth" in result + + +@pytest.mark.abort_on_fail +def test_config_file_exists(application: str, juju: jubilant.Juju): + """Test that the configuration file is created. + + Args: + application: The application name. + juju: The Juju instance. + + Assert: + The config file exists at the expected location. + """ + result = juju.ssh( + f"{application}/0", + "sudo test -f /var/snap/haproxy-spoe-auth/current/config.yaml && echo 'exists'", + ) + assert "exists" in result diff --git a/haproxy_spoe_auth_operator/tests/unit/__init__.py b/haproxy_spoe_auth_operator/tests/unit/__init__.py new file mode 100644 index 000000000..0f38cf54b --- /dev/null +++ b/haproxy_spoe_auth_operator/tests/unit/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Unit tests for haproxy-spoe-auth-operator.""" diff --git a/haproxy_spoe_auth_operator/tests/unit/conftest.py b/haproxy_spoe_auth_operator/tests/unit/conftest.py new file mode 100644 index 000000000..2e7ceab3b --- /dev/null +++ b/haproxy_spoe_auth_operator/tests/unit/conftest.py @@ -0,0 +1,68 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Unit test fixtures for haproxy-spoe-auth-operator.""" + +from unittest.mock import MagicMock, patch + +import ops.testing +import pytest + + +@pytest.fixture(name="context") +def fixture_context() -> ops.testing.Context: + """Create a testing context for the charm. + + Returns: + A Context object for testing. + """ + from charm import HaproxySpoeAuthCharm + + return ops.testing.Context( + HaproxySpoeAuthCharm, + meta={ + "name": "haproxy-spoe-auth", + "requires": { + "oauth": {"interface": "oauth"}, + }, + }, + config={ + "options": { + "spoe-address": { + "default": "127.0.0.1:3000", + "type": "string", + } + } + }, + ) + + +@pytest.fixture(name="base_state") +def fixture_base_state() -> dict: + """Create a base state for testing. + + Returns: + A dictionary representing the base state. + """ + return { + "config": {"spoe-address": "127.0.0.1:3000"}, + } + + +@pytest.fixture(name="context_with_mocks") +def fixture_context_with_mocks( + context: ops.testing.Context, +) -> tuple[ops.testing.Context, tuple[MagicMock, MagicMock]]: + """Create a context with mocked service methods. + + Args: + context: The testing context. + + Returns: + A tuple of the context and mocked methods. + """ + with ( + patch("charm.SpoeAuthService.install") as install_mock, + patch("charm.SpoeAuthService.reconcile") as reconcile_mock, + ): + yield context, (install_mock, reconcile_mock) diff --git a/haproxy_spoe_auth_operator/tests/unit/test_charm.py b/haproxy_spoe_auth_operator/tests/unit/test_charm.py new file mode 100644 index 000000000..fd9489729 --- /dev/null +++ b/haproxy_spoe_auth_operator/tests/unit/test_charm.py @@ -0,0 +1,123 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Unit tests for the haproxy-spoe-auth charm.""" + +import logging + +import ops.testing + +logger = logging.getLogger(__name__) + + +def test_install(context_with_mocks, base_state): + """Test charm install event. + + arrange: prepare base state + act: run install hook + assert: service install is called + """ + context, (install_mock, reconcile_mock) = context_with_mocks + state = ops.testing.State(**base_state) + context.run(context.on.install(), state) + install_mock.assert_called_once() + reconcile_mock.assert_called_once() + + +def test_config_changed(context_with_mocks, base_state): + """Test charm config changed event. + + arrange: prepare base state + act: trigger config changed hook + assert: reconcile is called + """ + context, (_, reconcile_mock) = context_with_mocks + state = ops.testing.State(**base_state) + context.run(context.on.config_changed(), state) + reconcile_mock.assert_called_once() + + +def test_oauth_relation_changed(context_with_mocks, base_state): + """Test oauth relation changed event. + + arrange: prepare state with oauth relation + act: trigger relation changed hook + assert: reconcile is called + """ + context, (_, reconcile_mock) = context_with_mocks + oauth_relation = ops.testing.Relation( + endpoint="oauth", + remote_app_name="oauth-provider", + remote_app_data={ + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "provider_url": "https://oauth.example.com", + }, + ) + state = ops.testing.State(relations=[oauth_relation], **base_state) + context.run(context.on.relation_changed(oauth_relation), state) + reconcile_mock.assert_called_once() + + +def test_oauth_relation_broken(context_with_mocks, base_state): + """Test oauth relation broken event. + + arrange: prepare state with oauth relation + act: trigger relation broken hook + assert: reconcile is called + """ + context, (_, reconcile_mock) = context_with_mocks + oauth_relation = ops.testing.Relation( + endpoint="oauth", + remote_app_name="oauth-provider", + remote_app_data={ + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "provider_url": "https://oauth.example.com", + }, + ) + state = ops.testing.State(relations=[oauth_relation], **base_state) + context.run(context.on.relation_broken(oauth_relation), state) + reconcile_mock.assert_called_once() + + +def test_charm_state_without_oauth(context, base_state): + """Test charm state when oauth is not configured. + + arrange: prepare base state without oauth + act: get charm state + assert: mode is NOAUTH + """ + from state.charm_state import ProxyMode + + state = ops.testing.State(**base_state) + with context.manager(context.on.config_changed(), state) as mgr: + charm = mgr.charm + charm_state = charm._get_charm_state() # type: ignore[attr-defined] + assert charm_state.mode == ProxyMode.NOAUTH + assert charm_state.spoe_address == "127.0.0.1:3000" + + +def test_charm_state_with_oauth(context, base_state): + """Test charm state when oauth is configured. + + arrange: prepare state with oauth relation + act: get charm state + assert: mode is OAUTH + """ + from state.charm_state import ProxyMode + + oauth_relation = ops.testing.Relation( + endpoint="oauth", + remote_app_name="oauth-provider", + remote_app_data={ + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "provider_url": "https://oauth.example.com", + }, + ) + state = ops.testing.State(relations=[oauth_relation], **base_state) + with context.manager(context.on.config_changed(), state) as mgr: + charm = mgr.charm + charm_state = charm._get_charm_state() # type: ignore[attr-defined] + assert charm_state.mode == ProxyMode.OAUTH diff --git a/haproxy_spoe_auth_operator/tox.toml b/haproxy_spoe_auth_operator/tox.toml new file mode 100644 index 000000000..a03e47f18 --- /dev/null +++ b/haproxy_spoe_auth_operator/tox.toml @@ -0,0 +1,134 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +skipsdist = true +skip_missing_interpreters = true +envlist = [ "lint", "unit", "static", "coverage-report" ] +requires = [ "tox>=4.21" ] +no_package = true + +[env_run_base] +passenv = [ "PYTHONPATH", "CHARM_BUILD_DIR", "MODEL_SETTINGS" ] +runner = "uv-venv-lock-runner" + +[env_run_base.setenv] +PYTHONPATH = "{toxinidir}/src" +PYTHONBREAKPOINT = "ipdb.set_trace" +PY_COLORS = "1" + +[env.fmt] +description = "Apply coding style standards to code" +commands = [ + [ + "ruff", + "check", + "--fix", + "--fix-only", + { replace = "ref", of = [ + "vars", + "all_path", + ], extend = true }, + ], + [ + "ruff", + "format", + { replace = "ref", of = [ + "vars", + "all_path", + ], extend = true }, + ], +] +dependency_groups = [ "fmt" ] + +[env.lint] +description = "Check code against coding style standards" +commands = [ + [ + "codespell", + "{toxinidir}", + ], + [ + "ruff", + "format", + "--check", + "--diff", + { replace = "ref", of = [ + "vars", + "all_path", + ], extend = true }, + ], + [ + "ruff", + "check", + { replace = "ref", of = [ + "vars", + "all_path", + ], extend = true }, + ], + [ + "mypy", + { replace = "ref", of = [ + "vars", + "all_path", + ], extend = true }, + ], +] +dependency_groups = [ "lint" ] + +[env.unit] +description = "Run unit tests" +commands = [ + [ + "coverage", + "run", + "--source={[vars]src_path}", + "-m", + "pytest", + "--ignore={[vars]tst_path}integration", + "-v", + "--tb", + "native", + "--log-cli-level=INFO", + "-s", + { replace = "posargs", extend = "true" }, + ], + [ + "coverage", + "report", + ], +] +dependency_groups = [ "unit" ] + +[env.coverage-report] +description = "Create test coverage report" +commands = [ [ "coverage", "report" ] ] +dependency_groups = [ "coverage-report" ] + +[env.static] +description = "Run static analysis tests" +commands = [ [ "bandit", "-c", "{toxinidir}/pyproject.toml", "-r", "{[vars]src_path}", "{[vars]tst_path}" ] ] +dependency_groups = [ "static" ] + +[env.integration] +description = "Run integration tests" +commands = [ + [ + "pytest", + "-v", + "--tb", + "native", + "--ignore={[vars]tst_path}unit", + "--log-cli-level=INFO", + "-s", + { replace = "posargs", extend = "true" }, + ], +] +dependency_groups = [ "integration" ] + +[vars] +src_path = "{toxinidir}/src/" +tst_path = "{toxinidir}/tests/" +all_path = [ + "{toxinidir}/src/", + "{toxinidir}/tests/", +] diff --git a/pyproject.toml b/pyproject.toml index 4b083dae9..9d2120edc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ select = ["E", "W", "F", "C", "N", "R", "D", "H"] # Ignore D107 Missing docstring in __init__ ignore = ["W503", "E501", "D107"] # D100, D101, D102, D103: Ignore missing docstrings in tests -per-file-ignores = ["tests/*:D100,D101,D102,D103,D104,D205,D212,D415"] +per-file-ignores = ["tests/*:D100,D101,D102,D103,D104,D205,D212,D415", "haproxy_spoe_auth_operator/tests/*:D100,D101,D102,D103,D104,D205,D212,D415"] docstring-convention = "google" [tool.isort] @@ -46,7 +46,7 @@ namespace_packages = true [[tool.mypy.overrides]] disallow_untyped_defs = false -module = "tests.*" +module = ["tests.*", "haproxy_spoe_auth_operator.tests.*"] [tool.pylint] disable = "wrong-import-order" @@ -74,7 +74,7 @@ extend-ignore = [ ] ignore = ["E501", "D107"] extend-exclude = ["__pycache__", "*.egg_info"] -per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"]} +per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"], "haproxy_spoe_auth_operator/tests/*" = ["D100","D101","D102","D103","D104"]} [tool.ruff.mccabe] max-complexity = 10 diff --git a/tox.ini b/tox.ini index e204d1b81..a956bc75f 100644 --- a/tox.ini +++ b/tox.ini @@ -9,12 +9,14 @@ envlist = lint, unit, static, coverage-report [vars] src_path = {toxinidir}/src/ tst_path = {toxinidir}/tests/ +spoe_auth_src_path = {toxinidir}/haproxy_spoe_auth_operator/src/ +spoe_auth_tst_path = {toxinidir}/haproxy_spoe_auth_operator/tests/ ;lib_path = {toxinidir}/lib/charms/operator_name_with_underscores -all_path = {[vars]src_path} {[vars]tst_path} {toxinidir}/lib/charms/haproxy/v1 {toxinidir}/lib/charms/haproxy/v0 +all_path = {[vars]src_path} {[vars]tst_path} {toxinidir}/lib/charms/haproxy/v1 {toxinidir}/lib/charms/haproxy/v0 {[vars]spoe_auth_src_path} {[vars]spoe_auth_tst_path} [testenv] setenv = - PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path} + PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path}:{[vars]spoe_auth_src_path} PYTHONBREAKPOINT=ipdb.set_trace PY_COLORS=1 passenv = @@ -61,6 +63,7 @@ deps = -r{toxinidir}/requirements.txt commands = pydocstyle {[vars]src_path} + pydocstyle {[vars]spoe_auth_src_path} # uncomment the following line if this charm owns a lib # codespell {[vars]lib_path} codespell {toxinidir} --skip {toxinidir}/.git --skip {toxinidir}/.tox \ @@ -81,8 +84,8 @@ deps = pytest -r{toxinidir}/requirements.txt commands = - coverage run --source={[vars]src_path},{toxinidir}/lib/charms/haproxy/v1/,{[vars]src_path},{toxinidir}/lib/charms/haproxy/v0/ \ - -m pytest --ignore={[vars]tst_path}integration -v --tb native --log-cli-level=INFO -s {posargs} + coverage run --source={[vars]src_path},{toxinidir}/lib/charms/haproxy/v1/,{[vars]src_path},{toxinidir}/lib/charms/haproxy/v0/,{[vars]spoe_auth_src_path} \ + -m pytest --ignore={[vars]tst_path}integration --ignore={[vars]spoe_auth_tst_path}integration -v --tb native --log-cli-level=INFO -s {posargs} coverage report [testenv:coverage-report] From 4e926d37e7db1c019b69c8e15a0fb0c74bbde90d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 23:34:13 +0000 Subject: [PATCH 03/48] Fix linting issues and ensure all tests pass Co-authored-by: Thanhphan1147 <42444001+Thanhphan1147@users.noreply.github.com> --- .../src/spoe_auth_service.py | 6 +- .../tests/unit/conftest.py | 6 +- .../tests/unit/test_charm.py | 37 +- haproxy_spoe_auth_operator/uv.lock | 1324 +++++++++++++++++ 4 files changed, 1346 insertions(+), 27 deletions(-) create mode 100644 haproxy_spoe_auth_operator/uv.lock diff --git a/haproxy_spoe_auth_operator/src/spoe_auth_service.py b/haproxy_spoe_auth_operator/src/spoe_auth_service.py index 55680d5c2..38dfad9d1 100644 --- a/haproxy_spoe_auth_operator/src/spoe_auth_service.py +++ b/haproxy_spoe_auth_operator/src/spoe_auth_service.py @@ -44,7 +44,7 @@ def install(self) -> None: # Import here to avoid issues when snap module is not available import subprocess # nosec B404 - subprocess.run( # nosec B603 + subprocess.run( # nosec B603 B607 ["snap", "install", SNAP_NAME, "--edge"], check=True, capture_output=True, @@ -65,7 +65,7 @@ def is_active(self) -> bool: try: import subprocess # nosec B404 - result = subprocess.run( # nosec B603 + result = subprocess.run( # nosec B603 B607 ["snap", "services", SNAP_NAME], check=True, capture_output=True, @@ -120,7 +120,7 @@ def _restart_service(self) -> None: """Restart the service.""" import subprocess # nosec B404 - subprocess.run( # nosec B603 + subprocess.run( # nosec B603 B607 ["snap", "restart", SNAP_NAME], check=True, capture_output=True, diff --git a/haproxy_spoe_auth_operator/tests/unit/conftest.py b/haproxy_spoe_auth_operator/tests/unit/conftest.py index 2e7ceab3b..f7a8e4322 100644 --- a/haproxy_spoe_auth_operator/tests/unit/conftest.py +++ b/haproxy_spoe_auth_operator/tests/unit/conftest.py @@ -3,7 +3,7 @@ """Unit test fixtures for haproxy-spoe-auth-operator.""" -from unittest.mock import MagicMock, patch +from unittest.mock import patch import ops.testing import pytest @@ -52,13 +52,13 @@ def fixture_base_state() -> dict: @pytest.fixture(name="context_with_mocks") def fixture_context_with_mocks( context: ops.testing.Context, -) -> tuple[ops.testing.Context, tuple[MagicMock, MagicMock]]: +): # type: ignore[misc] """Create a context with mocked service methods. Args: context: The testing context. - Returns: + Yields: A tuple of the context and mocked methods. """ with ( diff --git a/haproxy_spoe_auth_operator/tests/unit/test_charm.py b/haproxy_spoe_auth_operator/tests/unit/test_charm.py index fd9489729..ba5c932e9 100644 --- a/haproxy_spoe_auth_operator/tests/unit/test_charm.py +++ b/haproxy_spoe_auth_operator/tests/unit/test_charm.py @@ -81,32 +81,28 @@ def test_oauth_relation_broken(context_with_mocks, base_state): reconcile_mock.assert_called_once() -def test_charm_state_without_oauth(context, base_state): - """Test charm state when oauth is not configured. +def test_charm_state_without_oauth(context_with_mocks, base_state): + """Test charm status when oauth is not configured. arrange: prepare base state without oauth - act: get charm state - assert: mode is NOAUTH + act: trigger config changed + assert: charm is active without authentication """ - from state.charm_state import ProxyMode - + context, (_, reconcile_mock) = context_with_mocks state = ops.testing.State(**base_state) - with context.manager(context.on.config_changed(), state) as mgr: - charm = mgr.charm - charm_state = charm._get_charm_state() # type: ignore[attr-defined] - assert charm_state.mode == ProxyMode.NOAUTH - assert charm_state.spoe_address == "127.0.0.1:3000" + out = context.run(context.on.config_changed(), state) + reconcile_mock.assert_called_once() + assert out.unit_status == ops.testing.ActiveStatus("Service running without authentication") -def test_charm_state_with_oauth(context, base_state): - """Test charm state when oauth is configured. +def test_charm_state_with_oauth(context_with_mocks, base_state): + """Test charm status when oauth is configured. arrange: prepare state with oauth relation - act: get charm state - assert: mode is OAUTH + act: trigger config changed + assert: charm is active with oauth """ - from state.charm_state import ProxyMode - + context, (_, reconcile_mock) = context_with_mocks oauth_relation = ops.testing.Relation( endpoint="oauth", remote_app_name="oauth-provider", @@ -117,7 +113,6 @@ def test_charm_state_with_oauth(context, base_state): }, ) state = ops.testing.State(relations=[oauth_relation], **base_state) - with context.manager(context.on.config_changed(), state) as mgr: - charm = mgr.charm - charm_state = charm._get_charm_state() # type: ignore[attr-defined] - assert charm_state.mode == ProxyMode.OAUTH + out = context.run(context.on.config_changed(), state) + reconcile_mock.assert_called_once() + assert out.unit_status == ops.testing.ActiveStatus("OAuth authentication enabled") diff --git a/haproxy_spoe_auth_operator/uv.lock b/haproxy_spoe_auth_operator/uv.lock new file mode 100644 index 000000000..088e18edd --- /dev/null +++ b/haproxy_spoe_auth_operator/uv.lock @@ -0,0 +1,1324 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "bandit" +version = "1.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/b5/7eb834e213d6f73aace21938e5e90425c92e5f42abafaf8a6d5d21beed51/bandit-1.8.6.tar.gz", hash = "sha256:dbfe9c25fc6961c2078593de55fd19f2559f9e45b99f1272341f5b95dea4e56b", size = 4240271, upload-time = "2025-07-06T03:10:50.9Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ca/ba5f909b40ea12ec542d5d7bdd13ee31c4d65f3beed20211ef81c18fa1f3/bandit-1.8.6-py3-none-any.whl", hash = "sha256:3348e934d736fcdb68b6aa4030487097e23a501adf3e7827b63658df464dddd0", size = 133808, upload-time = "2025-07-06T03:10:49.134Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + +[[package]] +name = "cachetools" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "codespell" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/e0/709453393c0ea77d007d907dd436b3ee262e28b30995ea1aa36c6ffbccaf/codespell-2.4.1.tar.gz", hash = "sha256:299fcdcb09d23e81e35a671bbe746d5ad7e8385972e65dbb833a2eaac33c01e5", size = 344740, upload-time = "2025-01-28T18:52:39.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/01/b394922252051e97aab231d416c86da3d8a6d781eeadcdca1082867de64e/codespell-2.4.1-py3-none-any.whl", hash = "sha256:3dadafa67df7e4a3dbf51e0d7315061b80d265f9552ebd699b3dd6834b47e425", size = 344501, upload-time = "2025-01-28T18:52:37.057Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/9698d57a3b11704c7b89b21d69e9d23ecf80d538cabb536c8b63f4a12322/coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b", size = 815210, upload-time = "2025-11-10T00:13:17.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/39/af056ec7a27c487e25c7f6b6e51d2ee9821dba1863173ddf4dc2eebef4f7/coverage-7.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f", size = 216676, upload-time = "2025-11-10T00:11:11.566Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f8/21126d34b174d037b5d01bea39077725cbb9a0da94a95c5f96929c695433/coverage-7.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e", size = 217034, upload-time = "2025-11-10T00:11:13.12Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3f/0fd35f35658cdd11f7686303214bd5908225838f374db47f9e457c8d6df8/coverage-7.11.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a", size = 248531, upload-time = "2025-11-10T00:11:15.023Z" }, + { url = "https://files.pythonhosted.org/packages/8f/59/0bfc5900fc15ce4fd186e092451de776bef244565c840c9c026fd50857e1/coverage-7.11.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1", size = 251290, upload-time = "2025-11-10T00:11:16.628Z" }, + { url = "https://files.pythonhosted.org/packages/71/88/d5c184001fa2ac82edf1b8f2cd91894d2230d7c309e937c54c796176e35b/coverage-7.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd", size = 252375, upload-time = "2025-11-10T00:11:18.249Z" }, + { url = "https://files.pythonhosted.org/packages/5c/29/f60af9f823bf62c7a00ce1ac88441b9a9a467e499493e5cc65028c8b8dd2/coverage-7.11.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5", size = 248946, upload-time = "2025-11-10T00:11:20.202Z" }, + { url = "https://files.pythonhosted.org/packages/67/16/4662790f3b1e03fce5280cad93fd18711c35980beb3c6f28dca41b5230c6/coverage-7.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e", size = 250310, upload-time = "2025-11-10T00:11:21.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/dd6c2e28308a83e5fc1ee602f8204bd3aa5af685c104cb54499230cf56db/coverage-7.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044", size = 248461, upload-time = "2025-11-10T00:11:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/16/fe/b71af12be9f59dc9eb060688fa19a95bf3223f56c5af1e9861dfa2275d2c/coverage-7.11.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7", size = 248039, upload-time = "2025-11-10T00:11:25.07Z" }, + { url = "https://files.pythonhosted.org/packages/11/b8/023b2003a2cd96bdf607afe03d9b96c763cab6d76e024abe4473707c4eb8/coverage-7.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405", size = 249903, upload-time = "2025-11-10T00:11:26.992Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/5f1076311aa67b1fa4687a724cc044346380e90ce7d94fec09fd384aa5fd/coverage-7.11.3-cp312-cp312-win32.whl", hash = "sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e", size = 219201, upload-time = "2025-11-10T00:11:28.619Z" }, + { url = "https://files.pythonhosted.org/packages/4f/24/d21688f48fe9fcc778956680fd5aaf69f4e23b245b7c7a4755cbd421d25b/coverage-7.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055", size = 220012, upload-time = "2025-11-10T00:11:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9e/d5eb508065f291456378aa9b16698b8417d87cb084c2b597f3beb00a8084/coverage-7.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f", size = 218652, upload-time = "2025-11-10T00:11:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f6/d8572c058211c7d976f24dab71999a565501fb5b3cdcb59cf782f19c4acb/coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36", size = 216694, upload-time = "2025-11-10T00:11:34.296Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f6/b6f9764d90c0ce1bce8d995649fa307fff21f4727b8d950fa2843b7b0de5/coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e", size = 217065, upload-time = "2025-11-10T00:11:36.281Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/a12cb424063019fd077b5be474258a0ed8369b92b6d0058e673f0a945982/coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2", size = 248062, upload-time = "2025-11-10T00:11:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9c/dab1a4e8e75ce053d14259d3d7485d68528a662e286e184685ea49e71156/coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63", size = 250657, upload-time = "2025-11-10T00:11:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/3f/89/a14f256438324f33bae36f9a1a7137729bf26b0a43f5eda60b147ec7c8c7/coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3", size = 251900, upload-time = "2025-11-10T00:11:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/04/07/75b0d476eb349f1296486b1418b44f2d8780cc8db47493de3755e5340076/coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5", size = 248254, upload-time = "2025-11-10T00:11:43.27Z" }, + { url = "https://files.pythonhosted.org/packages/5a/4b/0c486581fa72873489ca092c52792d008a17954aa352809a7cbe6cf0bf07/coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5", size = 250041, upload-time = "2025-11-10T00:11:45.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/a3/0059dafb240ae3e3291f81b8de00e9c511d3dd41d687a227dd4b529be591/coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7", size = 248004, upload-time = "2025-11-10T00:11:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/83/93/967d9662b1eb8c7c46917dcc7e4c1875724ac3e73c3cb78e86d7a0ac719d/coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5", size = 247828, upload-time = "2025-11-10T00:11:48.563Z" }, + { url = "https://files.pythonhosted.org/packages/4c/1c/5077493c03215701e212767e470b794548d817dfc6247a4718832cc71fac/coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094", size = 249588, upload-time = "2025-11-10T00:11:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a5/77f64de461016e7da3e05d7d07975c89756fe672753e4cf74417fc9b9052/coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c", size = 219223, upload-time = "2025-11-10T00:11:52.184Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1c/ec51a3c1a59d225b44bdd3a4d463135b3159a535c2686fac965b698524f4/coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2", size = 220033, upload-time = "2025-11-10T00:11:53.871Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/e0ce39746ed558564c16f2cc25fa95ce6fc9fa8bfb3b9e62855d4386b886/coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944", size = 218661, upload-time = "2025-11-10T00:11:55.597Z" }, + { url = "https://files.pythonhosted.org/packages/46/cb/483f130bc56cbbad2638248915d97b185374d58b19e3cc3107359715949f/coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428", size = 217389, upload-time = "2025-11-10T00:11:57.59Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ae/81f89bae3afef75553cf10e62feb57551535d16fd5859b9ee5a2a97ddd27/coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a", size = 217742, upload-time = "2025-11-10T00:11:59.519Z" }, + { url = "https://files.pythonhosted.org/packages/db/6e/a0fb897041949888191a49c36afd5c6f5d9f5fd757e0b0cd99ec198a324b/coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655", size = 259049, upload-time = "2025-11-10T00:12:01.592Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/d13acc67eb402d91eb94b9bd60593411799aed09ce176ee8d8c0e39c94ca/coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7", size = 261113, upload-time = "2025-11-10T00:12:03.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/07/a6868893c48191d60406df4356aa7f0f74e6de34ef1f03af0d49183e0fa1/coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d", size = 263546, upload-time = "2025-11-10T00:12:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/e5/28598f70b2c1098332bac47925806353b3313511d984841111e6e760c016/coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f", size = 258260, upload-time = "2025-11-10T00:12:07.137Z" }, + { url = "https://files.pythonhosted.org/packages/0e/58/58e2d9e6455a4ed746a480c4b9cf96dc3cb2a6b8f3efbee5efd33ae24b06/coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0", size = 261121, upload-time = "2025-11-10T00:12:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/17/57/38803eefb9b0409934cbc5a14e3978f0c85cb251d2b6f6a369067a7105a0/coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739", size = 258736, upload-time = "2025-11-10T00:12:11.195Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/f94683167156e93677b3442be1d4ca70cb33718df32a2eea44a5898f04f6/coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71", size = 257625, upload-time = "2025-11-10T00:12:12.843Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/42d0bf1bc6bfa7d65f52299a31daaa866b4c11000855d753857fe78260ac/coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76", size = 259827, upload-time = "2025-11-10T00:12:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/d3/76/5682719f5d5fbedb0c624c9851ef847407cae23362deb941f185f489c54e/coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c", size = 219897, upload-time = "2025-11-10T00:12:17.274Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/1da511d0ac3d39e6676fa6cc5ec35320bbf1cebb9b24e9ee7548ee4e931a/coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac", size = 220959, upload-time = "2025-11-10T00:12:19.292Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9d/e255da6a04e9ec5f7b633c54c0fdfa221a9e03550b67a9c83217de12e96c/coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc", size = 219234, upload-time = "2025-11-10T00:12:21.251Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/634ec396e45aded1772dccf6c236e3e7c9604bc47b816e928f32ce7987d1/coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c", size = 216746, upload-time = "2025-11-10T00:12:23.089Z" }, + { url = "https://files.pythonhosted.org/packages/28/76/1079547f9d46f9c7c7d0dad35b6873c98bc5aa721eeabceafabd722cd5e7/coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203", size = 217077, upload-time = "2025-11-10T00:12:24.863Z" }, + { url = "https://files.pythonhosted.org/packages/2d/71/6ad80d6ae0d7cb743b9a98df8bb88b1ff3dc54491508a4a97549c2b83400/coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240", size = 248122, upload-time = "2025-11-10T00:12:26.553Z" }, + { url = "https://files.pythonhosted.org/packages/20/1d/784b87270784b0b88e4beec9d028e8d58f73ae248032579c63ad2ac6f69a/coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83", size = 250638, upload-time = "2025-11-10T00:12:28.555Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/b6dd31e23e004e9de84d1a8672cd3d73e50f5dae65dbd0f03fa2cdde6100/coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902", size = 251972, upload-time = "2025-11-10T00:12:30.246Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ef/f9c64d76faac56b82daa036b34d4fe9ab55eb37f22062e68e9470583e688/coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428", size = 248147, upload-time = "2025-11-10T00:12:32.195Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/5b666f90a8f8053bd264a1ce693d2edef2368e518afe70680070fca13ecd/coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75", size = 249995, upload-time = "2025-11-10T00:12:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/eb/7b/871e991ffb5d067f8e67ffb635dabba65b231d6e0eb724a4a558f4a702a5/coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704", size = 247948, upload-time = "2025-11-10T00:12:36.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/ce454f0af9609431b06dbe5485fc9d1c35ddc387e32ae8e374f49005748b/coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b", size = 247770, upload-time = "2025-11-10T00:12:38.167Z" }, + { url = "https://files.pythonhosted.org/packages/61/8f/79002cb58a61dfbd2085de7d0a46311ef2476823e7938db80284cedd2428/coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131", size = 249431, upload-time = "2025-11-10T00:12:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/58/cc/d06685dae97468ed22999440f2f2f5060940ab0e7952a7295f236d98cce7/coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a", size = 219508, upload-time = "2025-11-10T00:12:42.231Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ed/770cd07706a3598c545f62d75adf2e5bd3791bffccdcf708ec383ad42559/coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86", size = 220325, upload-time = "2025-11-10T00:12:44.065Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ac/6a1c507899b6fb1b9a56069954365f655956bcc648e150ce64c2b0ecbed8/coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e", size = 218899, upload-time = "2025-11-10T00:12:46.18Z" }, + { url = "https://files.pythonhosted.org/packages/9a/58/142cd838d960cd740654d094f7b0300d7b81534bb7304437d2439fb685fb/coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df", size = 217471, upload-time = "2025-11-10T00:12:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/bc/2c/2f44d39eb33e41ab3aba80571daad32e0f67076afcf27cb443f9e5b5a3ee/coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001", size = 217742, upload-time = "2025-11-10T00:12:50.182Z" }, + { url = "https://files.pythonhosted.org/packages/32/76/8ebc66c3c699f4de3174a43424c34c086323cd93c4930ab0f835731c443a/coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de", size = 259120, upload-time = "2025-11-10T00:12:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/78a3302b9595f331b86e4f12dfbd9252c8e93d97b8631500888f9a3a2af7/coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926", size = 261229, upload-time = "2025-11-10T00:12:54.667Z" }, + { url = "https://files.pythonhosted.org/packages/07/59/1a9c0844dadef2a6efac07316d9781e6c5a3f3ea7e5e701411e99d619bfd/coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd", size = 263642, upload-time = "2025-11-10T00:12:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/37/86/66c15d190a8e82eee777793cabde730640f555db3c020a179625a2ad5320/coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac", size = 258193, upload-time = "2025-11-10T00:12:58.687Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c7/4a4aeb25cb6f83c3ec4763e5f7cc78da1c6d4ef9e22128562204b7f39390/coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46", size = 261107, upload-time = "2025-11-10T00:13:00.502Z" }, + { url = "https://files.pythonhosted.org/packages/ed/91/b986b5035f23cf0272446298967ecdd2c3c0105ee31f66f7e6b6948fd7f8/coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64", size = 258717, upload-time = "2025-11-10T00:13:02.747Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/6c084997f5a04d050c513545d3344bfa17bd3b67f143f388b5757d762b0b/coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f", size = 257541, upload-time = "2025-11-10T00:13:04.689Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c5/38e642917e406930cb67941210a366ccffa767365c8f8d9ec0f465a8b218/coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820", size = 259872, upload-time = "2025-11-10T00:13:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/b7/67/5e812979d20c167f81dbf9374048e0193ebe64c59a3d93d7d947b07865fa/coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237", size = 220289, upload-time = "2025-11-10T00:13:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/24/3a/b72573802672b680703e0df071faadfab7dcd4d659aaaffc4626bc8bbde8/coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9", size = 221398, upload-time = "2025-11-10T00:13:10.734Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4e/649628f28d38bad81e4e8eb3f78759d20ac173e3c456ac629123815feb40/coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd", size = 219435, upload-time = "2025-11-10T00:13:12.712Z" }, + { url = "https://files.pythonhosted.org/packages/19/8f/92bdd27b067204b99f396a1414d6342122f3e2663459baf787108a6b8b84/coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe", size = 208478, upload-time = "2025-11-10T00:13:14.908Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +] + +[[package]] +name = "google-auth" +version = "2.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/ef/66d14cf0e01b08d2d51ffc3c20410c4e134a1548fc246a6081eae585a4fe/google_auth-2.43.0.tar.gz", hash = "sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483", size = 296359, upload-time = "2025-11-06T00:13:36.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/d1/385110a9ae86d91cc14c5282c61fe9f4dc41c0b9f7d423c6ad77038c4448/google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16", size = 223114, upload-time = "2025-11-06T00:13:35.209Z" }, +] + +[[package]] +name = "haproxy-spoe-auth-operator" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "jinja2" }, + { name = "ops" }, + { name = "pydantic" }, +] + +[package.dev-dependencies] +coverage-report = [ + { name = "coverage" }, + { name = "pytest" }, +] +fmt = [ + { name = "ruff" }, +] +integration = [ + { name = "jubilant" }, + { name = "juju" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] +lint = [ + { name = "codespell" }, + { name = "mypy" }, + { name = "ops", extra = ["testing"] }, + { name = "pytest" }, + { name = "ruff" }, + { name = "types-pyyaml" }, +] +static = [ + { name = "bandit" }, +] +unit = [ + { name = "coverage" }, + { name = "ops", extra = ["testing"] }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "jinja2", specifier = "==3.1.6" }, + { name = "ops", specifier = "==3.3.1" }, + { name = "pydantic", specifier = "==2.12.4" }, +] + +[package.metadata.requires-dev] +coverage-report = [ + { name = "coverage", extras = ["toml"] }, + { name = "pytest" }, +] +fmt = [{ name = "ruff" }] +integration = [ + { name = "jubilant", specifier = "==1.1.1" }, + { name = "juju", specifier = "==3.6.1.0" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] +lint = [ + { name = "codespell" }, + { name = "mypy" }, + { name = "ops", extras = ["testing"] }, + { name = "pytest" }, + { name = "ruff" }, + { name = "types-pyyaml" }, +] +static = [{ name = "bandit", extras = ["toml"] }] +unit = [ + { name = "coverage", extras = ["toml"] }, + { name = "ops", extras = ["testing"] }, + { name = "pytest" }, +] + +[[package]] +name = "hvac" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/57/b46c397fb3842cfb02a44609aa834c887f38dd75f290c2fc5a34da4b2fee/hvac-2.4.0.tar.gz", hash = "sha256:e0056ad9064e7923e874e6769015b032580b639e29246f5ab1044f7959c1c7e0", size = 332543, upload-time = "2025-10-30T12:57:47.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/33/71e45a6bd6875f44a26f99da31c63b6840123e88bedf2c0b1ce429b8be12/hvac-2.4.0-py3-none-any.whl", hash = "sha256:008db5efd8c2f77bd37d2368ea5f713edceae1c65f11fd608393179478649e0f", size = 155921, upload-time = "2025-10-30T12:57:46.253Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "invoke" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762, upload-time = "2025-10-11T00:36:35.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jubilant" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/1d/6077242245ffe5f986d23a675521c339a7c4d271b64e333e9f86709f8f73/jubilant-1.1.1.tar.gz", hash = "sha256:ffc194cf878c10c5d3768b224bce7a09e6d136c575080c8c5a62921551da0432", size = 24150, upload-time = "2025-06-05T23:48:20.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/b4/7a401a99ee6351dc52c8755c137652508f3a8810b378765e128c9b282232/jubilant-1.1.1-py3-none-any.whl", hash = "sha256:be3e813f329fe6b37115d617f3c3961b5b990c3022426df24dda4f3c5a121292", size = 23317, upload-time = "2025-06-05T23:48:19.329Z" }, +] + +[[package]] +name = "juju" +version = "3.6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hvac" }, + { name = "kubernetes" }, + { name = "macaroonbakery" }, + { name = "packaging" }, + { name = "paramiko" }, + { name = "pyasn1" }, + { name = "pyrfc3339" }, + { name = "pyyaml" }, + { name = "toposort" }, + { name = "typing-extensions" }, + { name = "typing-inspect" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/86e724a37ce2167a5145bd122a10ea36e5a1a3b2b28a27017d46b6fdc306/juju-3.6.1.0.tar.gz", hash = "sha256:59cfde55185bb53877a2bddc2855f3c48471537e130653d77984681676a448bc", size = 291387, upload-time = "2024-12-20T00:16:34.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/68/f70c41ab95990b610798809aed792d0efebd79cc322d54e5a3c5685fa4f0/juju-3.6.1.0-py3-none-any.whl", hash = "sha256:28b6a10093f2e0243ad0ddd5ef25a3f59d710e9da5a188456ba704142819fbb3", size = 287063, upload-time = "2024-12-20T00:16:32.206Z" }, +] + +[[package]] +name = "kubernetes" +version = "30.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "google-auth" }, + { name = "oauthlib" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/3c/9f29f6cab7f35df8e54f019e5719465fa97b877be2454e99f989270b4f34/kubernetes-30.1.0.tar.gz", hash = "sha256:41e4c77af9f28e7a6c314e3bd06a8c6229ddd787cad684e0ab9f69b498e98ebc", size = 887810, upload-time = "2024-06-06T15:58:30.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/2027ddede72d33be2effc087580aeba07e733a7360780ae87226f1f91bd8/kubernetes-30.1.0-py2.py3-none-any.whl", hash = "sha256:e212e8b7579031dd2e512168b617373bc1e03888d41ac4e04039240a292d478d", size = 1706042, upload-time = "2024-06-06T15:58:27.13Z" }, +] + +[[package]] +name = "macaroonbakery" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, + { name = "pymacaroons" }, + { name = "pynacl" }, + { name = "pyrfc3339" }, + { name = "requests" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/ae/59f5ab870640bd43673b708e5f24aed592dc2673cc72caa49b0053b4af37/macaroonbakery-1.3.4.tar.gz", hash = "sha256:41ca993a23e4f8ef2fe7723b5cd4a30c759735f1d5021e990770c8a0e0f33970", size = 82143, upload-time = "2023-12-13T14:22:22.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/42/227f748dc222b7a1c5cb40c7c74ab4162c7fc146b88980776b490ab673a1/macaroonbakery-1.3.4-py2.py3-none-any.whl", hash = "sha256:1e952a189f5c1e96ef82b081b2852c770d7daa20987e2088e762dd5689fb253b", size = 103184, upload-time = "2023-12-13T14:22:20.159Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/d8/0f354c375628e048bd0570645b310797299754730079853095bf000fba69/opentelemetry_api-1.38.0.tar.gz", hash = "sha256:f4c193b5e8acb0912b06ac5b16321908dd0843d75049c091487322284a3eea12", size = 65242, upload-time = "2025-10-16T08:35:50.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/a2/d86e01c28300bd41bab8f18afd613676e2bd63515417b77636fc1add426f/opentelemetry_api-1.38.0-py3-none-any.whl", hash = "sha256:2891b0197f47124454ab9f0cf58f3be33faca394457ac3e09daba13ff50aa582", size = 65947, upload-time = "2025-10-16T08:35:30.23Z" }, +] + +[[package]] +name = "ops" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "pyyaml" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/cd/bbd4263b53bb19e87f995b05ef93f565fa4edcb90bacacec615d47c56568/ops-3.3.1.tar.gz", hash = "sha256:8dec621c32a31365d040eaf05960c0e65a2ffbcdbfa91107e03b1ee9d4d26034", size = 541783, upload-time = "2025-10-16T01:46:25.431Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/97/c1e5447c3c0d86cab16cad802e1bb58613b2396b7184797af2e4484c2014/ops-3.3.1-py3-none-any.whl", hash = "sha256:38229bb1cc6d9c2aa4dff4495f3e33af928dbd1070e8c5d8b697dfb32dcd89a8", size = 191338, upload-time = "2025-10-16T01:46:20.038Z" }, +] + +[package.optional-dependencies] +testing = [ + { name = "ops-scenario" }, +] + +[[package]] +name = "ops-scenario" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ops" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/94/cf17208427d43f9136b0fb61ca75ac937b88ac66cb0a3fe126bbffbde677/ops_scenario-8.3.1.tar.gz", hash = "sha256:553cd1ee30f161edd110e217a01505a9778baf778c039d810e0cfbd6101b855b", size = 110260, upload-time = "2025-10-16T01:46:27.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/dd/da2bcb1a164c4c9c8690c689305b15c3f988166db44add5084c4dc04ad04/ops_scenario-8.3.1-py3-none-any.whl", hash = "sha256:bf8404387cd8c22cd0b45c996ca7c12e84b6c603edc8b056e85234fc3d270941", size = 64403, upload-time = "2025-10-16T01:46:22.082Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "paramiko" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "invoke" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/ff/64a6c8f420818bb873713988ca5492cba3a7946be57e027ac63495157d97/protobuf-6.33.0.tar.gz", hash = "sha256:140303d5c8d2037730c548f8c7b93b20bb1dc301be280c378b82b8894589c954", size = 443463, upload-time = "2025-10-15T20:39:52.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/ee/52b3fa8feb6db4a833dfea4943e175ce645144532e8a90f72571ad85df4e/protobuf-6.33.0-cp310-abi3-win32.whl", hash = "sha256:d6101ded078042a8f17959eccd9236fb7a9ca20d3b0098bbcb91533a5680d035", size = 425593, upload-time = "2025-10-15T20:39:40.29Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c6/7a465f1825872c55e0341ff4a80198743f73b69ce5d43ab18043699d1d81/protobuf-6.33.0-cp310-abi3-win_amd64.whl", hash = "sha256:9a031d10f703f03768f2743a1c403af050b6ae1f3480e9c140f39c45f81b13ee", size = 436882, upload-time = "2025-10-15T20:39:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a9/b6eee662a6951b9c3640e8e452ab3e09f117d99fc10baa32d1581a0d4099/protobuf-6.33.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:905b07a65f1a4b72412314082c7dbfae91a9e8b68a0cc1577515f8df58ecf455", size = 427521, upload-time = "2025-10-15T20:39:43.803Z" }, + { url = "https://files.pythonhosted.org/packages/10/35/16d31e0f92c6d2f0e77c2a3ba93185130ea13053dd16200a57434c882f2b/protobuf-6.33.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e0697ece353e6239b90ee43a9231318302ad8353c70e6e45499fa52396debf90", size = 324445, upload-time = "2025-10-15T20:39:44.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/2a981a13e35cda8b75b5585aaffae2eb904f8f351bdd3870769692acbd8a/protobuf-6.33.0-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:e0a1715e4f27355afd9570f3ea369735afc853a6c3951a6afe1f80d8569ad298", size = 339159, upload-time = "2025-10-15T20:39:46.186Z" }, + { url = "https://files.pythonhosted.org/packages/21/51/0b1cbad62074439b867b4e04cc09b93f6699d78fd191bed2bbb44562e077/protobuf-6.33.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef", size = 323172, upload-time = "2025-10-15T20:39:47.465Z" }, + { url = "https://files.pythonhosted.org/packages/07/d1/0a28c21707807c6aacd5dc9c3704b2aa1effbf37adebd8caeaf68b17a636/protobuf-6.33.0-py3-none-any.whl", hash = "sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995", size = 170477, upload-time = "2025-10-15T20:39:51.311Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pymacaroons" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pynacl" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/b4/52ff00b59e91c4817ca60210c33caf11e85a7f68f7b361748ca2eb50923e/pymacaroons-0.13.0.tar.gz", hash = "sha256:1e6bba42a5f66c245adf38a5a4006a99dcc06a0703786ea636098667d42903b8", size = 21083, upload-time = "2018-02-21T18:07:49.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/87/fd9b54258216e3f19671f6e9dd76da1ebc49e93ea0107c986b1071dd3068/pymacaroons-0.13.0-py2.py3-none-any.whl", hash = "sha256:3e14dff6a262fdbf1a15e769ce635a8aea72e6f8f91e408f9a97166c53b91907", size = 19463, upload-time = "2018-02-21T18:07:47.085Z" }, +] + +[[package]] +name = "pynacl" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/46/aeca065d227e2265125aea590c9c47fbf5786128c9400ee0eb7c88931f06/pynacl-1.6.1.tar.gz", hash = "sha256:8d361dac0309f2b6ad33b349a56cd163c98430d409fa503b10b70b3ad66eaa1d", size = 3506616, upload-time = "2025-11-10T16:02:13.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/d6/4b2dca33ed512de8f54e5c6074aa06eaeb225bfbcd9b16f33a414389d6bd/pynacl-1.6.1-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:7d7c09749450c385301a3c20dca967a525152ae4608c0a096fe8464bfc3df93d", size = 389109, upload-time = "2025-11-10T16:01:28.79Z" }, + { url = "https://files.pythonhosted.org/packages/3c/30/e8dbb8ff4fa2559bbbb2187ba0d0d7faf728d17cb8396ecf4a898b22d3da/pynacl-1.6.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc734c1696ffd49b40f7c1779c89ba908157c57345cf626be2e0719488a076d3", size = 808254, upload-time = "2025-11-10T16:01:37.839Z" }, + { url = "https://files.pythonhosted.org/packages/44/f9/f5449c652f31da00249638dbab065ad4969c635119094b79b17c3a4da2ab/pynacl-1.6.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3cd787ec1f5c155dc8ecf39b1333cfef41415dc96d392f1ce288b4fe970df489", size = 1407365, upload-time = "2025-11-10T16:01:40.454Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2f/9aa5605f473b712065c0a193ebf4ad4725d7a245533f0cd7e5dcdbc78f35/pynacl-1.6.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b35d93ab2df03ecb3aa506be0d3c73609a51449ae0855c2e89c7ed44abde40b", size = 843842, upload-time = "2025-11-10T16:01:30.524Z" }, + { url = "https://files.pythonhosted.org/packages/32/8d/748f0f6956e207453da8f5f21a70885fbbb2e060d5c9d78e0a4a06781451/pynacl-1.6.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dece79aecbb8f4640a1adbb81e4aa3bfb0e98e99834884a80eb3f33c7c30e708", size = 1445559, upload-time = "2025-11-10T16:01:33.663Z" }, + { url = "https://files.pythonhosted.org/packages/78/d0/2387f0dcb0e9816f38373999e48db4728ed724d31accdd4e737473319d35/pynacl-1.6.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c2228054f04bf32d558fb89bb99f163a8197d5a9bf4efa13069a7fa8d4b93fc3", size = 825791, upload-time = "2025-11-10T16:01:34.823Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/ef6fb7eb072aaf15f280bc66f26ab97e7fc9efa50fb1927683013ef47473/pynacl-1.6.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:2b12f1b97346f177affcdfdc78875ff42637cb40dcf79484a97dae3448083a78", size = 1410843, upload-time = "2025-11-10T16:01:36.401Z" }, + { url = "https://files.pythonhosted.org/packages/e3/fb/23824a017526850ee7d8a1cc4cd1e3e5082800522c10832edbbca8619537/pynacl-1.6.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e735c3a1bdfde3834503baf1a6d74d4a143920281cb724ba29fb84c9f49b9c48", size = 801140, upload-time = "2025-11-10T16:01:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d1/ebc6b182cb98603a35635b727d62f094bc201bf610f97a3bb6357fe688d2/pynacl-1.6.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3384a454adf5d716a9fadcb5eb2e3e72cd49302d1374a60edc531c9957a9b014", size = 1371966, upload-time = "2025-11-10T16:01:43.297Z" }, + { url = "https://files.pythonhosted.org/packages/64/f4/c9d7b6f02924b1f31db546c7bd2a83a2421c6b4a8e6a2e53425c9f2802e0/pynacl-1.6.1-cp314-cp314t-win32.whl", hash = "sha256:d8615ee34d01c8e0ab3f302dcdd7b32e2bcf698ba5f4809e7cc407c8cdea7717", size = 230482, upload-time = "2025-11-10T16:01:47.688Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2c/942477957fba22da7bf99131850e5ebdff66623418ab48964e78a7a8293e/pynacl-1.6.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5f5b35c1a266f8a9ad22525049280a600b19edd1f785bccd01ae838437dcf935", size = 243232, upload-time = "2025-11-10T16:01:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/bdbc0d04a53b96a765ab03aa2cf9a76ad8653d70bf1665459b9a0dedaa1c/pynacl-1.6.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d984c91fe3494793b2a1fb1e91429539c6c28e9ec8209d26d25041ec599ccf63", size = 187907, upload-time = "2025-11-10T16:01:46.328Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/3cfb3b4f3519f6ff62bf71bf1722547644bcfb1b05b8fdbdc300249ba113/pynacl-1.6.1-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:a6f9fd6d6639b1e81115c7f8ff16b8dedba1e8098d2756275d63d208b0e32021", size = 387591, upload-time = "2025-11-10T16:01:49.1Z" }, + { url = "https://files.pythonhosted.org/packages/18/21/b8a6563637799f617a3960f659513eccb3fcc655d5fc2be6e9dc6416826f/pynacl-1.6.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e49a3f3d0da9f79c1bec2aa013261ab9fa651c7da045d376bd306cf7c1792993", size = 798866, upload-time = "2025-11-10T16:01:55.688Z" }, + { url = "https://files.pythonhosted.org/packages/e8/6c/dc38033bc3ea461e05ae8f15a81e0e67ab9a01861d352ae971c99de23e7c/pynacl-1.6.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7713f8977b5d25f54a811ec9efa2738ac592e846dd6e8a4d3f7578346a841078", size = 1398001, upload-time = "2025-11-10T16:01:57.101Z" }, + { url = "https://files.pythonhosted.org/packages/9f/05/3ec0796a9917100a62c5073b20c4bce7bf0fea49e99b7906d1699cc7b61b/pynacl-1.6.1-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a3becafc1ee2e5ea7f9abc642f56b82dcf5be69b961e782a96ea52b55d8a9fc", size = 834024, upload-time = "2025-11-10T16:01:50.228Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b7/ae9982be0f344f58d9c64a1c25d1f0125c79201634efe3c87305ac7cb3e3/pynacl-1.6.1-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ce50d19f1566c391fedc8dc2f2f5be265ae214112ebe55315e41d1f36a7f0a9", size = 1436766, upload-time = "2025-11-10T16:01:51.886Z" }, + { url = "https://files.pythonhosted.org/packages/b4/51/b2ccbf89cf3025a02e044dd68a365cad593ebf70f532299f2c047d2b7714/pynacl-1.6.1-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:543f869140f67d42b9b8d47f922552d7a967e6c116aad028c9bfc5f3f3b3a7b7", size = 817275, upload-time = "2025-11-10T16:01:53.351Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6c/dd9ee8214edf63ac563b08a9b30f98d116942b621d39a751ac3256694536/pynacl-1.6.1-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a2bb472458c7ca959aeeff8401b8efef329b0fc44a89d3775cffe8fad3398ad8", size = 1401891, upload-time = "2025-11-10T16:01:54.587Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c1/97d3e1c83772d78ee1db3053fd674bc6c524afbace2bfe8d419fd55d7ed1/pynacl-1.6.1-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3206fa98737fdc66d59b8782cecc3d37d30aeec4593d1c8c145825a345bba0f0", size = 772291, upload-time = "2025-11-10T16:01:58.111Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ca/691ff2fe12f3bb3e43e8e8df4b806f6384593d427f635104d337b8e00291/pynacl-1.6.1-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:53543b4f3d8acb344f75fd4d49f75e6572fce139f4bfb4815a9282296ff9f4c0", size = 1370839, upload-time = "2025-11-10T16:01:59.252Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/06fe5389d30391fce006442246062cc35773c84fbcad0209fbbf5e173734/pynacl-1.6.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:319de653ef84c4f04e045eb250e6101d23132372b0a61a7acf91bac0fda8e58c", size = 791371, upload-time = "2025-11-10T16:02:01.075Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7a/e2bde8c9d39074a5aa046c7d7953401608d1f16f71e237f4bef3fb9d7e49/pynacl-1.6.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:262a8de6bba4aee8a66f5edf62c214b06647461c9b6b641f8cd0cb1e3b3196fe", size = 1363031, upload-time = "2025-11-10T16:02:02.656Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b6/63fd77264dae1087770a1bb414bc604470f58fbc21d83822fc9c76248076/pynacl-1.6.1-cp38-abi3-win32.whl", hash = "sha256:9fd1a4eb03caf8a2fe27b515a998d26923adb9ddb68db78e35ca2875a3830dde", size = 226585, upload-time = "2025-11-10T16:02:07.116Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/b419180f3fdb72ab4d45e1d88580761c267c7ca6eda9a20dcbcba254efe6/pynacl-1.6.1-cp38-abi3-win_amd64.whl", hash = "sha256:a569a4069a7855f963940040f35e87d8bc084cb2d6347428d5ad20550a0a1a21", size = 238923, upload-time = "2025-11-10T16:02:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/35/76/c34426d532e4dce7ff36e4d92cb20f4cbbd94b619964b93d24e8f5b5510f/pynacl-1.6.1-cp38-abi3-win_arm64.whl", hash = "sha256:5953e8b8cfadb10889a6e7bd0f53041a745d1b3d30111386a1bb37af171e6daf", size = 183970, upload-time = "2025-11-10T16:02:05.786Z" }, +] + +[[package]] +name = "pyrfc3339" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/52/75ea0ae249ba885c9429e421b4f94bc154df68484847f1ac164287d978d7/pyRFC3339-1.1.tar.gz", hash = "sha256:81b8cbe1519cdb79bed04910dd6fa4e181faf8c88dff1e1b987b5f7ab23a5b1a", size = 5290, upload-time = "2018-06-11T00:26:31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/7a/725f5c16756ec6211b1e7eeac09f469084595513917ea069bc023c40a5e2/pyRFC3339-1.1-py2.py3-none-any.whl", hash = "sha256:67196cb83b470709c580bb4738b83165e67c6cc60e1f2e4f286cfcb402a926f4", size = 5669, upload-time = "2018-06-11T00:22:40.934Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/55/cccfca45157a2031dcbb5a462a67f7cf27f8b37d4b3b1cd7438f0f5c1df6/ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3", size = 5587844, upload-time = "2025-11-06T22:07:45.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/b9/67240254166ae1eaa38dec32265e9153ac53645a6c6670ed36ad00722af8/ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518", size = 12606781, upload-time = "2025-11-06T22:07:01.841Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/09b3ab245d8652eafe5256ab59718641429f68681ee713ff06c5c549f156/ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4", size = 12946765, upload-time = "2025-11-06T22:07:05.858Z" }, + { url = "https://files.pythonhosted.org/packages/14/bb/1564b000219144bf5eed2359edc94c3590dd49d510751dad26202c18a17d/ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33", size = 11928120, upload-time = "2025-11-06T22:07:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/a3/92/d5f1770e9988cc0742fefaa351e840d9aef04ec24ae1be36f333f96d5704/ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2", size = 12370877, upload-time = "2025-11-06T22:07:10.015Z" }, + { url = "https://files.pythonhosted.org/packages/e2/29/e9282efa55f1973d109faf839a63235575519c8ad278cc87a182a366810e/ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5", size = 12408538, upload-time = "2025-11-06T22:07:13.085Z" }, + { url = "https://files.pythonhosted.org/packages/8e/01/930ed6ecfce130144b32d77d8d69f5c610e6d23e6857927150adf5d7379a/ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e", size = 13141942, upload-time = "2025-11-06T22:07:15.386Z" }, + { url = "https://files.pythonhosted.org/packages/6a/46/a9c89b42b231a9f487233f17a89cbef9d5acd538d9488687a02ad288fa6b/ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8", size = 14544306, upload-time = "2025-11-06T22:07:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/78/96/9c6cf86491f2a6d52758b830b89b78c2ae61e8ca66b86bf5a20af73d20e6/ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649", size = 14210427, upload-time = "2025-11-06T22:07:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/71/f4/0666fe7769a54f63e66404e8ff698de1dcde733e12e2fd1c9c6efb689cb5/ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850", size = 13658488, upload-time = "2025-11-06T22:07:22.32Z" }, + { url = "https://files.pythonhosted.org/packages/ee/79/6ad4dda2cfd55e41ac9ed6d73ef9ab9475b1eef69f3a85957210c74ba12c/ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5", size = 13354908, upload-time = "2025-11-06T22:07:24.347Z" }, + { url = "https://files.pythonhosted.org/packages/b5/60/f0b6990f740bb15c1588601d19d21bcc1bd5de4330a07222041678a8e04f/ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132", size = 13587803, upload-time = "2025-11-06T22:07:26.327Z" }, + { url = "https://files.pythonhosted.org/packages/c9/da/eaaada586f80068728338e0ef7f29ab3e4a08a692f92eb901a4f06bbff24/ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67", size = 12279654, upload-time = "2025-11-06T22:07:28.46Z" }, + { url = "https://files.pythonhosted.org/packages/66/d4/b1d0e82cf9bf8aed10a6d45be47b3f402730aa2c438164424783ac88c0ed/ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469", size = 12357520, upload-time = "2025-11-06T22:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/53e2b42cc82804617e5c7950b7079d79996c27e99c4652131c6a1100657f/ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde", size = 12719431, upload-time = "2025-11-06T22:07:33.831Z" }, + { url = "https://files.pythonhosted.org/packages/a2/94/80e3d74ed9a72d64e94a7b7706b1c1ebaa315ef2076fd33581f6a1cd2f95/ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349", size = 13464394, upload-time = "2025-11-06T22:07:35.905Z" }, + { url = "https://files.pythonhosted.org/packages/54/1a/a49f071f04c42345c793d22f6cf5e0920095e286119ee53a64a3a3004825/ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff", size = 12493429, upload-time = "2025-11-06T22:07:38.43Z" }, + { url = "https://files.pythonhosted.org/packages/bc/22/e58c43e641145a2b670328fb98bc384e20679b5774258b1e540207580266/ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c", size = 13635380, upload-time = "2025-11-06T22:07:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/30/bd/4168a751ddbbf43e86544b4de8b5c3b7be8d7167a2a5cb977d274e04f0a1/ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb", size = 12663065, upload-time = "2025-11-06T22:07:42.603Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "stevedore" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/5f/8418daad5c353300b7661dd8ce2574b0410a6316a8be650a189d5c68d938/stevedore-5.5.0.tar.gz", hash = "sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73", size = 513878, upload-time = "2025-08-25T12:54:26.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/c5/0c06759b95747882bb50abda18f5fb48c3e9b0fbfc6ebc0e23550b52415d/stevedore-5.5.0-py3-none-any.whl", hash = "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf", size = 49518, upload-time = "2025-08-25T12:54:25.445Z" }, +] + +[[package]] +name = "toposort" +version = "1.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/19/8e955d90985ecbd3b9adb2a759753a6840da2dff3c569d412b2c9217678b/toposort-1.10.tar.gz", hash = "sha256:bfbb479c53d0a696ea7402601f4e693c97b0367837c8898bc6471adfca37a6bd", size = 11132, upload-time = "2023-02-27T13:59:51.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/17/57b444fd314d5e1593350b9a31d000e7411ba8e17ce12dc7ad54ca76b810/toposort-1.10-py3-none-any.whl", hash = "sha256:cbdbc0d0bee4d2695ab2ceec97fe0679e9c10eab4b2a87a9372b929e70563a87", size = 8500, upload-time = "2023-02-25T20:07:06.538Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From a8f2ce93fbb29dde5149afdc7cbb9cc409e3db79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 23:54:18 +0000 Subject: [PATCH 04/48] Add OAuth library declaration and prepare for requirer class - Declare hydra.oauth library in charmcraft.yaml - Add placeholder for OAuth requirer initialization in charm - Update oauth.py with comments showing how to use the library - Keep temporary implementation until library is fetched locally Co-authored-by: Thanhphan1147 <42444001+Thanhphan1147@users.noreply.github.com> --- haproxy_spoe_auth_operator/charmcraft.yaml | 4 ++++ haproxy_spoe_auth_operator/src/charm.py | 3 +++ haproxy_spoe_auth_operator/src/state/oauth.py | 16 +++++++++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/haproxy_spoe_auth_operator/charmcraft.yaml b/haproxy_spoe_auth_operator/charmcraft.yaml index f75800d0c..7b7ae7b51 100644 --- a/haproxy_spoe_auth_operator/charmcraft.yaml +++ b/haproxy_spoe_auth_operator/charmcraft.yaml @@ -54,3 +54,7 @@ config: default: "127.0.0.1:3000" type: string description: Address for SPOE agent to listen on + +charm-libs: +- lib: hydra.oauth + version: "0" diff --git a/haproxy_spoe_auth_operator/src/charm.py b/haproxy_spoe_auth_operator/src/charm.py index 0725ad13c..678881eb4 100644 --- a/haproxy_spoe_auth_operator/src/charm.py +++ b/haproxy_spoe_auth_operator/src/charm.py @@ -36,6 +36,9 @@ def __init__(self, *args: typing.Any): super().__init__(*args) self.service = SpoeAuthService() + # OAuth requirer will be added here once the library is fetched + # Example: self.oauth = OAuthRequirer(self, relation_name=OAUTH_RELATION) + self.framework.observe(self.on.install, self._on_install) self.framework.observe(self.on.config_changed, self._on_config_changed) self.framework.observe( diff --git a/haproxy_spoe_auth_operator/src/state/oauth.py b/haproxy_spoe_auth_operator/src/state/oauth.py index aeab64dce..9dabd4bff 100644 --- a/haproxy_spoe_auth_operator/src/state/oauth.py +++ b/haproxy_spoe_auth_operator/src/state/oauth.py @@ -25,7 +25,7 @@ class OAuthData: Attributes: client_id: The OAuth client ID. client_secret: The OAuth client secret. - provider_url: The OAuth provider URL. + provider_url: The OAuth provider URL (issuer_url from the library). """ client_id: str = Field(min_length=1) @@ -56,6 +56,20 @@ def from_charm(cls, charm: "ops.CharmBase") -> "OAuthInformation": # type: igno Raises: OAuthDataValidationError: When OAuth data validation fails. """ + # Once the OAuth library is added, this will use the requirer object + # Example: + # if not charm.oauth.is_client_created(): + # return cls(oauth_data=None) + # + # oauth_info = charm.oauth.get_client_config() + # oauth_data = OAuthData( + # client_id=oauth_info.client_id, + # client_secret=oauth_info.client_secret, + # provider_url=oauth_info.issuer_url, + # ) + # return cls(oauth_data=oauth_data) + + # Temporary implementation until library is added oauth_relation = charm.model.get_relation("oauth") if not oauth_relation: return cls(oauth_data=None) From e9a33423209eae20ddab0c3dc42a74dfa321a4e1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:13:27 +0000 Subject: [PATCH 05/48] chore: update charm libraries (#206) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../v1/certificate_transfer.py | 270 ++-- lib/charms/operator_libs_linux/v0/apt.py | 16 +- .../v4/tls_certificates.py | 1282 ++++++++++++----- lib/charms/traefik_k8s/v1/ingress_per_unit.py | 85 +- lib/charms/traefik_k8s/v2/ingress.py | 142 +- 5 files changed, 1179 insertions(+), 616 deletions(-) diff --git a/lib/charms/certificate_transfer_interface/v1/certificate_transfer.py b/lib/charms/certificate_transfer_interface/v1/certificate_transfer.py index 3658bbaa3..af756b94c 100644 --- a/lib/charms/certificate_transfer_interface/v1/certificate_transfer.py +++ b/lib/charms/certificate_transfer_interface/v1/certificate_transfer.py @@ -124,12 +124,14 @@ def _on_certificates_removed(self, event: CertificatesRemovedEvent): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 12 +LIBPATCH = 13 logger = logging.getLogger(__name__) PYDEPS = ["pydantic"] +IS_PYDANTIC_V1 = int(pydantic.version.VERSION.split(".")[0]) < 2 + class TLSCertificatesError(Exception): """Base class for custom errors raised by this library.""" @@ -139,10 +141,13 @@ class DataValidationError(TLSCertificatesError): """Raised when data validation fails.""" -if int(pydantic.version.VERSION.split(".")[0]) < 2: +class DatabagModel(pydantic.BaseModel): + """Base databag model. + + Supports both pydantic v1 and v2. + """ - class DatabagModel(pydantic.BaseModel): # type: ignore - """Base databag model.""" + if IS_PYDANTIC_V1: class Config: """Pydantic config.""" @@ -155,122 +160,117 @@ class Config: _NEST_UNDER = None - @classmethod - def load(cls, databag: MutableMapping): - """Load this model from a Juju databag.""" - if cls._NEST_UNDER: - return cls.parse_obj(json.loads(databag[cls._NEST_UNDER])) - - try: - data = { - k: json.loads(v) - for k, v in databag.items() - # Don't attempt to parse model-external values - if k in {f.alias for f in cls.__fields__.values()} - } - except json.JSONDecodeError as e: - msg = f"invalid databag contents: expecting json. {databag}" - logger.error(msg) - raise DataValidationError(msg) from e - - try: - return cls.parse_raw(json.dumps(data)) # type: ignore - except pydantic.ValidationError as e: - msg = f"failed to validate databag: {databag}" - logger.debug(msg, exc_info=True) - raise DataValidationError(msg) from e - - def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): - """Write the contents of this model to Juju databag. - - :param databag: the databag to write the data to. - :param clear: ensure the databag is cleared before writing it. - """ - if clear and databag: - databag.clear() - - if databag is None: - databag = {} - - if self._NEST_UNDER: - databag[self._NEST_UNDER] = self.json(by_alias=True, exclude_defaults=False) - return databag - - dct = json.loads(self.json(by_alias=True, exclude_defaults=False)) - databag.update({k: json.dumps(v) for k, v in dct.items()}) + model_config = pydantic.ConfigDict( + # tolerate additional keys in databag + extra="ignore", + # Allow instantiating this class by field name (instead of forcing alias). + populate_by_name=True, + # Custom config key: whether to nest the whole datastructure (as json) + # under a field or spread it out at the toplevel. + _NEST_UNDER=None, + ) # type: ignore + """Pydantic config.""" + + @classmethod + def load(cls, databag: MutableMapping): + """Load this model from a Juju databag.""" + if IS_PYDANTIC_V1: + return cls._load_v1(databag) + nest_under = cls.model_config.get("_NEST_UNDER") + if nest_under: + return cls.model_validate(json.loads(databag[nest_under])) + + try: + data = { + k: json.loads(v) + for k, v in databag.items() + # Don't attempt to parse model-external values + if k in {(f.alias or n) for n, f in cls.model_fields.items()} + } + except json.JSONDecodeError as e: + msg = f"invalid databag contents: expecting json. {databag}" + logger.error(msg) + raise DataValidationError(msg) from e + + try: + return cls.model_validate_json(json.dumps(data)) + except pydantic.ValidationError as e: + msg = f"failed to validate databag: {databag}" + logger.debug(msg, exc_info=True) + raise DataValidationError(msg) from e + + @classmethod + def _load_v1(cls, databag: MutableMapping): + """Load implementation for pydantic v1.""" + if cls._NEST_UNDER: + return cls.parse_obj(json.loads(databag[cls._NEST_UNDER])) + + try: + data = { + k: json.loads(v) + for k, v in databag.items() + # Don't attempt to parse model-external values + if k in {f.alias for f in cls.__fields__.values()} + } + except json.JSONDecodeError as e: + msg = f"invalid databag contents: expecting json. {databag}" + logger.error(msg) + raise DataValidationError(msg) from e + + try: + return cls.parse_raw(json.dumps(data)) # type: ignore + except pydantic.ValidationError as e: + msg = f"failed to validate databag: {databag}" + logger.debug(msg, exc_info=True) + raise DataValidationError(msg) from e + + def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): + """Write the contents of this model to Juju databag. + Args: + databag: The databag to write to. + clear: Whether to clear the databag before writing. + + Returns: + MutableMapping: The databag. + """ + if IS_PYDANTIC_V1: + return self._dump_v1(databag, clear) + if clear and databag: + databag.clear() + + if databag is None: + databag = {} + nest_under = self.model_config.get("_NEST_UNDER") + if nest_under: + databag[nest_under] = self.model_dump_json( + by_alias=True, + # skip keys whose values are default + exclude_defaults=True, + ) return databag -else: - - class DatabagModel(pydantic.BaseModel): - """Base databag model.""" - - model_config = pydantic.ConfigDict( - # tolerate additional keys in databag - extra="ignore", - # Allow instantiating this class by field name (instead of forcing alias). - populate_by_name=True, - # Custom config key: whether to nest the whole datastructure (as json) - # under a field or spread it out at the toplevel. - _NEST_UNDER=None, - ) # type: ignore - """Pydantic config.""" - - @classmethod - def load(cls, databag: MutableMapping): - """Load this model from a Juju databag.""" - nest_under = cls.model_config.get("_NEST_UNDER") - if nest_under: - return cls.model_validate(json.loads(databag[nest_under])) - - try: - data = { - k: json.loads(v) - for k, v in databag.items() - # Don't attempt to parse model-external values - if k in {(f.alias or n) for n, f in cls.model_fields.items()} - } - except json.JSONDecodeError as e: - msg = f"invalid databag contents: expecting json. {databag}" - logger.error(msg) - raise DataValidationError(msg) from e - - try: - return cls.model_validate_json(json.dumps(data)) - except pydantic.ValidationError as e: - msg = f"failed to validate databag: {databag}" - logger.debug(msg, exc_info=True) - raise DataValidationError(msg) from e - - def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): - """Write the contents of this model to Juju databag. - - Args: - databag: The databag to write to. - clear: Whether to clear the databag before writing. - - Returns: - MutableMapping: The databag. - """ - if clear and databag: - databag.clear() - - if databag is None: - databag = {} - nest_under = self.model_config.get("_NEST_UNDER") - if nest_under: - databag[nest_under] = self.model_dump_json( - by_alias=True, - # skip keys whose values are default - exclude_defaults=True, - ) - return databag + dct = self.model_dump(mode="json", by_alias=True, exclude_defaults=False) + databag.update({k: json.dumps(v) for k, v in dct.items()}) + return databag + + def _dump_v1(self, databag: Optional[MutableMapping] = None, clear: bool = True): + """Dump implementation for pydantic v1.""" + if clear and databag: + databag.clear() - dct = self.model_dump(mode="json", by_alias=True, exclude_defaults=False) - databag.update({k: json.dumps(v) for k, v in dct.items()}) + if databag is None: + databag = {} + + if self._NEST_UNDER: + databag[self._NEST_UNDER] = self.json(by_alias=True, exclude_defaults=False) return databag + dct = json.loads(self.json(by_alias=True, exclude_defaults=False)) + databag.update({k: json.dumps(v) for k, v in dct.items()}) + + return databag + class ProviderApplicationData(DatabagModel): """Provider App databag model.""" @@ -337,10 +337,16 @@ def add_certificates(self, certificates: Set[str], relation_id: Optional[int] = return relations = self._get_active_relations(relation_id) if not relations: - logger.warning( - "At least 1 matching relation ID not found with the relation name '%s'", - self.relationship_name, - ) + if relation_id is not None: + logger.warning( + "At least 1 matching relation ID not found with the relation name '%s'", + self.relationship_name, + ) + else: + logger.debug( + "No active relations found with the relation name '%s'", + self.relationship_name, + ) return for relation in relations: @@ -364,10 +370,16 @@ def remove_all_certificates(self, relation_id: Optional[int] = None) -> None: return relations = self._get_active_relations(relation_id) if not relations: - logger.warning( - "At least 1 matching relation ID not found with the relation name '%s'", - self.relationship_name, - ) + if relation_id is not None: + logger.warning( + "At least 1 matching relation ID not found with the relation name '%s'", + self.relationship_name, + ) + else: + logger.debug( + "No active relations found with the relation name '%s'", + self.relationship_name, + ) return for relation in relations: @@ -394,10 +406,16 @@ def remove_certificate( return relations = self._get_active_relations(relation_id) if not relations: - logger.warning( - "At least 1 matching relation ID not found with the relation name '%s'", - self.relationship_name, - ) + if relation_id is not None: + logger.warning( + "At least 1 matching relation ID not found with the relation name '%s'", + self.relationship_name, + ) + else: + logger.debug( + "No active relations found with the relation name '%s'", + self.relationship_name, + ) return for relation in relations: diff --git a/lib/charms/operator_libs_linux/v0/apt.py b/lib/charms/operator_libs_linux/v0/apt.py index edd18dcdb..b845a37f5 100644 --- a/lib/charms/operator_libs_linux/v0/apt.py +++ b/lib/charms/operator_libs_linux/v0/apt.py @@ -12,7 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Abstractions for the system's Debian/Ubuntu package information and repositories. +"""Legacy Charmhub-hosted snap library, deprecated in favour of ``charmlibs.apt``. + +WARNING: This library is deprecated and will no longer receive feature updates or bugfixes. +``charmlibs.apt`` version 1.0 is a bug-for-bug compatible migration of this library. +Add 'charmlibs-apt~=1.0' to your charm's dependencies, and remove this Charmhub-hosted library. +Then replace `from charms.operator_libs_linux.v0 import apt` with `from charmlibs import apt`. +Read more: +- https://documentation.ubuntu.com/charmlibs +- https://pypi.org/project/charmlibs-apt + +--- + +Abstractions for the system's Debian/Ubuntu package information and repositories. This module contains abstractions and wrappers around Debian/Ubuntu-style repositories and packages, in order to easily provide an idiomatic and Pythonic mechanism for adding packages and/or @@ -133,7 +145,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 19 +LIBPATCH = 20 PYDEPS = ["opentelemetry-api"] diff --git a/lib/charms/tls_certificates_interface/v4/tls_certificates.py b/lib/charms/tls_certificates_interface/v4/tls_certificates.py index 2e422ff37..a0b02f76d 100644 --- a/lib/charms/tls_certificates_interface/v4/tls_certificates.py +++ b/lib/charms/tls_certificates_interface/v4/tls_certificates.py @@ -1,7 +1,26 @@ # Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. -"""Charm library for managing TLS certificates (V4). +"""Legacy Charmhub-hosted lib, deprecated in favour of ``charmlibs.interfaces.tls_certificates``. + +WARNING: This library is deprecated. +It will not receive feature updates or bugfixes. +``charmlibs.interfaces.tls_certificates`` 1.0 is a bug-for-bug compatible migration of this library. + +To migrate: +1. Add 'charmlibs-interfaces-tls-certificates~=1.0' to your charm's dependencies, + and remove this Charmhub-hosted library from your charm. +2. You can also remove any dependencies added to your charm only because of this library. +3. Replace `from charms.tls_certificates_interface.v4 import tls_certificates` + with `from charmlibs.interfaces import tls_certificates`. + +Read more: +- https://documentation.ubuntu.com/charmlibs +- https://pypi.org/project/charmlibs-interfaces-tls-certificates + +--- + +Charm library for managing TLS certificates (V4). This library contains the Requires and Provides classes for handling the tls-certificates interface. @@ -21,17 +40,29 @@ import json import logging import uuid +import warnings from contextlib import suppress -from dataclasses import dataclass +from dataclasses import asdict, dataclass, field from datetime import datetime, timedelta, timezone from enum import Enum -from typing import FrozenSet, List, MutableMapping, Optional, Tuple, Union +from typing import ( + Collection, + Dict, + FrozenSet, + List, + MutableMapping, + Optional, + Set, + Tuple, + Union, +) import pydantic from cryptography import x509 from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.asymmetric.types import CertificateIssuerPrivateKeyTypes from cryptography.x509.oid import ExtensionOID, NameOID from ops import BoundEvent, CharmBase, CharmEvents, Secret, SecretExpiredEvent, SecretRemoveEvent from ops.framework import EventBase, EventSource, Handle, Object @@ -46,7 +77,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 22 +LIBPATCH = 25 PYDEPS = [ "cryptography>=43.0.0", @@ -56,6 +87,48 @@ logger = logging.getLogger(__name__) +NESTED_JSON_KEY = "owasp_event" + + +@dataclass +class _OWASPLogEvent: + """OWASP-compliant log event.""" + + datetime: str + event: str + level: str + description: str + type: str = "security" + labels: Dict[str, str] = field(default_factory=dict) + + def to_json(self) -> str: + return json.dumps(self.to_dict(), ensure_ascii=False) + + def to_dict(self) -> Dict: + log_event = dict(asdict(self), **self.labels) + log_event.pop("labels", None) + return {k: v for k, v in log_event.items() if v is not None} + + +class _OWASPLogger: + """OWASP-compliant logger for security events.""" + + def __init__(self, application: Optional[str] = None): + self.application = application + self._logger = logging.getLogger(__name__) + + def log_event(self, event: str, level: int, description: str, **labels: str): + if self.application and "application" not in labels: + labels["application"] = self.application + log = _OWASPLogEvent( + datetime=datetime.now(timezone.utc).astimezone().isoformat(), + event=event, + level=logging.getLevelName(level), + description=description, + labels=labels, + ) + self._logger.log(level, log.to_json(), extra={NESTED_JSON_KEY: log.to_dict()}) + class TLSCertificatesError(Exception): """Base class for custom errors raised by this library.""" @@ -258,34 +331,56 @@ class Mode(Enum): APP = 2 -@dataclass(frozen=True) class PrivateKey: """This class represents a private key.""" - raw: str + def __init__( + self, raw: Optional[str] = None, x509_object: Optional[rsa.RSAPrivateKey] = None + ) -> None: + """Initialize the PrivateKey object. + + If both raw and x509_object are provided, x509_object takes precedence. + """ + if x509_object: + self._private_key = x509_object + elif raw: + self._private_key = serialization.load_pem_private_key( + raw.encode(), + password=None, + ) + else: + raise ValueError("Either raw private key string or x509_object must be provided") + + @property + def raw(self) -> str: + """Return the PEM-formatted string representation of the private key.""" + return str(self) def __str__(self): - """Return the private key as a string.""" - return self.raw + """Return the private key as a string in PEM format.""" + return ( + self._private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + .decode() + .strip() + ) @classmethod def from_string(cls, private_key: str) -> "PrivateKey": """Create a PrivateKey object from a private key.""" - return cls(raw=private_key.strip()) + return cls(raw=private_key) def is_valid(self) -> bool: """Validate that the private key is PEM-formatted, RSA, and at least 2048 bits.""" try: - key = serialization.load_pem_private_key( - self.raw.encode(), - password=None, - ) - - if not isinstance(key, rsa.RSAPrivateKey): + if not isinstance(self._private_key, rsa.RSAPrivateKey): logger.warning("Private key is not an RSA key") return False - if key.key_size < 2048: + if self._private_key.key_size < 2048: logger.warning("RSA key size is less than 2048 bits") return False @@ -294,29 +389,180 @@ def is_valid(self) -> bool: logger.warning("Invalid private key format") return False + @classmethod + def generate(cls, key_size: int = 2048, public_exponent: int = 65537) -> "PrivateKey": + """Generate a new RSA private key. + + Args: + key_size: The size of the key in bits. + public_exponent: The public exponent of the key. + + Returns: + PrivateKey: The generated private key. + """ + private_key = rsa.generate_private_key( + public_exponent=public_exponent, + key_size=key_size, + ) + _OWASPLogger().log_event( + event="private_key_generated", + level=logging.INFO, + description="Private key generated", + key_size=str(key_size), + ) + return PrivateKey(x509_object=private_key) + + def __eq__(self, other: object) -> bool: + """Check if two PrivateKey objects are equal.""" + if not isinstance(other, PrivateKey): + return NotImplemented + return self.raw == other.raw + -@dataclass(frozen=True) class Certificate: """This class represents a certificate.""" - raw: str - common_name: str - expiry_time: datetime - validity_start_time: datetime - is_ca: bool = False - sans_dns: Optional[FrozenSet[str]] = frozenset() - sans_ip: Optional[FrozenSet[str]] = frozenset() - sans_oid: Optional[FrozenSet[str]] = frozenset() - email_address: Optional[str] = None - organization: Optional[str] = None - organizational_unit: Optional[str] = None - country_name: Optional[str] = None - state_or_province_name: Optional[str] = None - locality_name: Optional[str] = None + _cert: x509.Certificate + + def __init__( + self, + raw: Optional[str] = None, # Must remain first argument for backwards compatibility + # Old Interface fields (ignored) + common_name: Optional[str] = None, + expiry_time: Optional[datetime] = None, + validity_start_time: Optional[datetime] = None, + is_ca: Optional[bool] = None, + sans_dns: Optional[Set[str]] = None, + sans_ip: Optional[Set[str]] = None, + sans_oid: Optional[Set[str]] = None, + email_address: Optional[str] = None, + organization: Optional[str] = None, + organizational_unit: Optional[str] = None, + country_name: Optional[str] = None, + state_or_province_name: Optional[str] = None, + locality_name: Optional[str] = None, + # End Old Interface fields + x509_object: Optional[x509.Certificate] = None, + ) -> None: + """Initialize the Certificate object. + + This initializer must maintain the old interface while also allowing + instantiation from an existing x509_object. It ignores all fields + other than raw and x509_object, preferring x509_object. + """ + if x509_object: + self._cert = x509_object + elif raw: + self._cert = x509.load_pem_x509_certificate(data=raw.encode()) + else: + raise ValueError("Either raw certificate string or x509_object must be provided") + + @property + def raw(self) -> str: + """Return the PEM-formatted string representation of the certificate.""" + return str(self) + + @property + def common_name(self) -> str: + """Return the common name of the certificate.""" + # We maintain compatibility with the old interface by returning + # an empty string if no common name is set. + common_name = self._cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME) + return str(common_name[0].value) if common_name else "" + + @property + def expiry_time(self) -> datetime: + """Return the expiry time of the certificate.""" + return self._cert.not_valid_after_utc + + @property + def validity_start_time(self) -> datetime: + """Return the validity start time of the certificate.""" + return self._cert.not_valid_before_utc + + @property + def is_ca(self) -> bool: + """Return whether the certificate is a CA certificate.""" + try: + return self._cert.extensions.get_extension_for_oid( + ExtensionOID.BASIC_CONSTRAINTS + ).value.ca # type: ignore[reportAttributeAccessIssue] + except x509.ExtensionNotFound: + return False + + @property + def sans_dns(self) -> Optional[Set[str]]: + """Return the DNS Subject Alternative Names of the certificate.""" + with suppress(x509.ExtensionNotFound): + sans = self._cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + return {str(san) for san in sans.get_values_for_type(x509.DNSName)} + return None + + @property + def sans_ip(self) -> Optional[Set[str]]: + """Return the IP Subject Alternative Names of the certificate.""" + with suppress(x509.ExtensionNotFound): + sans = self._cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + return {str(san) for san in sans.get_values_for_type(x509.IPAddress)} + return None + + @property + def sans_oid(self) -> Optional[Set[str]]: + """Return the OID Subject Alternative Names of the certificate.""" + with suppress(x509.ExtensionNotFound): + sans = self._cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + return {str(san.dotted_string) for san in sans.get_values_for_type(x509.RegisteredID)} + return None + + @property + def email_address(self) -> Optional[str]: + """Return the email address of the certificate.""" + email_address = self._cert.subject.get_attributes_for_oid(NameOID.EMAIL_ADDRESS) + return str(email_address[0].value) if email_address else None + + @property + def organization(self) -> Optional[str]: + """Return the organization name of the certificate.""" + organization = self._cert.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME) + return str(organization[0].value) if organization else None + + @property + def organizational_unit(self) -> Optional[str]: + """Return the organizational unit name of the certificate.""" + organizational_unit = self._cert.subject.get_attributes_for_oid( + NameOID.ORGANIZATIONAL_UNIT_NAME + ) + return str(organizational_unit[0].value) if organizational_unit else None + + @property + def country_name(self) -> Optional[str]: + """Return the country name of the certificate.""" + country_name = self._cert.subject.get_attributes_for_oid(NameOID.COUNTRY_NAME) + return str(country_name[0].value) if country_name else None + + @property + def state_or_province_name(self) -> Optional[str]: + """Return the state or province name of the certificate.""" + state_or_province_name = self._cert.subject.get_attributes_for_oid( + NameOID.STATE_OR_PROVINCE_NAME + ) + return str(state_or_province_name[0].value) if state_or_province_name else None + + @property + def locality_name(self) -> Optional[str]: + """Return the locality name of the certificate.""" + locality_name = self._cert.subject.get_attributes_for_oid(NameOID.LOCALITY_NAME) + return str(locality_name[0].value) if locality_name else None def __str__(self) -> str: """Return the certificate as a string.""" - return self.raw + return self._cert.public_bytes(serialization.Encoding.PEM).decode().strip() + + def __eq__(self, other: object) -> bool: + """Check if two Certificate objects are equal.""" + if not isinstance(other, Certificate): + return NotImplemented + return self.raw == other.raw @classmethod def from_string(cls, certificate: str) -> "Certificate": @@ -327,66 +573,7 @@ def from_string(cls, certificate: str) -> "Certificate": logger.error("Could not load certificate: %s", e) raise TLSCertificatesError("Could not load certificate") - common_name = certificate_object.subject.get_attributes_for_oid(NameOID.COMMON_NAME) - country_name = certificate_object.subject.get_attributes_for_oid(NameOID.COUNTRY_NAME) - state_or_province_name = certificate_object.subject.get_attributes_for_oid( - NameOID.STATE_OR_PROVINCE_NAME - ) - locality_name = certificate_object.subject.get_attributes_for_oid(NameOID.LOCALITY_NAME) - organization_name = certificate_object.subject.get_attributes_for_oid( - NameOID.ORGANIZATION_NAME - ) - organizational_unit = certificate_object.subject.get_attributes_for_oid( - NameOID.ORGANIZATIONAL_UNIT_NAME - ) - email_address = certificate_object.subject.get_attributes_for_oid(NameOID.EMAIL_ADDRESS) - sans_dns: List[str] = [] - sans_ip: List[str] = [] - sans_oid: List[str] = [] - try: - sans = certificate_object.extensions.get_extension_for_class( - x509.SubjectAlternativeName - ).value - for san in sans: - if isinstance(san, x509.DNSName): - sans_dns.append(san.value) - if isinstance(san, x509.IPAddress): - sans_ip.append(str(san.value)) - if isinstance(san, x509.RegisteredID): - sans_oid.append(str(san.value)) - except x509.ExtensionNotFound: - logger.debug("No SANs found in certificate") - sans_dns = [] - sans_ip = [] - sans_oid = [] - expiry_time = certificate_object.not_valid_after_utc - validity_start_time = certificate_object.not_valid_before_utc - is_ca = False - try: - is_ca = certificate_object.extensions.get_extension_for_oid( - ExtensionOID.BASIC_CONSTRAINTS - ).value.ca # type: ignore[reportAttributeAccessIssue] - except x509.ExtensionNotFound: - pass - - return cls( - raw=certificate.strip(), - common_name=str(common_name[0].value), - is_ca=is_ca, - country_name=str(country_name[0].value) if country_name else None, - state_or_province_name=str(state_or_province_name[0].value) - if state_or_province_name - else None, - locality_name=str(locality_name[0].value) if locality_name else None, - organization=str(organization_name[0].value) if organization_name else None, - organizational_unit=str(organizational_unit[0].value) if organizational_unit else None, - email_address=str(email_address[0].value) if email_address else None, - sans_dns=frozenset(sans_dns), - sans_ip=frozenset(sans_ip), - sans_oid=frozenset(sans_oid), - expiry_time=expiry_time, - validity_start_time=validity_start_time, - ) + return cls(x509_object=certificate_object) def matches_private_key(self, private_key: PrivateKey) -> bool: """Check if this certificate matches a given private key. @@ -398,13 +585,8 @@ def matches_private_key(self, private_key: PrivateKey) -> bool: bool: True if the certificate matches the private key, False otherwise. """ try: - cert_object = x509.load_pem_x509_certificate(self.raw.encode()) - key_object = serialization.load_pem_private_key( - private_key.raw.encode(), password=None - ) - - cert_public_key = cert_object.public_key() - key_public_key = key_object.public_key() + cert_public_key = self._cert.public_key() + key_public_key = private_key._private_key.public_key() if not isinstance(cert_public_key, rsa.RSAPublicKey): logger.warning("Certificate does not use RSA public key") @@ -419,84 +601,299 @@ def matches_private_key(self, private_key: PrivateKey) -> bool: logger.warning("Failed to validate certificate and private key match: %s", e) return False + @classmethod + def generate( + cls, + csr: "CertificateSigningRequest", + ca: "Certificate", + ca_private_key: "PrivateKey", + validity: timedelta, + is_ca: bool = False, + ) -> "Certificate": + """Generate a certificate from a CSR signed by the given CA and CA private key. -@dataclass(frozen=True) -class CertificateSigningRequest: - """This class represents a certificate signing request.""" - - raw: str - common_name: str - sans_dns: Optional[FrozenSet[str]] = None - sans_ip: Optional[FrozenSet[str]] = None - sans_oid: Optional[FrozenSet[str]] = None - email_address: Optional[str] = None - organization: Optional[str] = None - organizational_unit: Optional[str] = None - country_name: Optional[str] = None - state_or_province_name: Optional[str] = None - locality_name: Optional[str] = None - has_unique_identifier: bool = False + Args: + csr: The certificate signing request. + ca: The CA certificate. + ca_private_key: The CA private key. + validity: The validity period of the certificate. + is_ca: Whether the generated certificate is a CA certificate. - def __eq__(self, other: object) -> bool: - """Check if two CertificateSigningRequest objects are equal.""" - if not isinstance(other, CertificateSigningRequest): - return NotImplemented - return self.raw.strip() == other.raw.strip() + Returns: + Certificate: The generated certificate. + """ + # Ideally, this would be the constructor, but we can't add new + # required parameters to the constructor without breaking backwards + # compatibility. + private_key = serialization.load_pem_private_key( + str(ca_private_key).encode(), password=None + ) + assert isinstance(private_key, CertificateIssuerPrivateKeyTypes) + + # Create a certificate builder + cert_builder = x509.CertificateBuilder( + subject_name=csr._csr.subject, + # issuer_name=ca._cert.subject, # TODO: Validate this is correct, the old code used `issuer` + issuer_name=ca._cert.issuer, + public_key=csr._csr.public_key(), + serial_number=x509.random_serial_number(), + not_valid_before=datetime.now(timezone.utc), + not_valid_after=datetime.now(timezone.utc) + validity, + ) + extensions = _generate_certificate_request_extensions( + authority_key_identifier=ca._cert.extensions.get_extension_for_class( + x509.SubjectKeyIdentifier + ).value.key_identifier, + csr=csr._csr, + is_ca=is_ca, + ) + for extension in extensions: + try: + cert_builder = cert_builder.add_extension(extension.value, extension.critical) + except ValueError as e: + logger.error("Could not add extension to certificate: %s", e) + raise TLSCertificatesError("Could not add extension to certificate") from e + + # Sign the certificate with the CA's private key + cert = cert_builder.sign(private_key=private_key, algorithm=hashes.SHA256()) + _OWASPLogger().log_event( + event="certificate_generated", + level=logging.INFO, + description="Certificate generated from CSR", + common_name=csr.common_name, + is_ca=str(is_ca), + validity_days=str(validity.days), + ) - def __str__(self) -> str: - """Return the CSR as a string.""" - return self.raw + return cls(x509_object=cert) @classmethod - def from_string(cls, csr: str) -> "CertificateSigningRequest": - """Create a CertificateSigningRequest object from a CSR.""" - try: - csr_object = x509.load_pem_x509_csr(csr.encode()) - except ValueError as e: - logger.error("Could not load CSR: %s", e) - raise TLSCertificatesError("Could not load CSR") - common_name = csr_object.subject.get_attributes_for_oid(NameOID.COMMON_NAME) - country_name = csr_object.subject.get_attributes_for_oid(NameOID.COUNTRY_NAME) - state_or_province_name = csr_object.subject.get_attributes_for_oid( - NameOID.STATE_OR_PROVINCE_NAME + def generate_self_signed_ca( + cls, + attributes: "CertificateRequestAttributes", + private_key: PrivateKey, + validity: timedelta, + ) -> "Certificate": + """Generate a self-signed CA certificate. + + Args: + attributes: The certificate request attributes. + private_key: The private key to sign the CA certificate. + validity: The validity period of the CA certificate. + + Returns: + Certificate: The generated CA certificate. + """ + assert isinstance(private_key._private_key, rsa.RSAPrivateKey) + + public_key = private_key._private_key.public_key() + + builder = x509.CertificateBuilder( + public_key=public_key, + serial_number=x509.random_serial_number(), + not_valid_before=datetime.now(timezone.utc), + not_valid_after=datetime.now(timezone.utc) + validity, ) - locality_name = csr_object.subject.get_attributes_for_oid(NameOID.LOCALITY_NAME) - organization_name = csr_object.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME) - organizational_unit = csr_object.subject.get_attributes_for_oid( + + if subject_name := _extract_subject_name_attributes(attributes): + builder = builder.subject_name(subject_name).issuer_name(subject_name) + + builder = ( + builder.add_extension( + x509.SubjectKeyIdentifier.from_public_key(public_key), critical=False + ) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + key_cert_sign=True, + key_agreement=False, + content_commitment=False, + data_encipherment=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + ) + + if san_extension := _san_extension( + email_address=attributes.email_address, + sans_dns=attributes.sans_dns, + sans_ip=attributes.sans_ip, + sans_oid=attributes.sans_oid, + ): + builder = builder.add_extension(san_extension, critical=False) + + cert = cls(x509_object=builder.sign(private_key._private_key, algorithm=hashes.SHA256())) + + _OWASPLogger().log_event( + event="ca_certificate_generated", + level=logging.INFO, + description="CA certificate generated", + common_name=cert.common_name, + validity_days=str(validity.days), + ) + + return cert + + +class CertificateSigningRequest: + """A representation of the certificate signing request.""" + + _csr: x509.CertificateSigningRequest + + def __init__( + self, + raw: Optional[str] = None, # Must remain first argument for backwards compatibility + # Old Interface fields (ignored) + common_name: Optional[str] = None, + sans_dns: Optional[Set[str]] = None, + sans_ip: Optional[Set[str]] = None, + sans_oid: Optional[Set[str]] = None, + email_address: Optional[str] = None, + organization: Optional[str] = None, + organizational_unit: Optional[str] = None, + country_name: Optional[str] = None, + state_or_province_name: Optional[str] = None, + locality_name: Optional[str] = None, + has_unique_identifier: Optional[bool] = None, + # End Old Interface fields + x509_object: Optional[x509.CertificateSigningRequest] = None, + ): + """Initialize the CertificateSigningRequest object. + + This initializer must maintain the old interface while also allowing + instantiation from an existing x509_object. It ignores all fields + other than raw and x509_object, preferring x509_object. + """ + if x509_object: + self._csr = x509_object + return + elif raw: + try: + self._csr = x509.load_pem_x509_csr(raw.encode()) + except ValueError as e: + logger.error("Could not load CSR: %s", e) + raise TLSCertificatesError("Could not load CSR") + return + raise ValueError("Either raw CSR string or x509_object must be provided") + + @property + def common_name(self) -> str: + """Return the common name of the CSR.""" + common_name = self._csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME) + return str(common_name[0].value) if common_name else "" + + @property + def sans_dns(self) -> Set[str]: + """Return the DNS Subject Alternative Names of the CSR.""" + with suppress(x509.ExtensionNotFound): + sans = self._csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + return {str(san) for san in sans.get_values_for_type(x509.DNSName)} + return set() + + @property + def sans_ip(self) -> Set[str]: + """Return the IP Subject Alternative Names of the CSR.""" + with suppress(x509.ExtensionNotFound): + sans = self._csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + return {str(san) for san in sans.get_values_for_type(x509.IPAddress)} + return set() + + @property + def sans_oid(self) -> Set[str]: + """Return the OID Subject Alternative Names of the CSR.""" + with suppress(x509.ExtensionNotFound): + sans = self._csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + return {str(san.dotted_string) for san in sans.get_values_for_type(x509.RegisteredID)} + return set() + + @property + def email_address(self) -> Optional[str]: + """Return the email address of the CSR.""" + email_address = self._csr.subject.get_attributes_for_oid(NameOID.EMAIL_ADDRESS) + return str(email_address[0].value) if email_address else None + + @property + def organization(self) -> Optional[str]: + """Return the organization name of the CSR.""" + organization = self._csr.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME) + return str(organization[0].value) if organization else None + + @property + def organizational_unit(self) -> Optional[str]: + """Return the organizational unit name of the CSR.""" + organizational_unit = self._csr.subject.get_attributes_for_oid( NameOID.ORGANIZATIONAL_UNIT_NAME ) - email_address = csr_object.subject.get_attributes_for_oid(NameOID.EMAIL_ADDRESS) - unique_identifier = csr_object.subject.get_attributes_for_oid( - NameOID.X500_UNIQUE_IDENTIFIER + return str(organizational_unit[0].value) if organizational_unit else None + + @property + def country_name(self) -> Optional[str]: + """Return the country name of the CSR.""" + country_name = self._csr.subject.get_attributes_for_oid(NameOID.COUNTRY_NAME) + return str(country_name[0].value) if country_name else None + + @property + def state_or_province_name(self) -> Optional[str]: + """Return the state or province name of the CSR.""" + state_or_province_name = self._csr.subject.get_attributes_for_oid( + NameOID.STATE_OR_PROVINCE_NAME ) - try: - sans = csr_object.extensions.get_extension_for_class(x509.SubjectAlternativeName).value - sans_dns = frozenset(sans.get_values_for_type(x509.DNSName)) - sans_ip = frozenset([str(san) for san in sans.get_values_for_type(x509.IPAddress)]) - sans_oid = frozenset( - [san.dotted_string for san in sans.get_values_for_type(x509.RegisteredID)] - ) - except x509.ExtensionNotFound: - sans = frozenset() - sans_dns = frozenset() - sans_ip = frozenset() - sans_oid = frozenset() - return cls( - raw=csr.strip(), - common_name=str(common_name[0].value), - country_name=str(country_name[0].value) if country_name else None, - state_or_province_name=str(state_or_province_name[0].value) - if state_or_province_name - else None, - locality_name=str(locality_name[0].value) if locality_name else None, - organization=str(organization_name[0].value) if organization_name else None, - organizational_unit=str(organizational_unit[0].value) if organizational_unit else None, - email_address=str(email_address[0].value) if email_address else None, - sans_dns=sans_dns, - sans_ip=sans_ip, - sans_oid=sans_oid, - has_unique_identifier=bool(unique_identifier), + return str(state_or_province_name[0].value) if state_or_province_name else None + + @property + def locality_name(self) -> Optional[str]: + """Return the locality name of the CSR.""" + locality_name = self._csr.subject.get_attributes_for_oid(NameOID.LOCALITY_NAME) + return str(locality_name[0].value) if locality_name else None + + @property + def has_unique_identifier(self) -> bool: + """Return whether the CSR has a unique identifier.""" + unique_identifier = self._csr.subject.get_attributes_for_oid( + NameOID.X500_UNIQUE_IDENTIFIER ) + return bool(unique_identifier) + + @property + def raw(self) -> str: + """Return the PEM-formatted string representation of the CSR.""" + return self.__str__() + + def __str__(self) -> str: + """Return the CSR as a string.""" + return self._csr.public_bytes(serialization.Encoding.PEM).decode().strip() + + @classmethod + def from_string(cls, csr: str) -> "CertificateSigningRequest": + """Create a CertificateSigningRequest object from a CSR.""" + return cls(raw=csr) + + @classmethod + def from_csr(cls, csr: x509.CertificateSigningRequest) -> "CertificateSigningRequest": + """Create a CertificateSigningRequest object from a CSR.""" + return cls(x509_object=csr) + + def __eq__(self, other: object) -> bool: + """Check if two CertificateSigningRequest objects are equal.""" + if not isinstance(other, CertificateSigningRequest): + return NotImplemented + return self.raw == other.raw + + def matches_certificate(self, certificate: Certificate) -> bool: + """Check if this CSR matches a given certificate. + + Args: + certificate (Certificate): The certificate to validate against. + + Returns: + bool: True if the CSR matches the certificate, False otherwise. + """ + return self._csr.public_key() == certificate._cert.public_key() def matches_private_key(self, key: PrivateKey) -> bool: """Check if a CSR matches a private key. @@ -509,12 +906,8 @@ def matches_private_key(self, key: PrivateKey) -> bool: bool: True/False depending on whether the CSR matches the private key. """ try: - csr_object = x509.load_pem_x509_csr(self.raw.encode("utf-8")) - key_object = serialization.load_pem_private_key( - data=key.raw.encode("utf-8"), password=None - ) - key_object_public_key = key_object.public_key() - csr_object_public_key = csr_object.public_key() + key_object_public_key = key._private_key.public_key() + csr_object_public_key = self._csr.public_key() if not isinstance(key_object_public_key, rsa.RSAPublicKey): logger.warning("Key is not an RSA key") return False @@ -532,82 +925,181 @@ def matches_private_key(self, key: PrivateKey) -> bool: return False return True - def matches_certificate(self, certificate: Certificate) -> bool: - """Check if a CSR matches a certificate. - - Args: - certificate (Certificate): Certificate - Returns: - bool: True/False depending on whether the CSR matches the certificate. - """ - csr_object = x509.load_pem_x509_csr(self.raw.encode("utf-8")) - cert_object = x509.load_pem_x509_certificate(certificate.raw.encode("utf-8")) - return csr_object.public_key() == cert_object.public_key() - def get_sha256_hex(self) -> str: """Calculate the hash of the provided data and return the hexadecimal representation.""" digest = hashes.Hash(hashes.SHA256()) digest.update(self.raw.encode()) return digest.finalize().hex() + def sign( + self, ca: Certificate, ca_private_key: PrivateKey, validity: timedelta, is_ca: bool = False + ) -> Certificate: + """Sign this CSR with the given CA and CA private key. -@dataclass(frozen=True) -class CertificateRequestAttributes: - """A representation of the certificate request attributes. - - This class should be used inside the requirer charm to specify the requested - attributes for the certificate. - """ - - common_name: str - sans_dns: Optional[FrozenSet[str]] = frozenset() - sans_ip: Optional[FrozenSet[str]] = frozenset() - sans_oid: Optional[FrozenSet[str]] = frozenset() - email_address: Optional[str] = None - organization: Optional[str] = None - organizational_unit: Optional[str] = None - country_name: Optional[str] = None - state_or_province_name: Optional[str] = None - locality_name: Optional[str] = None - is_ca: bool = False - add_unique_id_to_subject_name: bool = True + Args: + ca: The CA certificate. + ca_private_key: The CA private key. + validity: The validity period of the certificate. + is_ca: Whether the generated certificate is a CA certificate. - def is_valid(self) -> bool: - """Check whether the certificate request is valid.""" - if not self.common_name: - return False - return True + Returns: + Certificate: The signed certificate. + """ + return Certificate.generate( + csr=self, + ca=ca, + ca_private_key=ca_private_key, + validity=validity, + is_ca=is_ca, + ) - def generate_csr( - self, + @classmethod + def generate( + cls, + attributes: "CertificateRequestAttributes", private_key: PrivateKey, - ) -> CertificateSigningRequest: - """Generate a CSR using private key and subject. + ) -> "CertificateSigningRequest": + """Generate a CSR using the supplied attributes and private key. Args: + attributes (CertificateRequestAttributes): Certificate request attributes private_key (PrivateKey): Private key - Returns: CertificateSigningRequest: CSR """ - return generate_csr( - private_key=private_key, - common_name=self.common_name, - sans_dns=self.sans_dns, - sans_ip=self.sans_ip, - sans_oid=self.sans_oid, - email_address=self.email_address, - organization=self.organization, - organizational_unit=self.organizational_unit, - country_name=self.country_name, - state_or_province_name=self.state_or_province_name, - locality_name=self.locality_name, - add_unique_id_to_subject_name=self.add_unique_id_to_subject_name, - ) + signing_key = private_key._private_key + assert isinstance(signing_key, CertificateIssuerPrivateKeyTypes) + + csr_builder = x509.CertificateSigningRequestBuilder() + if subject_name := _extract_subject_name_attributes(attributes): + csr_builder = csr_builder.subject_name(subject_name) + + _sans: List[x509.GeneralName] = [] + if attributes.sans_oid: + _sans.extend( + [x509.RegisteredID(x509.ObjectIdentifier(san)) for san in attributes.sans_oid] + ) + if attributes.sans_ip: + _sans.extend([x509.IPAddress(ipaddress.ip_address(san)) for san in attributes.sans_ip]) + if attributes.sans_dns: + _sans.extend([x509.DNSName(san) for san in attributes.sans_dns]) + if _sans: + csr_builder = csr_builder.add_extension( + x509.SubjectAlternativeName(set(_sans)), critical=False + ) + signed_certificate_request = csr_builder.sign(signing_key, hashes.SHA256()) + return cls(x509_object=signed_certificate_request) + + +class CertificateRequestAttributes: + """A representation of the certificate request attributes.""" + + def __init__( + self, + common_name: Optional[str] = None, + sans_dns: Optional[Collection[str]] = None, + sans_ip: Optional[Collection[str]] = None, + sans_oid: Optional[Collection[str]] = None, + email_address: Optional[str] = None, + organization: Optional[str] = None, + organizational_unit: Optional[str] = None, + country_name: Optional[str] = None, + state_or_province_name: Optional[str] = None, + locality_name: Optional[str] = None, + is_ca: bool = False, + add_unique_id_to_subject_name: bool = True, + ): + if not common_name and not sans_dns and not sans_ip and not sans_oid: + raise ValueError( + "At least one of common_name, sans_dns, sans_ip, or sans_oid must be provided" + ) + self._common_name = common_name + self._sans_dns = set(sans_dns) if sans_dns else None + self._sans_ip = set(sans_ip) if sans_ip else None + self._sans_oid = set(sans_oid) if sans_oid else None + self._email_address = email_address + self._organization = organization + self._organizational_unit = organizational_unit + self._country_name = country_name + self._state_or_province_name = state_or_province_name + self._locality_name = locality_name + self._is_ca = is_ca + self._add_unique_id_to_subject_name = add_unique_id_to_subject_name + + @property + def common_name(self) -> str: + """Return the common name.""" + # For legacy interface compatibility, return empty string if not set + return self._common_name if self._common_name else "" + + @property + def sans_dns(self) -> Optional[Set[str]]: + """Return the DNS Subject Alternative Names.""" + return self._sans_dns + + @property + def sans_ip(self) -> Optional[Set[str]]: + """Return the IP Subject Alternative Names.""" + return self._sans_ip + + @property + def sans_oid(self) -> Optional[Set[str]]: + """Return the OID Subject Alternative Names.""" + return self._sans_oid + + @property + def email_address(self) -> Optional[str]: + """Return the email address.""" + return self._email_address + + @property + def organization(self) -> Optional[str]: + """Return the organization name.""" + return self._organization + + @property + def organizational_unit(self) -> Optional[str]: + """Return the organizational unit name.""" + return self._organizational_unit + + @property + def country_name(self) -> Optional[str]: + """Return the country name.""" + return self._country_name + + @property + def state_or_province_name(self) -> Optional[str]: + """Return the state or province name.""" + return self._state_or_province_name + + @property + def locality_name(self) -> Optional[str]: + """Return the locality name.""" + return self._locality_name + + @property + def is_ca(self) -> bool: + """Return whether the certificate is a CA certificate.""" + return self._is_ca + + @property + def add_unique_id_to_subject_name(self) -> bool: + """Return whether to add a unique identifier to the subject name.""" + return self._add_unique_id_to_subject_name @classmethod - def from_csr(cls, csr: CertificateSigningRequest, is_ca: bool): - """Create a CertificateRequestAttributes object from a CSR.""" + def from_csr( + cls, csr: CertificateSigningRequest, is_ca: bool + ) -> "CertificateRequestAttributes": + """Create CertificateRequestAttributes from a CertificateSigningRequest. + + Args: + csr: The CSR to extract attributes from. + is_ca: Whether a CA certificate is being requested. + + Returns: + CertificateRequestAttributes: The extracted attributes. + """ return cls( common_name=csr.common_name, sans_dns=csr.sans_dns, @@ -623,6 +1115,52 @@ def from_csr(cls, csr: CertificateSigningRequest, is_ca: bool): add_unique_id_to_subject_name=csr.has_unique_identifier, ) + def __eq__(self, other: object) -> bool: + """Check if two CertificateRequestAttributes objects are equal.""" + if not isinstance(other, CertificateRequestAttributes): + return NotImplemented + return ( + self.common_name == other.common_name + and self.sans_dns == other.sans_dns + and self.sans_ip == other.sans_ip + and self.sans_oid == other.sans_oid + and self.email_address == other.email_address + and self.organization == other.organization + and self.organizational_unit == other.organizational_unit + and self.country_name == other.country_name + and self.state_or_province_name == other.state_or_province_name + and self.locality_name == other.locality_name + and self.is_ca == other.is_ca + and self.add_unique_id_to_subject_name == other.add_unique_id_to_subject_name + ) + + def is_valid(self) -> bool: + """Validate the attributes of the certificate request. + + Returns: + bool: True if the attributes are valid, False otherwise. + """ + if not self.common_name and not self.sans_dns and not self.sans_ip and not self.sans_oid: + logger.warning( + "At least one of common_name, sans_dns, sans_ip, or sans_oid must be provided" + ) + return False + return True + + def generate_csr( + self, + private_key: PrivateKey, + ) -> CertificateSigningRequest: + """Generate a CSR using the current attributes and a private key. + + Args: + private_key (PrivateKey): Private key to sign the CSR. + + Returns: + CertificateSigningRequest: The generated CSR. + """ + return CertificateSigningRequest.generate(self, private_key) + @dataclass(frozen=True) class ProviderCertificate: @@ -715,18 +1253,11 @@ def generate_private_key( Returns: PrivateKey: Private Key """ - if key_size < 2048: - raise ValueError("Key size must be at least 2048 bits for RSA security") - private_key = rsa.generate_private_key( - public_exponent=public_exponent, - key_size=key_size, - ) - key_bytes = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), + warnings.warn( + "generate_private_key() is deprecated. Use PrivateKey.generate() instead.", + DeprecationWarning, ) - return PrivateKey.from_string(key_bytes.decode()) + return PrivateKey.generate(key_size=key_size, public_exponent=public_exponent) def calculate_relative_datetime(target_time: datetime, fraction: float) -> datetime: @@ -807,43 +1338,23 @@ def generate_csr( # noqa: C901 Returns: CertificateSigningRequest: CSR """ - signing_key = serialization.load_pem_private_key(str(private_key).encode(), password=None) - subject_name = [x509.NameAttribute(x509.NameOID.COMMON_NAME, common_name)] - if add_unique_id_to_subject_name: - unique_identifier = uuid.uuid4() - subject_name.append( - x509.NameAttribute(x509.NameOID.X500_UNIQUE_IDENTIFIER, str(unique_identifier)) - ) - if organization: - subject_name.append(x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, organization)) - if organizational_unit: - subject_name.append( - x509.NameAttribute(x509.NameOID.ORGANIZATIONAL_UNIT_NAME, organizational_unit) - ) - if email_address: - subject_name.append(x509.NameAttribute(x509.NameOID.EMAIL_ADDRESS, email_address)) - if country_name: - subject_name.append(x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country_name)) - if state_or_province_name: - subject_name.append( - x509.NameAttribute(x509.NameOID.STATE_OR_PROVINCE_NAME, state_or_province_name) - ) - if locality_name: - subject_name.append(x509.NameAttribute(x509.NameOID.LOCALITY_NAME, locality_name)) - csr = x509.CertificateSigningRequestBuilder(subject_name=x509.Name(subject_name)) - - _sans: List[x509.GeneralName] = [] - if sans_oid: - _sans.extend([x509.RegisteredID(x509.ObjectIdentifier(san)) for san in sans_oid]) - if sans_ip: - _sans.extend([x509.IPAddress(ipaddress.ip_address(san)) for san in sans_ip]) - if sans_dns: - _sans.extend([x509.DNSName(san) for san in sans_dns]) - if _sans: - csr = csr.add_extension(x509.SubjectAlternativeName(set(_sans)), critical=False) - signed_certificate = csr.sign(signing_key, hashes.SHA256()) # type: ignore[arg-type] - csr_str = signed_certificate.public_bytes(serialization.Encoding.PEM).decode() - return CertificateSigningRequest.from_string(csr_str) + warnings.warn( + "generate_csr() is deprecated. Use CertificateRequestAttributes.generate_csr() or CertificateSigningRequest.generate() instead.", + DeprecationWarning, + ) + return CertificateRequestAttributes( + common_name=common_name, + sans_dns=sans_dns, + sans_ip=sans_ip, + sans_oid=sans_oid, + organization=organization, + organizational_unit=organizational_unit, + email_address=email_address, + country_name=country_name, + state_or_province_name=state_or_province_name, + locality_name=locality_name, + add_unique_id_to_subject_name=add_unique_id_to_subject_name, + ).generate_csr(private_key=private_key) def generate_ca( @@ -863,101 +1374,47 @@ def generate_ca( """Generate a self signed CA Certificate. Args: - private_key (PrivateKey): Private key - validity (timedelta): Certificate validity time - common_name (str): Common Name that can be an IP or a Full Qualified Domain Name (FQDN). - sans_dns (FrozenSet[str]): DNS Subject Alternative Names - sans_ip (FrozenSet[str]): IP Subject Alternative Names - sans_oid (FrozenSet[str]): OID Subject Alternative Names - organization (Optional[str]): Organization name - organizational_unit (Optional[str]): Organizational unit name - email_address (Optional[str]): Email address - country_name (str): Certificate Issuing country - state_or_province_name (str): Certificate Issuing state or province - locality_name (str): Certificate Issuing locality + private_key: Private key + validity: Certificate validity time + common_name: Common Name that can be an IP or a Full Qualified Domain Name (FQDN). + sans_dns: DNS Subject Alternative Names + sans_ip: IP Subject Alternative Names + sans_oid: OID Subject Alternative Names + organization: Organization name + organizational_unit: Organizational unit name + email_address: Email address + country_name: Certificate Issuing country + state_or_province_name: Certificate Issuing state or province + locality_name: Certificate Issuing locality Returns: - Certificate: CA Certificate. + CA Certificate. """ - private_key_object = serialization.load_pem_private_key( - str(private_key).encode(), password=None - ) - assert isinstance(private_key_object, rsa.RSAPrivateKey) - subject_name = [x509.NameAttribute(x509.NameOID.COMMON_NAME, common_name)] - if organization: - subject_name.append(x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, organization)) - if organizational_unit: - subject_name.append( - x509.NameAttribute(x509.NameOID.ORGANIZATIONAL_UNIT_NAME, organizational_unit) - ) - if email_address: - subject_name.append(x509.NameAttribute(x509.NameOID.EMAIL_ADDRESS, email_address)) - if country_name: - subject_name.append(x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country_name)) - if state_or_province_name: - subject_name.append( - x509.NameAttribute(x509.NameOID.STATE_OR_PROVINCE_NAME, state_or_province_name) - ) - if locality_name: - subject_name.append(x509.NameAttribute(x509.NameOID.LOCALITY_NAME, locality_name)) - - subject_identifier_object = x509.SubjectKeyIdentifier.from_public_key( - private_key_object.public_key() - ) - subject_identifier = key_identifier = subject_identifier_object.public_bytes() - key_usage = x509.KeyUsage( - digital_signature=True, - key_encipherment=True, - key_cert_sign=True, - key_agreement=False, - content_commitment=False, - data_encipherment=False, - crl_sign=False, - encipher_only=False, - decipher_only=False, - ) - - builder = ( - x509.CertificateBuilder() - .subject_name(x509.Name(subject_name)) - .issuer_name(x509.Name(subject_name)) - .public_key(private_key_object.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.now(timezone.utc)) - .not_valid_after(datetime.now(timezone.utc) + validity) - .add_extension(x509.SubjectKeyIdentifier(digest=subject_identifier), critical=False) - .add_extension( - x509.AuthorityKeyIdentifier( - key_identifier=key_identifier, - authority_cert_issuer=None, - authority_cert_serial_number=None, - ), - critical=False, - ) - .add_extension(key_usage, critical=True) - .add_extension( - x509.BasicConstraints(ca=True, path_length=None), - critical=True, - ) + warnings.warn( + "generate_ca() is deprecated. Use Certificate.generate_self_signed_ca() instead.", + DeprecationWarning, ) - san_extension = _san_extension( - email_address=email_address, + attributes = CertificateRequestAttributes( + common_name=common_name, sans_dns=sans_dns, sans_ip=sans_ip, sans_oid=sans_oid, + organization=organization, + organizational_unit=organizational_unit, + email_address=email_address, + country_name=country_name, + state_or_province_name=state_or_province_name, + locality_name=locality_name, + is_ca=True, ) - if san_extension: - builder = builder.add_extension(san_extension, critical=False) - cert = builder.sign(private_key_object, hashes.SHA256()) # type: ignore[arg-type] - ca_cert_str = cert.public_bytes(serialization.Encoding.PEM).decode().strip() - return Certificate.from_string(ca_cert_str) + return Certificate.generate_self_signed_ca(attributes, private_key, validity) def _san_extension( email_address: Optional[str] = None, - sans_dns: Optional[FrozenSet[str]] = frozenset(), - sans_ip: Optional[FrozenSet[str]] = frozenset(), - sans_oid: Optional[FrozenSet[str]] = frozenset(), + sans_dns: Optional[Collection[str]] = frozenset(), + sans_ip: Optional[Collection[str]] = frozenset(), + sans_oid: Optional[Collection[str]] = frozenset(), ) -> Optional[x509.SubjectAlternativeName]: sans: List[x509.GeneralName] = [] if email_address: @@ -993,40 +1450,67 @@ def generate_certificate( Returns: Certificate: Certificate """ - csr_object = x509.load_pem_x509_csr(str(csr).encode()) - subject = csr_object.subject - ca_pem = x509.load_pem_x509_certificate(str(ca).encode()) - issuer = ca_pem.issuer - private_key = serialization.load_pem_private_key(str(ca_private_key).encode(), password=None) - - certificate_builder = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(issuer) - .public_key(csr_object.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.now(timezone.utc)) - .not_valid_after(datetime.now(timezone.utc) + validity) + warnings.warn( + "generate_certificate() is deprecated. Use Certificate.generate() instead.", + DeprecationWarning, ) - extensions = _generate_certificate_request_extensions( - authority_key_identifier=ca_pem.extensions.get_extension_for_class( - x509.SubjectKeyIdentifier - ).value.key_identifier, - csr=csr_object, + return Certificate.generate( + csr=csr, + ca=ca, + ca_private_key=ca_private_key, + validity=validity, is_ca=is_ca, ) - for extension in extensions: - try: - certificate_builder = certificate_builder.add_extension( - extval=extension.value, - critical=extension.critical, + + +def _extract_subject_name_attributes( + attributes: CertificateRequestAttributes, +) -> Optional[x509.Name]: + subject_name_attributes = [] + if attributes.common_name: + subject_name_attributes.append( + x509.NameAttribute(x509.NameOID.COMMON_NAME, attributes.common_name) + ) + if attributes.add_unique_id_to_subject_name: + unique_identifier = uuid.uuid4() + subject_name_attributes.append( + x509.NameAttribute(x509.NameOID.X500_UNIQUE_IDENTIFIER, str(unique_identifier)) + ) + if attributes.organization: + subject_name_attributes.append( + x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, attributes.organization) + ) + if attributes.organizational_unit: + subject_name_attributes.append( + x509.NameAttribute( + x509.NameOID.ORGANIZATIONAL_UNIT_NAME, + attributes.organizational_unit, ) - except ValueError as e: - logger.warning("Failed to add extension %s: %s", extension.oid, e) + ) + if attributes.email_address: + subject_name_attributes.append( + x509.NameAttribute(x509.NameOID.EMAIL_ADDRESS, attributes.email_address) + ) + if attributes.country_name: + subject_name_attributes.append( + x509.NameAttribute(x509.NameOID.COUNTRY_NAME, attributes.country_name) + ) + if attributes.state_or_province_name: + subject_name_attributes.append( + x509.NameAttribute( + x509.NameOID.STATE_OR_PROVINCE_NAME, + attributes.state_or_province_name, + ) + ) + if attributes.locality_name: + subject_name_attributes.append( + x509.NameAttribute(x509.NameOID.LOCALITY_NAME, attributes.locality_name) + ) + + if subject_name_attributes: + return x509.Name(subject_name_attributes) - cert = certificate_builder.sign(private_key, hashes.SHA256()) # type: ignore[arg-type] - cert_bytes = cert.public_bytes(serialization.Encoding.PEM) - return Certificate.from_string(cert_bytes.decode().strip()) + return None def _generate_certificate_request_extensions( @@ -1212,7 +1696,7 @@ def __init__( raise TLSCertificatesError("Invalid private key") if renewal_relative_time <= 0.5 or renewal_relative_time > 1.0: raise TLSCertificatesError( - "Invalid renewal relative time. Must be between 0.0 and 1.0" + "Invalid renewal relative time. Must be between 0.5 and 1.0" ) self._private_key = private_key self.renewal_relative_time = renewal_relative_time @@ -1222,6 +1706,7 @@ def __init__( self.framework.observe(charm.on.secret_remove, self._on_secret_remove) for event in refresh_events: self.framework.observe(event, self._configure) + self._security_logger = _OWASPLogger(application=f"tls-certificates-{charm.app.name}") def _configure(self, _: Optional[EventBase] = None): """Handle TLS Certificates Relation Data. @@ -1744,6 +2229,7 @@ def __init__(self, charm: CharmBase, relationship_name: str): self.framework.observe(charm.on.update_status, self._configure) self.charm = charm self.relationship_name = relationship_name + self._security_logger = _OWASPLogger(application=f"tls-certificates-{charm.app.name}") def _configure(self, _: EventBase) -> None: """Handle update status and tls relation changed events. @@ -1889,6 +2375,11 @@ def revoke_all_certificates(self) -> None: for certificate in provider_certificates: certificate.revoked = True self._dump_provider_certificates(relation=relation, certificates=provider_certificates) + self._security_logger.log_event( + event="all_certificates_revoked", + level=logging.WARNING, + description="All certificates revoked", + ) def set_relation_certificate( self, @@ -1918,6 +2409,13 @@ def set_relation_certificate( relation=certificates_relation, provider_certificate=provider_certificate, ) + self._security_logger.log_event( + event="certificate_provided", + level=logging.INFO, + description="Certificate provided to requirer", + relation_id=str(provider_certificate.relation_id), + common_name=provider_certificate.certificate.common_name, + ) def get_issued_certificates( self, relation_id: Optional[int] = None diff --git a/lib/charms/traefik_k8s/v1/ingress_per_unit.py b/lib/charms/traefik_k8s/v1/ingress_per_unit.py index cf285915e..b01dd059e 100644 --- a/lib/charms/traefik_k8s/v1/ingress_per_unit.py +++ b/lib/charms/traefik_k8s/v1/ingress_per_unit.py @@ -60,18 +60,12 @@ def _on_ingress_revoked(self, event: IngressPerUnitRevokedForUnitEvent): import logging import socket import typing -from typing import Any, Dict, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union import yaml +from ops import EventBase from ops.charm import CharmBase, RelationEvent -from ops.framework import ( - EventSource, - Object, - ObjectEvents, - StoredDict, - StoredList, - StoredState, -) +from ops.framework import EventSource, Object, ObjectEvents, StoredDict, StoredList, StoredState from ops.model import Application, ModelError, Relation, Unit # The unique Charmhub library identifier, never change it @@ -82,7 +76,7 @@ def _on_ingress_revoked(self, event: IngressPerUnitRevokedForUnitEvent): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 20 +LIBPATCH = 22 log = logging.getLogger(__name__) @@ -141,7 +135,7 @@ def _on_ingress_revoked(self, event: IngressPerUnitRevokedForUnitEvent): try: from typing import Literal, TypedDict # type: ignore except ImportError: - from typing_extensions import Literal, TypedDict # py35 compat + from typing_extensions import Literal, TypedDict # type: ignore # py35 compat # Model of the data a unit implementing the requirer will need to provide. @@ -166,7 +160,7 @@ def _on_ingress_revoked(self, event: IngressPerUnitRevokedForUnitEvent): ProviderApplicationData = Dict[str, KeyValueMapping] -def _type_convert_stored(obj): +def _type_convert_stored(obj: Any) -> Any: """Convert Stored* to their appropriate types, recursively.""" if isinstance(obj, StoredList): return list(map(_type_convert_stored, obj)) @@ -178,7 +172,7 @@ def _type_convert_stored(obj): return obj -def _validate_data(data, schema): +def _validate_data(data: Any, schema: Any) -> None: """Checks whether `data` matches `schema`. Will raise DataValidationError if the data is not valid, else return None. @@ -205,6 +199,12 @@ class RelationException(RuntimeError): """ def __init__(self, relation: Relation, entity: Union[Application, Unit]): + """Initialize the relation exception base class. + + Args: + relation: Relation instance. + entity: Application and Unit. + """ super().__init__(relation) self.args = ( "There is an error with the relation {}:{} with {}".format( @@ -223,6 +223,13 @@ class RelationPermissionError(RelationException): """Ingress is requested to do something for which it lacks permissions.""" def __init__(self, relation: Relation, entity: Union[Application, Unit], message: str): + """Initialize the exception. + + Args: + relation: Relation instance. + entity: Application and Unit. + message: Exception message. + """ super(RelationPermissionError, self).__init__(relation, entity) self.args = ( "Unable to write data to relation '{}:{}' with {}: {}".format( @@ -260,19 +267,19 @@ def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME) observe(charm.on.upgrade_charm, self._handle_upgrade_or_leader) # type: ignore @property - def relations(self): + def relations(self) -> List[Relation]: """The list of Relation instances associated with this relation_name.""" return list(self.charm.model.relations[self.relation_name]) - def _handle_relation(self, event): + def _handle_relation(self, event: RelationEvent) -> None: """Subclasses should implement this method to handle a relation update.""" pass - def _handle_relation_broken(self, event): + def _handle_relation_broken(self, event: RelationEvent) -> None: """Subclasses should implement this method to handle a relation breaking.""" pass - def _handle_upgrade_or_leader(self, event): + def _handle_upgrade_or_leader(self, event: EventBase) -> None: """Subclasses should implement this method to handle upgrades or leadership change.""" pass @@ -328,14 +335,14 @@ class IngressPerUnitProvider(_IngressPerUnitBase): on = IngressPerUnitProviderEvents() # type: ignore - def _handle_relation(self, event): + def _handle_relation(self, event: RelationEvent) -> None: relation = event.relation try: self.validate(relation) except RelationDataMismatchError as e: self.on.data_removed.emit(relation=relation, app=relation.app) # type: ignore log.warning( - "relation data mismatch: {} " "data_removed ingress for {}.".format(e, relation) + "relation data mismatch: {} data_removed ingress for {}.".format(e, relation) ) return @@ -344,7 +351,7 @@ def _handle_relation(self, event): else: self.on.data_removed.emit(relation=relation, app=relation.app) # type: ignore - def _handle_relation_broken(self, event): + def _handle_relation_broken(self, event: RelationEvent) -> None: # relation broken -> we revoke in any case self.on.data_removed.emit(relation=event.relation, app=event.relation.app) # type: ignore @@ -368,7 +375,7 @@ def is_ready(self, relation: Optional[Relation] = None) -> bool: return any(requirer_units_data.values()) - def validate(self, relation: Relation): + def validate(self, relation: Relation) -> None: """Checks whether the given relation is failed. Or any relation if not specified. @@ -390,11 +397,11 @@ def validate(self, relation: Relation): def is_unit_ready(self, relation: Relation, unit: Unit) -> bool: """Report whether the given unit has shared data in its unit data bag.""" - # sanity check: this should not occur in production, but it may happen + # confidence check: this should not occur in production, but it may happen # during testing: cfr https://github.com/canonical/traefik-k8s-operator/issues/39 - assert unit in relation.units, ( - "attempting to get ready state " "for unit that does not belong to relation" - ) + assert ( + unit in relation.units + ), "attempting to get ready state for unit that does not belong to relation" try: self._get_requirer_unit_data(relation, unit) except (KeyError, DataValidationError): @@ -405,7 +412,7 @@ def get_data(self, relation: Relation, unit: Unit) -> "RequirerData": """Fetch the data shared by the specified unit on the relation (Requirer side).""" return self._get_requirer_unit_data(relation, unit) - def publish_url(self, relation: Relation, unit_name: str, url: str): + def publish_url(self, relation: Relation, unit_name: str, url: str) -> None: """Place the ingress url in the application data bag for the units on the requirer side. Assumes that this unit is leader. @@ -438,7 +445,7 @@ def publish_url(self, relation: Relation, unit_name: str, url: str): self.on.endpoints_updated.emit(relation=relation, app=relation.app) - def wipe_ingress_data(self, relation): + def wipe_ingress_data(self, relation: Relation) -> None: """Remove all published ingress data. Assumes that this unit is leader. @@ -483,7 +490,7 @@ def _requirer_units_data(self, relation: Relation) -> RequirerUnitData: requirer_units_data[remote_unit] = remote_data return requirer_units_data - def _get_requirer_unit_data(self, relation: Relation, remote_unit: Unit) -> RequirerData: # type: ignore + def _get_requirer_unit_data(self, relation: Relation, remote_unit: Unit) -> RequirerData: # type: ignore # noqa """Fetch and validate the requirer unit data for this unit. For convenience, we convert 'port' to integer. @@ -570,10 +577,10 @@ class _IPUEvent(RelationEvent): __optional_kwargs__: Dict[str, Any] = {} @classmethod - def __attrs__(cls): + def __attrs__(cls): # type: ignore return cls.__args__ + tuple(cls.__optional_kwargs__.keys()) - def __init__(self, handle, relation, *args, **kwargs): + def __init__(self, handle, relation, *args, **kwargs): # type: ignore super().__init__(handle, relation, app=relation.app) if not len(self.__args__) == len(args): @@ -585,7 +592,7 @@ def __init__(self, handle, relation, *args, **kwargs): obj = kwargs.get(attr, default) setattr(self, attr, obj) - def snapshot(self): + def snapshot(self) -> Dict[str, Any]: dct = super().snapshot() for attr in self.__attrs__(): obj = getattr(self, attr) @@ -599,7 +606,7 @@ def snapshot(self): ) from e return dct - def restore(self, snapshot) -> None: + def restore(self, snapshot: Any) -> None: super().restore(snapshot) for attr, obj in snapshot.items(): setattr(self, attr, obj) @@ -738,7 +745,7 @@ def __init__( self.charm.on[self.relation_name].relation_broken, self._handle_relation ) - def _handle_relation(self, event: RelationEvent): + def _handle_relation(self, event: RelationEvent) -> None: # we calculate the diff between the urls we were aware of # before and those we know now previous_urls = self._stored.current_urls or {} # type: ignore @@ -749,7 +756,7 @@ def _handle_relation(self, event: RelationEvent): self._stored.current_urls = current_urls # type: ignore removed = previous_urls.keys() - current_urls.keys() # type: ignore - changed = {a for a in current_urls if current_urls[a] != previous_urls.get(a)} # type: ignore + changed = {a for a in current_urls if current_urls[a] != previous_urls.get(a)} # type: ignore # noqa this_unit_name = self.unit.name # do not use self.relation in this context because if @@ -771,10 +778,10 @@ def _handle_relation(self, event: RelationEvent): self._publish_auto_data() - def _handle_upgrade_or_leader(self, event): + def _handle_upgrade_or_leader(self, event: EventBase) -> None: self._publish_auto_data() - def _publish_auto_data(self): + def _publish_auto_data(self) -> None: if self._port: self.provide_ingress_requirements(host=self._host, port=self._port) @@ -783,7 +790,7 @@ def relation(self) -> Optional[Relation]: """The established Relation instance, or None if still unrelated.""" return self.relations[0] if self.relations else None - def is_ready(self) -> bool: + def is_ready(self) -> bool: # type: ignore """Checks whether the given relation is ready. Or any relation if not specified. @@ -797,7 +804,7 @@ def is_ready(self) -> bool: def provide_ingress_requirements( self, *, scheme: Optional[str] = None, host: Optional[str] = None, port: int - ): + ) -> None: """Publishes the data that Traefik needs to provide ingress. Args: @@ -854,7 +861,7 @@ def _urls_from_relation_data(self) -> Dict[str, str]: assert isinstance(relation.app, Application) # type guard try: - raw = relation.data.get(relation.app, {}).get("ingress") + raw = relation.data.get(relation.app, {}).get("ingress") # type: ignore except ModelError as e: log.debug( "Error {} attempting to read remote app data; " diff --git a/lib/charms/traefik_k8s/v2/ingress.py b/lib/charms/traefik_k8s/v2/ingress.py index d329866fb..7ff4cebf4 100644 --- a/lib/charms/traefik_k8s/v2/ingress.py +++ b/lib/charms/traefik_k8s/v2/ingress.py @@ -50,6 +50,7 @@ def _on_ingress_ready(self, event: IngressPerAppReadyEvent): def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent): logger.info("This app no longer has ingress") """ + import ipaddress import json import logging @@ -71,6 +72,7 @@ def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent): ) import pydantic +from ops import EventBase from ops.charm import CharmBase, RelationBrokenEvent, RelationEvent from ops.framework import EventSource, Object, ObjectEvents, StoredState from ops.model import ModelError, Relation, Unit @@ -84,7 +86,7 @@ def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 17 +LIBPATCH = 19 PYDEPS = ["pydantic"] @@ -95,7 +97,7 @@ def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent): BUILTIN_JUJU_KEYS = {"ingress-address", "private-address", "egress-subnets"} PYDANTIC_IS_V1 = int(pydantic.version.VERSION.split(".")[0]) < 2 -if PYDANTIC_IS_V1: +if PYDANTIC_IS_V1: # noqa from pydantic import validator input_validator = partial(validator, pre=True) @@ -111,8 +113,10 @@ class Config: _NEST_UNDER = None + # Annotating -> "DatabagModel" as the return type here doesn't sit well with pyright + # We are disabling this line for now and come back to it later. @classmethod - def load(cls, databag: MutableMapping): + def load(cls, databag: MutableMapping): # type: ignore[no-untyped-def] """Load this model from a Juju databag.""" if cls._NEST_UNDER: return cls.parse_obj(json.loads(databag[cls._NEST_UNDER])) @@ -136,7 +140,7 @@ def load(cls, databag: MutableMapping): log.debug(msg, exc_info=True) raise DataValidationError(msg) from e - def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): + def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True) -> Any: """Write the contents of this model to Juju databag. :param databag: the databag to write the data to. @@ -152,7 +156,7 @@ def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): databag[self._NEST_UNDER] = self.json(by_alias=True, exclude_defaults=True) return databag - for key, value in self.dict(by_alias=True, exclude_defaults=True).items(): # type: ignore + for key, value in self.dict(by_alias=True, exclude_defaults=True).items(): # type: ignore # noqa databag[key] = json.dumps(value) return databag @@ -160,9 +164,9 @@ def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): else: from pydantic import ConfigDict, field_validator - input_validator = partial(field_validator, mode="before") + input_validator = partial(field_validator, mode="before") # type: ignore - class DatabagModel(BaseModel): + class DatabagModel(BaseModel): # type: ignore """Base databag model.""" model_config = ConfigDict( @@ -176,8 +180,10 @@ class DatabagModel(BaseModel): ) # type: ignore """Pydantic config.""" + # Annotating -> "DatabagModel" as the return type here doesn't sit well with pyright + # We are disabling this line for now and come back to it later. @classmethod - def load(cls, databag: MutableMapping): + def load(cls, databag: MutableMapping): # type: ignore[no-untyped-def] """Load this model from a Juju databag.""" nest_under = cls.model_config.get("_NEST_UNDER") if nest_under: @@ -202,7 +208,7 @@ def load(cls, databag: MutableMapping): log.debug(msg, exc_info=True) raise DataValidationError(msg) from e - def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): + def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True) -> Any: """Write the contents of this model to Juju databag. :param databag: the databag to write the data to. @@ -222,7 +228,11 @@ def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): ) return databag - dct = self.model_dump(mode="json", by_alias=True, exclude_defaults=True) # type: ignore + dct = self.model_dump( + mode="json", + by_alias=True, + exclude_defaults=True, # type: ignore + ) databag.update({k: json.dumps(v) for k, v in dct.items()}) return databag @@ -289,15 +299,17 @@ class IngressRequirerAppData(DatabagModel): default="http", description="What scheme to use in the generated ingress url" ) + # pydantic wants 'cls' as first arg @input_validator("scheme") - def validate_scheme(cls, scheme): # noqa: N805 # pydantic wants 'cls' as first arg + def validate_scheme(cls, scheme: str) -> str: # noqa: N805 """Validate scheme arg.""" if scheme not in {"http", "https", "h2c"}: raise ValueError("invalid scheme: should be one of `http|https|h2c`") return scheme + # pydantic wants 'cls' as first arg @input_validator("port") - def validate_port(cls, port): # noqa: N805 # pydantic wants 'cls' as first arg + def validate_port(cls, port: int) -> int: # noqa: N805 """Validate port.""" assert isinstance(port, int), type(port) assert 0 < port < 65535, "port out of TCP range" @@ -314,14 +326,16 @@ class IngressRequirerUnitData(DatabagModel): "IP can only be None if the IP information can't be retrieved from juju.", ) + # pydantic wants 'cls' as first arg @input_validator("host") - def validate_host(cls, host): # noqa: N805 # pydantic wants 'cls' as first arg + def validate_host(cls, host: str) -> str: # noqa: N805 """Validate host.""" assert isinstance(host, str), type(host) return host + # pydantic wants 'cls' as first arg @input_validator("ip") - def validate_ip(cls, ip): # noqa: N805 # pydantic wants 'cls' as first arg + def validate_ip(cls, ip: str) -> Optional[str]: # noqa: N805 """Validate ip.""" if ip is None: return None @@ -380,19 +394,19 @@ def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME) observe(charm.on.upgrade_charm, self._handle_upgrade_or_leader) # type: ignore @property - def relations(self): + def relations(self) -> List[Relation]: """The list of Relation instances associated with this endpoint.""" return list(self.charm.model.relations[self.relation_name]) - def _handle_relation(self, event): + def _handle_relation(self, event: RelationEvent) -> None: """Subclasses should implement this method to handle a relation update.""" pass - def _handle_relation_broken(self, event): + def _handle_relation_broken(self, event: RelationEvent) -> None: """Subclasses should implement this method to handle a relation breaking.""" pass - def _handle_upgrade_or_leader(self, event): + def _handle_upgrade_or_leader(self, event: EventBase) -> None: """Subclasses should implement this method to handle upgrades or leadership change.""" pass @@ -402,10 +416,10 @@ class _IPAEvent(RelationEvent): __optional_kwargs__: Dict[str, Any] = {} @classmethod - def __attrs__(cls): + def __attrs__(cls): # type: ignore return cls.__args__ + tuple(cls.__optional_kwargs__.keys()) - def __init__(self, handle, relation, *args, **kwargs): + def __init__(self, handle, relation, *args, **kwargs): # type: ignore super().__init__(handle, relation) if not len(self.__args__) == len(args): @@ -417,7 +431,7 @@ def __init__(self, handle, relation, *args, **kwargs): obj = kwargs.get(attr, default) setattr(self, attr, obj) - def snapshot(self): + def snapshot(self) -> Dict[str, Any]: dct = super().snapshot() for attr in self.__attrs__(): obj = getattr(self, attr) @@ -432,7 +446,7 @@ def snapshot(self): return dct - def restore(self, snapshot) -> None: + def restore(self, snapshot: Any) -> None: super().restore(snapshot) for attr, obj in snapshot.items(): setattr(self, attr, obj) @@ -495,7 +509,7 @@ def __init__( """ super().__init__(charm, relation_name) - def _handle_relation(self, event): + def _handle_relation(self, event: RelationEvent) -> None: # created, joined or changed: if remote side has sent the required data: # notify listeners. if self.is_ready(event.relation): @@ -512,10 +526,10 @@ def _handle_relation(self, event): data.app.redirect_https or False, ) - def _handle_relation_broken(self, event): + def _handle_relation_broken(self, event: RelationEvent) -> None: self.on.data_removed.emit(event.relation, event.relation.app) # type: ignore - def wipe_ingress_data(self, relation: Relation): + def wipe_ingress_data(self, relation: Relation) -> None: """Clear ingress data from relation.""" assert self.unit.is_leader(), "only leaders can do this" try: @@ -539,7 +553,7 @@ def _get_requirer_units_data(self, relation: Relation) -> List["IngressRequirerU databag = relation.data[unit] try: data = IngressRequirerUnitData.load(databag) - out.append(data) + out.append(cast(IngressRequirerUnitData, data)) except pydantic.ValidationError: log.info(f"failed to validate remote unit data for {unit}") raise @@ -553,7 +567,7 @@ def _get_requirer_app_data(relation: Relation) -> "IngressRequirerAppData": raise NotReadyError(relation) databag = relation.data[app] - return IngressRequirerAppData.load(databag) + return cast(IngressRequirerAppData, IngressRequirerAppData.load(databag)) def get_data(self, relation: Relation) -> IngressRequirerData: """Fetch the remote (requirer) app and units' databags.""" @@ -566,7 +580,7 @@ def get_data(self, relation: Relation) -> IngressRequirerData: "failed to validate ingress requirer data: %s" % str(e) ) from e - def is_ready(self, relation: Optional[Relation] = None): + def is_ready(self, relation: Optional[Relation] = None) -> bool: """The Provider is ready if the requirer has sent valid data.""" if not relation: return any(map(self.is_ready, self.relations)) @@ -594,7 +608,7 @@ def _published_url(self, relation: Relation) -> Optional["IngressProviderAppData return IngressProviderAppData.load(databag) - def publish_url(self, relation: Relation, url: str): + def publish_url(self, relation: Relation, url: str) -> None: """Publish to the app databag the ingress url.""" ingress_url = {"url": url} try: @@ -604,8 +618,11 @@ def publish_url(self, relation: Relation, url: str): # If we cannot validate the url as valid, publish an empty databag and log the error. log.error(f"Failed to validate ingress url '{url}' - got ValidationError {e}") log.error( - "url was not published to ingress relation for {relation.app}. This error is likely due to an" - " error or misconfiguration of the charm calling this library." + ( + f"url was not published to ingress relation for {relation.app}." + f"This error is likely due to an error or misconfiguration of the" + "charm calling this library." + ) ) IngressProviderAppData(ingress=None).dump(relation.data[self.app]) # type: ignore @@ -630,7 +647,10 @@ def proxied_endpoints(self) -> Dict[str, Dict[str, str]]: for ingress_relation in self.relations: if not ingress_relation.app: log.warning( - f"no app in relation {ingress_relation} when fetching proxied endpoints: skipping" + ( + f"no app in relation {ingress_relation} when fetching proxied endpoints:" + "skipping" + ) ) continue try: @@ -647,8 +667,7 @@ def proxied_endpoints(self) -> Dict[str, Dict[str, str]]: continue # Validation above means ingress cannot be None, but type checker doesn't know that. - ingress = ingress_data.ingress - ingress = cast(IngressProviderAppData, ingress) + ingress = cast(IngressProviderAppData, ingress_data.ingress) if PYDANTIC_IS_V1: results[ingress_relation.app.name] = ingress.dict() else: @@ -683,6 +702,7 @@ class IngressPerAppRequirer(_IngressPerAppBase): # used to prevent spurious urls to be sent out if the event we're currently # handling is a relation-broken one. _stored = StoredState() + _auto_data: Optional[Tuple[Optional[str], Optional[str], int]] def __init__( self, @@ -695,7 +715,8 @@ def __init__( strip_prefix: bool = False, redirect_https: bool = False, # fixme: this is horrible UX. - # shall we switch to manually calling provide_ingress_requirements with all args when ready? + # shall we switch to manually calling provide_ingress_requirements with all args when + # ready? scheme: Union[Callable[[], str], str] = lambda: "http", healthcheck_params: Optional[Dict[str, Any]] = None, ): @@ -715,19 +736,25 @@ def __init__( ip: Alternative addressing method other than host to be used by the ingress provider; if unspecified, the binding address from the Juju network API will be used. healthcheck_params: Optional dictionary containing health check - configuration parameters conforming to the IngressHealthCheck schema. The dictionary must include: + configuration parameters conforming to the IngressHealthCheck schema. + The dictionary must include: - "path" (str): The health check endpoint path (required). It may also include: - - "scheme" (Optional[str]): Replaces the server URL scheme for the health check endpoint. + - "scheme" (Optional[str]): Replaces the server URL scheme for the health check + endpoint. - "hostname" (Optional[str]): Hostname to be set in the health check request. - - "port" (Optional[int]): Replaces the server URL port for the health check endpoint. - - "interval" (str): Frequency of the health check calls (defaults to "30s" if omitted). - - "timeout" (str): Maximum duration for a health check request (defaults to "5s" if omitted). - If provided, "path" is required while "interval" and "timeout" will use Traefik's defaults when not specified. + - "port" (Optional[int]): Replaces the server URL port for the health check + endpoint. + - "interval" (str): Frequency of the health check calls + (defaults to "30s" if omitted). + - "timeout" (str): Maximum duration for a health check request + (defaults to "5s" if omitted). + If provided, "path" is required while "interval" and "timeout" will use Traefik's + defaults when not specified. strip_prefix: Configure Traefik to strip the path prefix. redirect_https: Redirect incoming requests to HTTPS. - scheme: Either a callable that returns the scheme to use when constructing the ingress URL, - or a string if the scheme is known and stable at charm initialization. + scheme: Either a callable that returns the scheme to use when constructing the ingress + URL, or a string if the scheme is known and stable at charm initialization. Request Args: port: the port of the service @@ -749,7 +776,7 @@ def __init__( else: self._auto_data = None - def _handle_relation(self, event): + def _handle_relation(self, event: RelationEvent) -> None: # created, joined or changed: if we have auto data: publish it self._publish_auto_data() if self.is_ready(): @@ -763,15 +790,15 @@ def _handle_relation(self, event): self._stored.current_url = new_url # type: ignore self.on.ready.emit(event.relation, new_url) # type: ignore - def _handle_relation_broken(self, event): + def _handle_relation_broken(self, event: RelationEvent) -> None: self._stored.current_url = None # type: ignore self.on.revoked.emit(relation=event.relation, app=event.relation.app) # type: ignore - def _handle_upgrade_or_leader(self, event): + def _handle_upgrade_or_leader(self, event: EventBase) -> None: """On upgrade/leadership change: ensure we publish the data we have.""" self._publish_auto_data() - def is_ready(self): + def is_ready(self) -> bool: """The Requirer is ready if the Provider has sent valid data.""" try: return bool(self._get_url_from_relation_data()) @@ -779,7 +806,7 @@ def is_ready(self): log.debug("Requirer not ready; validation error encountered: %s" % str(e)) return False - def _publish_auto_data(self): + def _publish_auto_data(self) -> None: if self._auto_data: host, ip, port = self._auto_data self.provide_ingress_requirements(host=host, ip=ip, port=port) @@ -791,7 +818,7 @@ def provide_ingress_requirements( host: Optional[str] = None, ip: Optional[str] = None, port: int, - ): + ) -> None: """Publishes the data that Traefik needs to provide ingress. Args: @@ -812,7 +839,7 @@ def _provide_ingress_requirements( ip: Optional[str], port: int, relation: Relation, - ): + ) -> None: if self.unit.is_leader(): self._publish_app_data(scheme, port, relation) @@ -823,7 +850,7 @@ def _publish_unit_data( host: Optional[str], ip: Optional[str], relation: Relation, - ): + ) -> None: if not host: host = socket.getfqdn() @@ -850,7 +877,7 @@ def _publish_app_data( scheme: Optional[str], port: int, relation: Relation, - ): + ) -> None: # assumes leadership! app_databag = relation.data[self.app] @@ -859,13 +886,14 @@ def _publish_app_data( scheme = self._get_scheme() try: - IngressRequirerAppData( # type: ignore # pyright does not like aliases + # Ignore pyright errors since pyright does not like aliases. + IngressRequirerAppData( # type: ignore model=self.model.name, name=self.app.name, scheme=scheme, port=port, - strip_prefix=self._strip_prefix, # type: ignore # pyright does not like aliases - redirect_https=self._redirect_https, # type: ignore # pyright does not like aliases + strip_prefix=self._strip_prefix, # type: ignore + redirect_https=self._redirect_https, # type: ignore healthcheck_params=( IngressHealthCheck(**self.healthcheck_params) if self.healthcheck_params @@ -878,7 +906,7 @@ def _publish_app_data( raise DataValidationError(msg) from e @property - def relation(self): + def relation(self) -> Optional[Relation]: """The established Relation instance, or None.""" return self.relations[0] if self.relations else None @@ -904,7 +932,7 @@ def _get_url_from_relation_data(self) -> Optional[str]: if not databag: # not ready yet return None - ingress = IngressProviderAppData.load(databag).ingress + ingress = cast(IngressProviderAppData, IngressProviderAppData.load(databag)).ingress if ingress is None: return None From 192929b3339ee6ac859d2d9dd5b0005fca2a160d Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 13 Nov 2025 16:11:52 +0100 Subject: [PATCH 06/48] update templating, add deps --- haproxy_spoe_auth_operator/README.md | 48 +- haproxy_spoe_auth_operator/charmcraft.yaml | 15 +- .../lib/charms/hydra/v0/oauth.py | 808 ++++++++++++++++++ haproxy_spoe_auth_operator/pyproject.toml | 1 + haproxy_spoe_auth_operator/src/charm.py | 78 +- ...ervice.py => haproxy_spoe_auth_service.py} | 56 +- .../templates/config.yaml.j2 | 7 +- haproxy_spoe_auth_operator/uv.lock | 14 + 8 files changed, 857 insertions(+), 170 deletions(-) create mode 100644 haproxy_spoe_auth_operator/lib/charms/hydra/v0/oauth.py rename haproxy_spoe_auth_operator/src/{spoe_auth_service.py => haproxy_spoe_auth_service.py} (61%) diff --git a/haproxy_spoe_auth_operator/README.md b/haproxy_spoe_auth_operator/README.md index 3359f7257..89b9f5b28 100644 --- a/haproxy_spoe_auth_operator/README.md +++ b/haproxy_spoe_auth_operator/README.md @@ -6,62 +6,22 @@ A Juju charm that deploys and manages HAProxy SPOE Auth on machines. HAProxy SPOE Auth is a Stream Processing Offload Engine (SPOE) agent for HAProxy that provides authentication capabilities. This charm simplifies the deployment and management of the agent. -## Features - -- **OAuth Authentication**: Support for OAuth-based authentication -- **Snap-based Deployment**: Uses the haproxy-spoe-auth snap for easy installation and updates -- **Configuration Management**: Automated configuration file management via Jinja2 templates ## Usage Deploy the charm: ```bash -juju deploy ./haproxy_spoe_auth_operator -``` - -Configure the SPOE address: - -```bash -juju config haproxy-spoe-auth spoe-address="127.0.0.1:3000" +juju deploy haproxy_spoe_auth_operator --channel=latest/edge ``` Integrate with an OAuth provider: ```bash -juju integrate haproxy-spoe-auth oauth-provider -``` - -## Configuration - -- `spoe-address`: Address for SPOE agent to listen on (default: "127.0.0.1:3000") - -## Relations - -- `oauth` (requires): OAuth authentication provider integration - -## Development - -This charm is part of the haproxy-operator monorepo. - -### Testing - -Run unit tests: -```bash -tox -e unit +juju relate haproxy-spoe-auth oauth-provider ``` -Run integration tests: +Integrate with haproxy ```bash -tox -e integration +juju relate haproxy-spoe-auth haproxy ``` - -Run linting: -```bash -tox -e lint -``` - -## Project Information - -- [Source Code](https://github.com/canonical/haproxy-operator) -- [Issue Tracker](https://github.com/canonical/haproxy-operator/issues) diff --git a/haproxy_spoe_auth_operator/charmcraft.yaml b/haproxy_spoe_auth_operator/charmcraft.yaml index 7b7ae7b51..d57fa53ca 100644 --- a/haproxy_spoe_auth_operator/charmcraft.yaml +++ b/haproxy_spoe_auth_operator/charmcraft.yaml @@ -47,13 +47,18 @@ requires: oauth: interface: oauth description: OAuth authentication provider integration +provides: + spoe-auth: + interface: spoe-auth + description: Relation to provide authentication proxy to HAProxy. + limit: 1 config: - options: - spoe-address: - default: "127.0.0.1:3000" - type: string - description: Address for SPOE agent to listen on + hostname: + type: string + description: | + Public hostname of the agent. Used to store client credentials + and to build the redirect URI charm-libs: - lib: hydra.oauth diff --git a/haproxy_spoe_auth_operator/lib/charms/hydra/v0/oauth.py b/haproxy_spoe_auth_operator/lib/charms/hydra/v0/oauth.py new file mode 100644 index 000000000..c0b35a3a0 --- /dev/null +++ b/haproxy_spoe_auth_operator/lib/charms/hydra/v0/oauth.py @@ -0,0 +1,808 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""# Oauth Library. + +This library is designed to enable applications to register OAuth2/OIDC +clients with an OIDC Provider through the `oauth` interface. + +## Getting started + +To get started using this library you just need to fetch the library using `charmcraft`. **Note +that you also need to add `jsonschema` to your charm's `requirements.txt`.** + +```shell +cd some-charm +charmcraft fetch-lib charms.hydra.v0.oauth +EOF +``` + +Then, to initialize the library: +```python +# ... +from charms.hydra.v0.oauth import ClientConfig, OAuthRequirer + +OAUTH = "oauth" +OAUTH_SCOPES = "openid email" +OAUTH_GRANT_TYPES = ["authorization_code"] + +class SomeCharm(CharmBase): + def __init__(self, *args): + # ... + self.oauth = OAuthRequirer(self, client_config, relation_name=OAUTH) + + self.framework.observe(self.oauth.on.oauth_info_changed, self._configure_application) + # ... + + def _on_ingress_ready(self, event): + self.external_url = "https://example.com" + self._set_client_config() + + def _set_client_config(self): + client_config = ClientConfig( + urljoin(self.external_url, "/oauth/callback"), + OAUTH_SCOPES, + OAUTH_GRANT_TYPES, + ) + self.oauth.update_client_config(client_config) +``` +""" + +import json +import logging +import re +from dataclasses import asdict, dataclass, field, fields +from typing import Dict, List, Mapping, Optional + +import jsonschema +from ops.charm import CharmBase, RelationBrokenEvent, RelationChangedEvent, RelationCreatedEvent +from ops.framework import EventBase, EventSource, Handle, Object, ObjectEvents +from ops.model import Relation, Secret, SecretNotFoundError, TooManyRelatedAppsError + +# The unique Charmhub library identifier, never change it +LIBID = "a3a301e325e34aac80a2d633ef61fe97" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 11 + +PYDEPS = ["jsonschema"] + + +logger = logging.getLogger(__name__) + +DEFAULT_RELATION_NAME = "oauth" +ALLOWED_GRANT_TYPES = [ + "authorization_code", + "refresh_token", + "client_credentials", + "urn:ietf:params:oauth:grant-type:device_code", +] +ALLOWED_CLIENT_AUTHN_METHODS = ["client_secret_basic", "client_secret_post"] +CLIENT_SECRET_FIELD = "secret" + +url_regex = re.compile( + r"(^http://)|(^https://)" # http:// or https:// + r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|" + r"[A-Z0-9-]{2,}\.?)|" # domain... + r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" # ...or ip + r"(?::\d+)?" # optional port + r"(?:/?|[/?]\S+)$", + re.IGNORECASE, +) + +OAUTH_PROVIDER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://canonical.github.io/charm-relation-interfaces/interfaces/oauth/schemas/provider.json", + "type": "object", + "properties": { + "issuer_url": { + "type": "string", + }, + "authorization_endpoint": { + "type": "string", + }, + "token_endpoint": { + "type": "string", + }, + "introspection_endpoint": { + "type": "string", + }, + "userinfo_endpoint": { + "type": "string", + }, + "jwks_endpoint": { + "type": "string", + }, + "scope": { + "type": "string", + }, + "client_id": { + "type": "string", + }, + "client_secret_id": { + "type": "string", + }, + "groups": {"type": "string", "default": None}, + "ca_chain": {"type": "array", "items": {"type": "string"}, "default": []}, + "jwt_access_token": {"type": "string", "default": "False"}, + }, + "required": [ + "issuer_url", + "authorization_endpoint", + "token_endpoint", + "introspection_endpoint", + "userinfo_endpoint", + "jwks_endpoint", + "scope", + ], +} +OAUTH_REQUIRER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://canonical.github.io/charm-relation-interfaces/interfaces/oauth/schemas/requirer.json", + "type": "object", + "properties": { + "redirect_uri": { + "type": "string", + "default": None, + }, + "audience": {"type": "array", "default": [], "items": {"type": "string"}}, + "scope": {"type": "string", "default": None}, + "grant_types": { + "type": "array", + "default": None, + "items": { + "enum": ALLOWED_GRANT_TYPES, + "type": "string", + }, + }, + "token_endpoint_auth_method": { + "type": "string", + "enum": ALLOWED_CLIENT_AUTHN_METHODS, + "default": "client_secret_basic", + }, + }, + "required": ["redirect_uri", "audience", "scope", "grant_types", "token_endpoint_auth_method"], +} + + +class ClientConfigError(Exception): + """Emitted when invalid client config is provided.""" + + +class DataValidationError(RuntimeError): + """Raised when data validation fails on relation data.""" + + +def _load_data(data: Mapping, schema: Optional[Dict] = None) -> Dict: + """Parses nested fields and checks whether `data` matches `schema`.""" + ret = {} + for k, v in data.items(): + try: + ret[k] = json.loads(v) + except json.JSONDecodeError: + ret[k] = v + + if schema: + _validate_data(ret, schema) + return ret + + +def _dump_data(data: Dict, schema: Optional[Dict] = None) -> Dict: + if schema: + _validate_data(data, schema) + + ret = {} + for k, v in data.items(): + if isinstance(v, (list, dict)): + try: + ret[k] = json.dumps(v) + except json.JSONDecodeError as e: + raise DataValidationError(f"Failed to encode relation json: {e}") + elif isinstance(v, bool): + ret[k] = str(v) + else: + ret[k] = v + return ret + + +def strtobool(val: str) -> bool: + """Convert a string representation of truth to true (1) or false (0). + + True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values + are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if + 'val' is anything else. + """ + if not isinstance(val, str): + raise ValueError(f"invalid value type {type(val)}") + + val = val.lower() + if val in ("y", "yes", "t", "true", "on", "1"): + return True + elif val in ("n", "no", "f", "false", "off", "0"): + return False + else: + raise ValueError(f"invalid truth value {val}") + + +class OAuthRelation(Object): + """A class containing helper methods for oauth relation.""" + + def _pop_relation_data(self, relation_id: Relation) -> None: + if not self.model.unit.is_leader(): + return + + if len(self.model.relations) == 0: + return + + relation = self.model.get_relation(self._relation_name, relation_id=relation_id) + if not relation or not relation.app: + return + + try: + for data in list(relation.data[self.model.app]): + relation.data[self.model.app].pop(data, "") + except Exception as e: + logger.info(f"Failed to pop the relation data: {e}") + + +def _validate_data(data: Dict, schema: Dict) -> None: + """Checks whether `data` matches `schema`. + + Will raise DataValidationError if the data is not valid, else return None. + """ + try: + jsonschema.validate(instance=data, schema=schema) + except jsonschema.ValidationError as e: + raise DataValidationError(data, schema) from e + + +@dataclass +class ClientConfig: + """Helper class containing a client's configuration.""" + + redirect_uri: str + scope: str + grant_types: List[str] + audience: List[str] = field(default_factory=lambda: []) + token_endpoint_auth_method: str = "client_secret_basic" + client_id: Optional[str] = None + + def validate(self) -> None: + """Validate the client configuration.""" + # Validate redirect_uri + if not re.match(url_regex, self.redirect_uri): + raise ClientConfigError(f"Invalid URL {self.redirect_uri}") + + if self.redirect_uri.startswith("http://"): + logger.warning("Provided Redirect URL uses http scheme. Don't do this in production") + + # Validate grant_types + for grant_type in self.grant_types: + if grant_type not in ALLOWED_GRANT_TYPES: + raise ClientConfigError( + f"Invalid grant_type {grant_type}, must be one " f"of {ALLOWED_GRANT_TYPES}" + ) + + # Validate client authentication methods + if self.token_endpoint_auth_method not in ALLOWED_CLIENT_AUTHN_METHODS: + raise ClientConfigError( + f"Invalid client auth method {self.token_endpoint_auth_method}, " + f"must be one of {ALLOWED_CLIENT_AUTHN_METHODS}" + ) + + def to_dict(self) -> Dict: + """Convert object to dict.""" + return {k: v for k, v in asdict(self).items() if v is not None} + + +@dataclass +class OauthProviderConfig: + """Helper class containing provider's configuration.""" + + issuer_url: str + authorization_endpoint: str + token_endpoint: str + introspection_endpoint: str + userinfo_endpoint: str + jwks_endpoint: str + scope: str + client_id: Optional[str] = None + client_secret: Optional[str] = None + groups: Optional[str] = None + ca_chain: Optional[str] = None + jwt_access_token: Optional[bool] = False + + @classmethod + def from_dict(cls, dic: Dict) -> "OauthProviderConfig": + """Generate OauthProviderConfig instance from dict.""" + jwt_access_token = False + if "jwt_access_token" in dic: + jwt_access_token = strtobool(dic["jwt_access_token"]) + return cls( + jwt_access_token=jwt_access_token, + **{ + k: v + for k, v in dic.items() + if k in [f.name for f in fields(cls)] and k != "jwt_access_token" + }, + ) + + +class OAuthInfoChangedEvent(EventBase): + """Event to notify the charm that the information in the databag changed.""" + + def __init__(self, handle: Handle, client_id: str, client_secret_id: str): + super().__init__(handle) + self.client_id = client_id + self.client_secret_id = client_secret_id + + def snapshot(self) -> Dict: + """Save event.""" + return { + "client_id": self.client_id, + "client_secret_id": self.client_secret_id, + } + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + super().restore(snapshot) + self.client_id = snapshot["client_id"] + self.client_secret_id = snapshot["client_secret_id"] + + +class InvalidClientConfigEvent(EventBase): + """Event to notify the charm that the client configuration is invalid.""" + + def __init__(self, handle: Handle, error: str): + super().__init__(handle) + self.error = error + + def snapshot(self) -> Dict: + """Save event.""" + return { + "error": self.error, + } + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + self.error = snapshot["error"] + + +class OAuthInfoRemovedEvent(EventBase): + """Event to notify the charm that the provider data was removed.""" + + def snapshot(self) -> Dict: + """Save event.""" + return {} + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + pass + + +class OAuthRequirerEvents(ObjectEvents): + """Event descriptor for events raised by `OAuthRequirerEvents`.""" + + oauth_info_changed = EventSource(OAuthInfoChangedEvent) + oauth_info_removed = EventSource(OAuthInfoRemovedEvent) + invalid_client_config = EventSource(InvalidClientConfigEvent) + + +class OAuthRequirer(OAuthRelation): + """Register an oauth client.""" + + on = OAuthRequirerEvents() + + def __init__( + self, + charm: CharmBase, + client_config: Optional[ClientConfig] = None, + relation_name: str = DEFAULT_RELATION_NAME, + ) -> None: + super().__init__(charm, relation_name) + self._charm = charm + self._relation_name = relation_name + self._client_config = client_config + events = self._charm.on[relation_name] + self.framework.observe(events.relation_created, self._on_relation_created_event) + self.framework.observe(events.relation_changed, self._on_relation_changed_event) + self.framework.observe(events.relation_broken, self._on_relation_broken_event) + + def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: + try: + self._update_relation_data(self._client_config, event.relation.id) + except ClientConfigError as e: + self.on.invalid_client_config.emit(e.args[0]) + + def _on_relation_broken_event(self, event: RelationBrokenEvent) -> None: + # This may be caused by a provider unit being removed. + # Also the oauth data may still be there, perhaps we should remove this event altogether for now. + + # Notify the requirer that the relation data was removed + self.on.oauth_info_removed.emit() + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + data = event.relation.data[event.app] + if not data: + logger.info("No relation data available.") + return + + data = _load_data(data, OAUTH_PROVIDER_JSON_SCHEMA) + + client_id = data.get("client_id") + client_secret_id = data.get("client_secret_id") + if not client_id or not client_secret_id: + logger.info("OAuth Provider info is available, waiting for client to be registered.") + # The client credentials are not ready yet, so we do nothing + # This could mean that the client credentials were removed from the databag, + # but we don't allow that (for now), so we don't have to check for it. + return + + self.on.oauth_info_changed.emit(client_id, client_secret_id) + + def _update_relation_data( + self, client_config: Optional[ClientConfig], relation_id: Optional[int] = None + ) -> None: + if not self.model.unit.is_leader() or not client_config: + return + + if not isinstance(client_config, ClientConfig): + raise ValueError(f"Unexpected client_config type: {type(client_config)}") + + client_config.validate() + + try: + relation = self.model.get_relation( + relation_name=self._relation_name, relation_id=relation_id + ) + except TooManyRelatedAppsError: + raise RuntimeError("More than one relations are defined. Please provide a relation_id") + + if not relation or not relation.app: + return + + data = _dump_data(client_config.to_dict(), OAUTH_REQUIRER_JSON_SCHEMA) + relation.data[self.model.app].update(data) + + def is_client_created(self, relation_id: Optional[int] = None) -> bool: + """Check if the client has been created.""" + if len(self.model.relations) == 0: + return None + try: + relation = self.model.get_relation(self._relation_name, relation_id=relation_id) + except TooManyRelatedAppsError: + raise RuntimeError("More than one relations are defined. Please provide a relation_id") + + if not relation or not relation.app: + return None + + return ( + "client_id" in relation.data[relation.app] + and "client_secret_id" in relation.data[relation.app] + ) + + def get_provider_info( + self, relation_id: Optional[int] = None + ) -> Optional[OauthProviderConfig]: + """Get the provider information from the databag.""" + if len(self.model.relations) == 0: + return None + try: + relation = self.model.get_relation(self._relation_name, relation_id=relation_id) + except TooManyRelatedAppsError: + raise RuntimeError("More than one relations are defined. Please provide a relation_id") + if not relation or not relation.app: + return None + + data = relation.data[relation.app] + if not data: + logger.info("No relation data available.") + return + + data = _load_data(data, OAUTH_PROVIDER_JSON_SCHEMA) + + client_secret_id = data.get("client_secret_id") + if client_secret_id: + _client_secret = self.get_client_secret(client_secret_id) + client_secret = _client_secret.get_content()[CLIENT_SECRET_FIELD] + data["client_secret"] = client_secret + + oauth_provider = OauthProviderConfig.from_dict(data) + return oauth_provider + + def get_client_secret(self, client_secret_id: str) -> Secret: + """Get the client_secret.""" + client_secret = self.model.get_secret(id=client_secret_id) + return client_secret + + def update_client_config( + self, client_config: ClientConfig, relation_id: Optional[int] = None + ) -> None: + """Update the client config stored in the object.""" + self._client_config = client_config + self._update_relation_data(client_config, relation_id=relation_id) + + +class ClientCreatedEvent(EventBase): + """Event to notify the Provider charm to create a new client.""" + + def __init__( + self, + handle: Handle, + redirect_uri: str, + scope: str, + grant_types: List[str], + audience: List, + token_endpoint_auth_method: str, + relation_id: int, + ) -> None: + super().__init__(handle) + self.redirect_uri = redirect_uri + self.scope = scope + self.grant_types = grant_types + self.audience = audience + self.token_endpoint_auth_method = token_endpoint_auth_method + self.relation_id = relation_id + + def snapshot(self) -> Dict: + """Save event.""" + return { + "redirect_uri": self.redirect_uri, + "scope": self.scope, + "grant_types": self.grant_types, + "audience": self.audience, + "token_endpoint_auth_method": self.token_endpoint_auth_method, + "relation_id": self.relation_id, + } + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + self.redirect_uri = snapshot["redirect_uri"] + self.scope = snapshot["scope"] + self.grant_types = snapshot["grant_types"] + self.audience = snapshot["audience"] + self.token_endpoint_auth_method = snapshot["token_endpoint_auth_method"] + self.relation_id = snapshot["relation_id"] + + def to_client_config(self) -> ClientConfig: + """Convert the event information to a ClientConfig object.""" + return ClientConfig( + self.redirect_uri, + self.scope, + self.grant_types, + self.audience, + self.token_endpoint_auth_method, + ) + + +class ClientChangedEvent(EventBase): + """Event to notify the Provider charm that the client config changed.""" + + def __init__( + self, + handle: Handle, + redirect_uri: str, + scope: str, + grant_types: List, + audience: List, + token_endpoint_auth_method: str, + relation_id: int, + client_id: str, + ) -> None: + super().__init__(handle) + self.redirect_uri = redirect_uri + self.scope = scope + self.grant_types = grant_types + self.audience = audience + self.token_endpoint_auth_method = token_endpoint_auth_method + self.relation_id = relation_id + self.client_id = client_id + + def snapshot(self) -> Dict: + """Save event.""" + return { + "redirect_uri": self.redirect_uri, + "scope": self.scope, + "grant_types": self.grant_types, + "audience": self.audience, + "token_endpoint_auth_method": self.token_endpoint_auth_method, + "relation_id": self.relation_id, + "client_id": self.client_id, + } + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + self.redirect_uri = snapshot["redirect_uri"] + self.scope = snapshot["scope"] + self.grant_types = snapshot["grant_types"] + self.audience = snapshot["audience"] + self.token_endpoint_auth_method = snapshot["token_endpoint_auth_method"] + self.relation_id = snapshot["relation_id"] + self.client_id = snapshot["client_id"] + + def to_client_config(self) -> ClientConfig: + """Convert the event information to a ClientConfig object.""" + return ClientConfig( + self.redirect_uri, + self.scope, + self.grant_types, + self.audience, + self.token_endpoint_auth_method, + self.client_id, + ) + + +class ClientDeletedEvent(EventBase): + """Event to notify the Provider charm that the client was deleted.""" + + def __init__( + self, + handle: Handle, + relation_id: int, + ) -> None: + super().__init__(handle) + self.relation_id = relation_id + + def snapshot(self) -> Dict: + """Save event.""" + return {"relation_id": self.relation_id} + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + self.relation_id = snapshot["relation_id"] + + +class OAuthProviderEvents(ObjectEvents): + """Event descriptor for events raised by `OAuthProviderEvents`.""" + + client_created = EventSource(ClientCreatedEvent) + client_changed = EventSource(ClientChangedEvent) + client_deleted = EventSource(ClientDeletedEvent) + + +class OAuthProvider(OAuthRelation): + """A provider object for OIDC Providers.""" + + on = OAuthProviderEvents() + + def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME) -> None: + super().__init__(charm, relation_name) + self._charm = charm + self._relation_name = relation_name + + events = self._charm.on[relation_name] + self.framework.observe( + events.relation_changed, + self._get_client_config_from_relation_data, + ) + self.framework.observe( + events.relation_broken, + self._on_relation_broken, + ) + + def _get_client_config_from_relation_data(self, event: RelationChangedEvent) -> None: + if not self.model.unit.is_leader(): + return + + data = event.relation.data[event.app] + if not data: + logger.info("No requirer relation data available.") + return + + client_data = _load_data(data, OAUTH_REQUIRER_JSON_SCHEMA) + redirect_uri = client_data.get("redirect_uri") + scope = client_data.get("scope") + grant_types = client_data.get("grant_types") + audience = client_data.get("audience") + token_endpoint_auth_method = client_data.get("token_endpoint_auth_method") + + data = event.relation.data[self._charm.app] + if not data: + logger.info("No provider relation data available.") + return + provider_data = _load_data(data, OAUTH_PROVIDER_JSON_SCHEMA) + client_id = provider_data.get("client_id") + + relation_id = event.relation.id + + if client_id: + # Modify an existing client + self.on.client_changed.emit( + redirect_uri, + scope, + grant_types, + audience, + token_endpoint_auth_method, + relation_id, + client_id, + ) + else: + # Create a new client + self.on.client_created.emit( + redirect_uri, scope, grant_types, audience, token_endpoint_auth_method, relation_id + ) + + def _get_secret_label(self, relation: Relation) -> str: + return f"client_secret_{relation.id}" + + def _on_relation_broken(self, event: RelationBrokenEvent) -> None: + # There is no way to tell if this event was emitted because the relation was removed or if one of + # the applications was scaled down. Until this is fixed, we don't delete the client. + # Workaround for https://github.com/canonical/operator/issues/888 + # self._pop_relation_data(event.relation.id) + + # self._delete_juju_secret(event.relation) + self.on.client_deleted.emit(event.relation.id) + + def _create_juju_secret(self, client_secret: str, relation: Relation) -> Secret: + """Create a juju secret and grant it to a relation.""" + secret = {CLIENT_SECRET_FIELD: client_secret} + juju_secret = self.model.app.add_secret(secret, label=self._get_secret_label(relation)) + juju_secret.grant(relation) + return juju_secret + + def _delete_juju_secret(self, relation: Relation) -> None: + try: + secret = self.model.get_secret(label=self._get_secret_label(relation)) + except SecretNotFoundError: + return + else: + secret.remove_all_revisions() + + def remove_secret(self, relation: Relation) -> None: + return self._delete_juju_secret(relation) + + def set_provider_info_in_relation_data( + self, + issuer_url: str, + authorization_endpoint: str, + token_endpoint: str, + introspection_endpoint: str, + userinfo_endpoint: str, + jwks_endpoint: str, + scope: str, + groups: Optional[str] = None, + ca_chain: Optional[str] = None, + jwt_access_token: Optional[bool] = False, + ) -> None: + """Put the provider information in the databag.""" + if not self.model.unit.is_leader(): + return + + data = { + "issuer_url": issuer_url, + "authorization_endpoint": authorization_endpoint, + "token_endpoint": token_endpoint, + "introspection_endpoint": introspection_endpoint, + "userinfo_endpoint": userinfo_endpoint, + "jwks_endpoint": jwks_endpoint, + "scope": scope, + "jwt_access_token": jwt_access_token, + } + if groups: + data["groups"] = groups + if ca_chain: + data["ca_chain"] = ca_chain + + for relation in self.model.relations[self._relation_name]: + relation.data[self.model.app].update(_dump_data(data)) + + def set_client_credentials_in_relation_data( + self, relation_id: int, client_id: str, client_secret: str + ) -> None: + """Put the client credentials in the databag.""" + if not self.model.unit.is_leader(): + return + + relation = self.model.get_relation(self._relation_name, relation_id) + if not relation or not relation.app: + return + # TODO: What if we are refreshing the client_secret? We need to add a + # new revision for that + secret = self._create_juju_secret(client_secret, relation) + data = {"client_id": client_id, "client_secret_id": secret.id} + relation.data[self.model.app].update(_dump_data(data)) diff --git a/haproxy_spoe_auth_operator/pyproject.toml b/haproxy_spoe_auth_operator/pyproject.toml index 239549f42..f8f12d079 100644 --- a/haproxy_spoe_auth_operator/pyproject.toml +++ b/haproxy_spoe_auth_operator/pyproject.toml @@ -14,6 +14,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ + "charmlibs-snap==1.0.0", "jinja2==3.1.6", "ops==3.3.1", "pydantic==2.12.4", diff --git a/haproxy_spoe_auth_operator/src/charm.py b/haproxy_spoe_auth_operator/src/charm.py index 678881eb4..22f45063d 100644 --- a/haproxy_spoe_auth_operator/src/charm.py +++ b/haproxy_spoe_auth_operator/src/charm.py @@ -9,12 +9,11 @@ import typing import ops - -from spoe_auth_service import ( +from haproxy_spoe_auth_operator.src.haproxy_spoe_auth_service import ( SpoeAuthService, SpoeAuthServiceConfigError, - SpoeAuthServiceInstallError, ) + from state.charm_state import CharmState, InvalidCharmConfigError, ProxyMode from state.exception import CharmStateValidationBaseError from state.oauth import OAuthInformation @@ -39,56 +38,17 @@ def __init__(self, *args: typing.Any): # OAuth requirer will be added here once the library is fetched # Example: self.oauth = OAuthRequirer(self, relation_name=OAUTH_RELATION) - self.framework.observe(self.on.install, self._on_install) - self.framework.observe(self.on.config_changed, self._on_config_changed) + self.framework.observe(self.on.install, self._reconcile) + self.framework.observe(self.on.config_changed, self._reconcile) self.framework.observe( - self.on[OAUTH_RELATION].relation_changed, self._on_oauth_relation_changed + self.on[OAUTH_RELATION].relation_changed, self._reconcile ) self.framework.observe( - self.on[OAUTH_RELATION].relation_broken, self._on_oauth_relation_broken + self.on[OAUTH_RELATION].relation_broken, self._reconcile ) - def _on_install(self, _event: ops.InstallEvent) -> None: - """Handle install event. - - Args: - _event: The install event. - """ - try: - self.service.install() - self.unit.status = ops.MaintenanceStatus("Service installed") - except SpoeAuthServiceInstallError as exc: - logger.exception("Failed to install service") - self.unit.status = ops.BlockedStatus(f"Installation failed: {exc}") - return - - self._reconcile() - - def _on_config_changed(self, _event: ops.ConfigChangedEvent) -> None: - """Handle config changed event. - - Args: - _event: The config changed event. - """ - self._reconcile() - - def _on_oauth_relation_changed(self, _event: ops.RelationChangedEvent) -> None: - """Handle oauth relation changed event. - - Args: - _event: The relation changed event. - """ - self._reconcile() - - def _on_oauth_relation_broken(self, _event: ops.RelationBrokenEvent) -> None: - """Handle oauth relation broken event. - Args: - _event: The relation broken event. - """ - self._reconcile() - - def _reconcile(self) -> None: + def _reconcile(self, _: ops.EventBase) -> None: """Reconcile the charm state and service configuration.""" try: charm_state = self._get_charm_state() @@ -96,11 +56,6 @@ def _reconcile(self) -> None: self.service.reconcile(charm_state, oauth_info) - if charm_state.mode == ProxyMode.OAUTH: - self.unit.status = ops.ActiveStatus("OAuth authentication enabled") - else: - self.unit.status = ops.ActiveStatus("Service running without authentication") - except CharmStateValidationBaseError as exc: logger.exception("Charm state validation failed") self.unit.status = ops.BlockedStatus(f"Configuration error: {exc}") @@ -111,25 +66,6 @@ def _reconcile(self) -> None: logger.exception("Unexpected error during reconciliation") self.unit.status = ops.BlockedStatus(f"Unexpected error: {exc}") - def _get_charm_state(self) -> CharmState: - """Get the current charm state. - - Returns: - CharmState: The current charm state. - - Raises: - InvalidCharmConfigError: When the charm configuration is invalid. - """ - try: - oauth_relation = self.model.get_relation(OAUTH_RELATION) - mode = ProxyMode.OAUTH if oauth_relation else ProxyMode.NOAUTH - - spoe_address = self.config.get("spoe-address", "127.0.0.1:3000") - - return CharmState(mode=mode, spoe_address=spoe_address) - except Exception as exc: - raise InvalidCharmConfigError(f"Invalid charm configuration: {exc}") from exc - if __name__ == "__main__": # pragma: nocover ops.main(HaproxySpoeAuthCharm) diff --git a/haproxy_spoe_auth_operator/src/spoe_auth_service.py b/haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py similarity index 61% rename from haproxy_spoe_auth_operator/src/spoe_auth_service.py rename to haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py index 38dfad9d1..ba558a249 100644 --- a/haproxy_spoe_auth_operator/src/spoe_auth_service.py +++ b/haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py @@ -6,13 +6,13 @@ import logging from pathlib import Path +from charmlibs import snap from jinja2 import Environment, FileSystemLoader, select_autoescape from state.charm_state import CharmState from state.oauth import OAuthInformation SNAP_NAME = "haproxy-spoe-auth" -SERVICE_NAME = "haproxy-spoe-auth" CONFIG_PATH = Path("/var/snap/haproxy-spoe-auth/current/config.yaml") CONFIG_TEMPLATE = "config.yaml.j2" @@ -33,6 +33,8 @@ class SpoeAuthService: def __init__(self) -> None: """Initialize the service.""" self._template_dir = Path(__file__).parent.parent / "templates" + cache = snap.SnapCache() + self.haproxy_spoe_auth_snap = cache[SNAP_NAME] def install(self) -> None: """Install the haproxy-spoe-auth snap. @@ -41,39 +43,12 @@ def install(self) -> None: SpoeAuthServiceInstallError: When snap installation fails. """ try: - # Import here to avoid issues when snap module is not available - import subprocess # nosec B404 - - subprocess.run( # nosec B603 B607 - ["snap", "install", SNAP_NAME, "--edge"], - check=True, - capture_output=True, - text=True, - ) - logger.info("Successfully installed %s snap", SNAP_NAME) - except Exception as exc: - raise SpoeAuthServiceInstallError( - f"Failed to install {SNAP_NAME} snap: {exc}" - ) from exc - - def is_active(self) -> bool: - """Check if the service is active. + if not self.haproxy_spoe_auth_snap.present: + self.haproxy_spoe_auth_snap.ensure(snap.SnapState.Latest, channel="edege") + self.haproxy_spoe_auth_snap.restart(reload=True) + except snap.SnapError as e: + logger.error("An exception occurred when installing charmcraft. Reason: %s", e.message) - Returns: - True if the service is running, False otherwise. - """ - try: - import subprocess # nosec B404 - - result = subprocess.run( # nosec B603 B607 - ["snap", "services", SNAP_NAME], - check=True, - capture_output=True, - text=True, - ) - return "active" in result.stdout - except Exception: - return False def reconcile(self, charm_state: CharmState, oauth_info: OAuthInformation) -> None: """Reconcile the service configuration. @@ -87,8 +62,7 @@ def reconcile(self, charm_state: CharmState, oauth_info: OAuthInformation) -> No """ try: self._render_config(charm_state, oauth_info) - self._restart_service() - logger.info("Successfully reconciled %s service", SERVICE_NAME) + self.haproxy_spoe_auth_snap.restart(reload=True) except Exception as exc: raise SpoeAuthServiceConfigError(f"Failed to reconcile service: {exc}") from exc @@ -115,15 +89,3 @@ def _render_config(self, charm_state: CharmState, oauth_info: OAuthInformation) CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) CONFIG_PATH.write_text(config_content, encoding="utf-8") logger.info("Configuration written to %s", CONFIG_PATH) - - def _restart_service(self) -> None: - """Restart the service.""" - import subprocess # nosec B404 - - subprocess.run( # nosec B603 B607 - ["snap", "restart", SNAP_NAME], - check=True, - capture_output=True, - text=True, - ) - logger.info("Service %s restarted", SERVICE_NAME) diff --git a/haproxy_spoe_auth_operator/templates/config.yaml.j2 b/haproxy_spoe_auth_operator/templates/config.yaml.j2 index 3d0ae3dd6..195e9d183 100644 --- a/haproxy_spoe_auth_operator/templates/config.yaml.j2 +++ b/haproxy_spoe_auth_operator/templates/config.yaml.j2 @@ -7,7 +7,8 @@ spoe: {% if oauth_enabled %} oauth: - client_id: {{ oauth_data.client_id }} - client_secret: {{ oauth_data.client_secret }} - provider_url: {{ oauth_data.provider_url }} + {{ hostname }}: + client_id: {{ oauth_data.client_id }} + client_secret: {{ oauth_data.client_secret }} + provider_url: {{ oauth_data.provider_url }} {% endif %} diff --git a/haproxy_spoe_auth_operator/uv.lock b/haproxy_spoe_auth_operator/uv.lock index 088e18edd..1b80453c4 100644 --- a/haproxy_spoe_auth_operator/uv.lock +++ b/haproxy_spoe_auth_operator/uv.lock @@ -167,6 +167,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "charmlibs-snap" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/19/1d726478bddd55686ab99faa54d5772744c00f36e5ea97479d5f7be9662d/charmlibs_snap-1.0.0.tar.gz", hash = "sha256:eba3cb8cec766d138df732a9ddcb9a902f5d987ecc156c51d3244d1b3f5116e4", size = 29102, upload-time = "2025-09-23T00:47:45.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/8b/51ebbd3f652db426a6caed0b4762f3a197e99a6113ab568a25cb947a34d6/charmlibs_snap-1.0.0-py3-none-any.whl", hash = "sha256:ea0dd74b75719b8bbfc475e2d1157500cf4872b7cf4ba4e3f792d9aa5726277e", size = 15336, upload-time = "2025-09-23T00:47:44.36Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -391,6 +403,7 @@ name = "haproxy-spoe-auth-operator" version = "0.0.0" source = { virtual = "." } dependencies = [ + { name = "charmlibs-snap" }, { name = "jinja2" }, { name = "ops" }, { name = "pydantic" }, @@ -429,6 +442,7 @@ unit = [ [package.metadata] requires-dist = [ + { name = "charmlibs-snap", specifier = "==1.0.0" }, { name = "jinja2", specifier = "==3.1.6" }, { name = "ops", specifier = "==3.3.1" }, { name = "pydantic", specifier = "==2.12.4" }, From f53baabdc858cfd7a0f7d7a28b6bf8dabb3ba735 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 13 Nov 2025 21:10:16 +0100 Subject: [PATCH 07/48] update charmcode, fix state management + add spoe-auth relation --- haproxy_spoe_auth_operator/pyproject.toml | 4 + haproxy_spoe_auth_operator/src/charm.py | 62 +++++--- .../src/haproxy_spoe_auth_service.py | 35 +++-- haproxy_spoe_auth_operator/src/state.py | 147 ++++++++++++++++++ .../src/state/__init__.py | 4 - .../src/state/charm_state.py | 43 ----- .../src/state/exception.py | 8 - haproxy_spoe_auth_operator/src/state/oauth.py | 86 ---------- .../templates/config.yaml.j2 | 40 +++-- haproxy_spoe_auth_operator/uv.lock | 11 ++ 10 files changed, 250 insertions(+), 190 deletions(-) create mode 100644 haproxy_spoe_auth_operator/src/state.py delete mode 100644 haproxy_spoe_auth_operator/src/state/__init__.py delete mode 100644 haproxy_spoe_auth_operator/src/state/charm_state.py delete mode 100644 haproxy_spoe_auth_operator/src/state/exception.py delete mode 100644 haproxy_spoe_auth_operator/src/state/oauth.py diff --git a/haproxy_spoe_auth_operator/pyproject.toml b/haproxy_spoe_auth_operator/pyproject.toml index f8f12d079..d321745b8 100644 --- a/haproxy_spoe_auth_operator/pyproject.toml +++ b/haproxy_spoe_auth_operator/pyproject.toml @@ -14,6 +14,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ + "charmlibs-interfaces-haproxy-spoe-auth", "charmlibs-snap==1.0.0", "jinja2==3.1.6", "ops==3.3.1", @@ -43,6 +44,9 @@ integration = [ [tool.uv] package = false +[tool.uv.sources] +charmlibs-interfaces-haproxy-spoe-auth = { git = "https://github.com/Thanhphan1147/charmlibs", subdirectory = "interfaces/haproxy_spoe_auth", rev = "feat/add-spoe-auth-lib" } + [tool.ruff] target-version = "py310" line-length = 99 diff --git a/haproxy_spoe_auth_operator/src/charm.py b/haproxy_spoe_auth_operator/src/charm.py index 22f45063d..f5a43cfa2 100644 --- a/haproxy_spoe_auth_operator/src/charm.py +++ b/haproxy_spoe_auth_operator/src/charm.py @@ -9,18 +9,25 @@ import typing import ops +from charmlibs.interfaces.haproxy_spoe_auth import HaproxyEvent, SpoeAuthProvider +from haproxy_spoe_auth_operator.lib.charms.hydra.v0.oauth import ClientConfig, OAuthRequirer from haproxy_spoe_auth_operator.src.haproxy_spoe_auth_service import ( SpoeAuthService, SpoeAuthServiceConfigError, ) -from state.charm_state import CharmState, InvalidCharmConfigError, ProxyMode -from state.exception import CharmStateValidationBaseError -from state.oauth import OAuthInformation +from .state import CharmState, InvalidCharmConfigError, OauthInformation logger = logging.getLogger(__name__) OAUTH_RELATION = "oauth" +OIDC_CALLBACK_PATH = "/oauth2/callback" +SPOP_PORT = 8081 +OIDC_CALLBACK_PORT = 5000 +OIDC_SCOPE = "openid email profile" +VAR_AUTHENTICATED = "sess.auth.is_authenticated" +VAR_REDIRECT_URL = "sess.auth.redirect_url" +COOKIE_NAME = "authsession" class HaproxySpoeAuthCharm(ops.CharmBase): @@ -34,38 +41,47 @@ def __init__(self, *args: typing.Any): """ super().__init__(*args) self.service = SpoeAuthService() - - # OAuth requirer will be added here once the library is fetched - # Example: self.oauth = OAuthRequirer(self, relation_name=OAUTH_RELATION) + self._spoe_auth_provider = SpoeAuthProvider(self, relation_name="spoe-auth") + self._oauth = OAuthRequirer(self, relation_name=OAUTH_RELATION) self.framework.observe(self.on.install, self._reconcile) self.framework.observe(self.on.config_changed, self._reconcile) - self.framework.observe( - self.on[OAUTH_RELATION].relation_changed, self._reconcile - ) - self.framework.observe( - self.on[OAUTH_RELATION].relation_broken, self._reconcile - ) - + self.framework.observe(self._oauth.on.oauth_info_changed, self._reconcile) + self.framework.observe(self._oauth.on.oauth_info_removed, self._reconcile) def _reconcile(self, _: ops.EventBase) -> None: """Reconcile the charm state and service configuration.""" try: - charm_state = self._get_charm_state() - oauth_info = OAuthInformation.from_charm(self) - - self.service.reconcile(charm_state, oauth_info) - - except CharmStateValidationBaseError as exc: + state = CharmState.from_charm(self) + + self._oauth.update_client_config( + client_config=ClientConfig( + redirect_uri=f"https://{state.hostname}{OIDC_CALLBACK_PATH}", + scope=OIDC_SCOPE, + grant_types=["authorization_code"], + ) + ) + oauth_information = OauthInformation.from_charm(self, self._oauth) + self._spoe_auth_provider.provide_spoe_auth_requirements( + relation=oauth_information.spoe_auth_relation, + spop_port=SPOP_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + oidc_callback_port=OIDC_CALLBACK_PORT, + var_authenticated=VAR_AUTHENTICATED, + var_redirect_url=VAR_REDIRECT_URL, + cookie_name=COOKIE_NAME, + oidc_callback_hostname=state.hostname, + oidc_callback_path=OIDC_CALLBACK_PATH, + ) + self.service.reconcile(charm_state=state, oauth_information=oauth_information) + self.unit.status = ops.ActiveStatus() + + except InvalidCharmConfigError as exc: logger.exception("Charm state validation failed") self.unit.status = ops.BlockedStatus(f"Configuration error: {exc}") except SpoeAuthServiceConfigError as exc: logger.exception("Service configuration failed") self.unit.status = ops.BlockedStatus(f"Service configuration failed: {exc}") - except Exception as exc: - logger.exception("Unexpected error during reconciliation") - self.unit.status = ops.BlockedStatus(f"Unexpected error: {exc}") - if __name__ == "__main__": # pragma: nocover ops.main(HaproxySpoeAuthCharm) diff --git a/haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py b/haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py index ba558a249..fa5b1791a 100644 --- a/haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py +++ b/haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py @@ -9,8 +9,7 @@ from charmlibs import snap from jinja2 import Environment, FileSystemLoader, select_autoescape -from state.charm_state import CharmState -from state.oauth import OAuthInformation +from .state import CharmState, OauthInformation SNAP_NAME = "haproxy-spoe-auth" CONFIG_PATH = Path("/var/snap/haproxy-spoe-auth/current/config.yaml") @@ -49,43 +48,45 @@ def install(self) -> None: except snap.SnapError as e: logger.error("An exception occurred when installing charmcraft. Reason: %s", e.message) - - def reconcile(self, charm_state: CharmState, oauth_info: OAuthInformation) -> None: + def reconcile(self, charm_state: CharmState, oauth_information: OauthInformation) -> None: """Reconcile the service configuration. Args: - charm_state: The current charm state. - oauth_info: OAuth integration information. + charm_state: The charm state. + oauth_information: OAuth integration information. Raises: SpoeAuthServiceConfigError: When configuration fails. """ try: - self._render_config(charm_state, oauth_info) + self._render_config(charm_state, oauth_information) self.haproxy_spoe_auth_snap.restart(reload=True) except Exception as exc: raise SpoeAuthServiceConfigError(f"Failed to reconcile service: {exc}") from exc - def _render_config(self, charm_state: CharmState, oauth_info: OAuthInformation) -> None: + def _render_config(self, charm_state: CharmState, oauth_information: OauthInformation) -> None: """Render the configuration file. Args: - charm_state: The current charm state. - oauth_info: OAuth integration information. + charm_state: The charm state. + oauth_information: OAuth integration information. """ env = Environment( - loader=FileSystemLoader(str(self._template_dir)), + loader=FileSystemLoader("templates"), autoescape=select_autoescape(), + keep_trailing_newline=True, + trim_blocks=True, + lstrip_blocks=True, ) template = env.get_template(CONFIG_TEMPLATE) - config_content = template.render( - spoe_address=charm_state.spoe_address, - oauth_enabled=oauth_info.oauth_data is not None, - oauth_data=oauth_info.oauth_data if oauth_info.oauth_data else {}, + hostname=charm_state.hostname, + issuer_url=oauth_information.issuer_url, + client_id=oauth_information.client_id, + client_secret=oauth_information.client_secret, + signature_secret=charm_state.signature_secret, + encryption_secret=charm_state.encryption_secret, ) # Ensure parent directory exists - CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) CONFIG_PATH.write_text(config_content, encoding="utf-8") - logger.info("Configuration written to %s", CONFIG_PATH) diff --git a/haproxy_spoe_auth_operator/src/state.py b/haproxy_spoe_auth_operator/src/state.py new file mode 100644 index 000000000..c9325e12a --- /dev/null +++ b/haproxy_spoe_auth_operator/src/state.py @@ -0,0 +1,147 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""haproxy-spoe-auth-operator charm state.""" + +import logging +import typing +import secrets +import ops +from pydantic import Field, ValidationError +from pydantic.dataclasses import dataclass + +from lib.charms.hydra.v0.oauth import OAuthRequirer + +logger = logging.getLogger(__name__) + +SPOE_AUTH_RELATION = "spoe-auth" +AGENT_SECRETS_LABEL = "agent-secrets" + + +class InvalidCharmConfigError(Exception): + """Exception raised when a charm configuration is found to be invalid.""" + + +@dataclass(frozen=True) +class CharmState: + """A component of charm state that contains the charm's configuration and mode. + + Attributes: + hostname: The hostname of the charm. + client_id: The OAuth client ID. + client_secret: The OAuth client secret. + issuer_url: The OAuth issuer URL. + """ + + hostname: str = Field(description="The hostname part of the redirect URL.") + signature_secret: str = Field( + description="Secret used for signing by the SPOE agent.", min_length=1 + ) + encryption_secret: str = Field( + description="Secret used for encrypting by the SPOE agent.", min_length=1 + ) + + @classmethod + def from_charm( + cls, + charm: ops.CharmBase, + ) -> "CharmState": + """Create a CharmState class from a charm instance. + + Args: + charm: The spoe-auth agent charm. + + Raises: + InvalidCharmConfigError: When the charm's config is invalid. + + Returns: + CharmState: Instance of the charm state component. + """ + hostname = typing.cast(str, charm.config.get("hostname")) + signature_secret = None + encryption_secret = None + try: + secret = charm.model.get_secret(label=AGENT_SECRETS_LABEL) + signing_and_encryption_secrets = secret.get_content(refresh=True) + signature_secret = signing_and_encryption_secrets.get("signature_secret") + encryption_secret = signing_and_encryption_secrets.get("encryption_secret") + except ops.SecretNotFoundError: + signature_secret = secrets.token_urlsafe(32) + encryption_secret = secrets.token_urlsafe(32) + secret = charm.model.app.add_secret( + content={ + "signature_secret": signature_secret, + "encryption_secret": encryption_secret, + }, + label=AGENT_SECRETS_LABEL, + ) + + if signature_secret is None or encryption_secret is None: + raise InvalidCharmConfigError("Error fetching agent secrets.") + + try: + return cls( + hostname=hostname, + signature_secret=signature_secret, + encryption_secret=encryption_secret, + ) + except ValidationError as exc: + raise InvalidCharmConfigError("Invalid configuration") from exc + + +@dataclass(frozen=True) +class OauthInformation: + """A component of charm state that contains the charm's configuration and mode. + + Attributes: + client_id: The OAuth client ID. + client_secret: The OAuth client secret. + issuer_url: The OAuth issuer URL. + spoe_auth_relation: The spoe-auth relation. + """ + + issuer_url: str = Field(description="The OAuth issuer URL.", min_length=1) + client_id: str = Field(description="The OAuth client ID.", min_length=1) + client_secret: str = Field(description="The OAuth client secret.", min_length=1) + spoe_auth_relation: ops.Relation = Field(description="The spoe-auth relation.") + + @classmethod + def from_charm( + cls, + charm: ops.CharmBase, + oauth: OAuthRequirer, + ) -> "OauthInformation": + """Create a CharmState class from a charm instance. + + Args: + charm: The haproxy charm. + oauth: The OAuthRequirer instance. + + Raises: + InvalidCharmConfigError: When the charm's config is invalid. + + Returns: + CharmState: Instance of the charm state component. + """ + spoe_auth_relation = charm.model.get_relation(SPOE_AUTH_RELATION) + if spoe_auth_relation is None: + logger.error("spoe-auth relation missing.") + raise InvalidCharmConfigError("spoe-auth relation missing.") + + oauth_provider_information = oauth.get_provider_info() + if ( + oauth_provider_information is None + or oauth_provider_information.client_id is None + or oauth_provider_information.client_secret is None + ): + raise InvalidCharmConfigError("Waiting for complete oauth relation data.") + + try: + return cls( + issuer_url=oauth_provider_information.issuer_url, + client_id=oauth_provider_information.client_id, + client_secret=oauth_provider_information.client_secret, + spoe_auth_relation=spoe_auth_relation, + ) + except ValidationError as exc: + raise InvalidCharmConfigError("Invalid configuration") from exc diff --git a/haproxy_spoe_auth_operator/src/state/__init__.py b/haproxy_spoe_auth_operator/src/state/__init__.py deleted file mode 100644 index 8eccbc7f7..000000000 --- a/haproxy_spoe_auth_operator/src/state/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - -"""State module for haproxy-spoe-auth-operator.""" diff --git a/haproxy_spoe_auth_operator/src/state/charm_state.py b/haproxy_spoe_auth_operator/src/state/charm_state.py deleted file mode 100644 index 8cd7f83bf..000000000 --- a/haproxy_spoe_auth_operator/src/state/charm_state.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - -"""haproxy-spoe-auth-operator charm state.""" - -import logging -from enum import StrEnum - -from pydantic import Field -from pydantic.dataclasses import dataclass - -from .exception import CharmStateValidationBaseError - -logger = logging.getLogger(__name__) - - -class InvalidCharmConfigError(CharmStateValidationBaseError): - """Exception raised when a charm configuration is found to be invalid.""" - - -class ProxyMode(StrEnum): - """StrEnum of possible authentication modes. - - Attrs: - OAUTH: When oauth relation is established. - NOAUTH: When no authentication is configured. - """ - - OAUTH = "oauth" - NOAUTH = "noauth" - - -@dataclass(frozen=True) -class CharmState: - """A component of charm state that contains the charm's configuration and mode. - - Attributes: - mode: The current authentication mode of the charm. - spoe_address: The address for SPOE agent to listen on. - """ - - mode: ProxyMode - spoe_address: str = Field(alias="spoe_address") diff --git a/haproxy_spoe_auth_operator/src/state/exception.py b/haproxy_spoe_auth_operator/src/state/exception.py deleted file mode 100644 index 89a9a8526..000000000 --- a/haproxy_spoe_auth_operator/src/state/exception.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Exceptions for charm state.""" - - -class CharmStateValidationBaseError(Exception): - """Base exception for charm state validation errors.""" diff --git a/haproxy_spoe_auth_operator/src/state/oauth.py b/haproxy_spoe_auth_operator/src/state/oauth.py deleted file mode 100644 index 9dabd4bff..000000000 --- a/haproxy_spoe_auth_operator/src/state/oauth.py +++ /dev/null @@ -1,86 +0,0 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - -"""OAuth relation state management.""" - -import logging -from typing import Optional - -from pydantic import Field, ValidationError -from pydantic.dataclasses import dataclass - -from .exception import CharmStateValidationBaseError - -logger = logging.getLogger(__name__) - - -class OAuthDataValidationError(CharmStateValidationBaseError): - """Exception raised when OAuth relation data validation fails.""" - - -@dataclass(frozen=True) -class OAuthData: - """OAuth relation data. - - Attributes: - client_id: The OAuth client ID. - client_secret: The OAuth client secret. - provider_url: The OAuth provider URL (issuer_url from the library). - """ - - client_id: str = Field(min_length=1) - client_secret: str = Field(min_length=1) - provider_url: str = Field(min_length=1) - - -@dataclass(frozen=True) -class OAuthInformation: - """OAuth integration information. - - Attributes: - oauth_data: OAuth configuration data. - """ - - oauth_data: Optional[OAuthData] - - @classmethod - def from_charm(cls, charm: "ops.CharmBase") -> "OAuthInformation": # type: ignore # noqa: F821 - """Initialize OAuthInformation from charm. - - Args: - charm: The charm instance. - - Returns: - OAuthInformation instance. - - Raises: - OAuthDataValidationError: When OAuth data validation fails. - """ - # Once the OAuth library is added, this will use the requirer object - # Example: - # if not charm.oauth.is_client_created(): - # return cls(oauth_data=None) - # - # oauth_info = charm.oauth.get_client_config() - # oauth_data = OAuthData( - # client_id=oauth_info.client_id, - # client_secret=oauth_info.client_secret, - # provider_url=oauth_info.issuer_url, - # ) - # return cls(oauth_data=oauth_data) - - # Temporary implementation until library is added - oauth_relation = charm.model.get_relation("oauth") - if not oauth_relation: - return cls(oauth_data=None) - - try: - relation_data = oauth_relation.data[oauth_relation.app] - oauth_data = OAuthData( - client_id=relation_data.get("client_id", ""), - client_secret=relation_data.get("client_secret", ""), - provider_url=relation_data.get("provider_url", ""), - ) - return cls(oauth_data=oauth_data) - except (ValidationError, KeyError) as exc: - raise OAuthDataValidationError(f"Invalid OAuth relation data: {exc}") from exc diff --git a/haproxy_spoe_auth_operator/templates/config.yaml.j2 b/haproxy_spoe_auth_operator/templates/config.yaml.j2 index 195e9d183..0533d3d9c 100644 --- a/haproxy_spoe_auth_operator/templates/config.yaml.j2 +++ b/haproxy_spoe_auth_operator/templates/config.yaml.j2 @@ -1,14 +1,36 @@ -# Copyright 2025 Canonical Ltd. -# See LICENSE file for licensing details. - # HAProxy SPOE Auth Configuration spoe: - address: {{ spoe_address }} + address: 8081 + +oidc: + # The URL to the OpenID Connect provider. This is the URL hosting the discovery endpoint + provider_url: {{ issuer_url }} + + # The callback the OIDC server will redirect the user to once authentication is done + oauth2_callback_path: /oauth2/callback + + # The path to the logout endpoint to redirect the user to. + oauth2_logout_path: /oauth2/logout + + # The path the oidc client uses for a healthcheck + oauth2_healthcheck_path: /health + + # The SPOE agent will open a dedicated port for the HTTP server handling the callback. This is the address the server listens on + callback_addr: ":5000" + + # Various properties of the cookie holding the ID Token of the user + cookie_name: authsession + cookie_secure: true + # If not set, then uses ID token expire timestamp + cookie_ttl_seconds: 3600 + + # The secret used to sign the state parameter + signature_secret: {{ signature_secret }} + # The secret used to encrypt the cookie in order to guarantee the privacy of the data in case of leak + encryption_secret: {{ encryption_secret }} -{% if oauth_enabled %} oauth: {{ hostname }}: - client_id: {{ oauth_data.client_id }} - client_secret: {{ oauth_data.client_secret }} - provider_url: {{ oauth_data.provider_url }} -{% endif %} + client_id: {{ client_id }} + client_secret: {{ client_secret }} + redirect_url: https://{{ hostname }}/oauth2/callback diff --git a/haproxy_spoe_auth_operator/uv.lock b/haproxy_spoe_auth_operator/uv.lock index 1b80453c4..2d2ce608d 100644 --- a/haproxy_spoe_auth_operator/uv.lock +++ b/haproxy_spoe_auth_operator/uv.lock @@ -167,6 +167,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "charmlibs-interfaces-haproxy-spoe-auth" +version = "0.0.1" +source = { git = "https://github.com/Thanhphan1147/charmlibs?subdirectory=interfaces%2Fhaproxy_spoe_auth&rev=feat%2Fadd-spoe-auth-lib#350ddd0a76df1cd239240a43eb90418555b28321" } +dependencies = [ + { name = "ops" }, + { name = "pydantic" }, +] + [[package]] name = "charmlibs-snap" version = "1.0.0" @@ -403,6 +412,7 @@ name = "haproxy-spoe-auth-operator" version = "0.0.0" source = { virtual = "." } dependencies = [ + { name = "charmlibs-interfaces-haproxy-spoe-auth" }, { name = "charmlibs-snap" }, { name = "jinja2" }, { name = "ops" }, @@ -442,6 +452,7 @@ unit = [ [package.metadata] requires-dist = [ + { name = "charmlibs-interfaces-haproxy-spoe-auth", git = "https://github.com/Thanhphan1147/charmlibs?subdirectory=interfaces%2Fhaproxy_spoe_auth&rev=feat%2Fadd-spoe-auth-lib" }, { name = "charmlibs-snap", specifier = "==1.0.0" }, { name = "jinja2", specifier = "==3.1.6" }, { name = "ops", specifier = "==3.3.1" }, From 84836e6c5018b2063f734294ac9c6cc222a7a4d7 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 13 Nov 2025 21:13:03 +0100 Subject: [PATCH 08/48] update licenserc --- .licenserc.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.licenserc.yaml b/.licenserc.yaml index c0a3d8d6c..893f811f5 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -27,4 +27,5 @@ header: - 'src/loki_alert_rules/*.rule' - 'docs/release-notes/**/*.yaml' - '**/*.md.j2' + - '**/lib/**' comment: on-failure From d90bdf9438dd887a332d84db7fbc322fdbcd921c Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 17 Nov 2025 17:09:01 +0100 Subject: [PATCH 09/48] update docs for charm relation --- haproxy_spoe_auth_operator/charmcraft.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/haproxy_spoe_auth_operator/charmcraft.yaml b/haproxy_spoe_auth_operator/charmcraft.yaml index d57fa53ca..28c7e5cc6 100644 --- a/haproxy_spoe_auth_operator/charmcraft.yaml +++ b/haproxy_spoe_auth_operator/charmcraft.yaml @@ -47,11 +47,14 @@ requires: oauth: interface: oauth description: OAuth authentication provider integration + limit: 1 + optional: false provides: spoe-auth: interface: spoe-auth description: Relation to provide authentication proxy to HAProxy. limit: 1 + optional: false config: hostname: From d0ca935e185caad7a3798c62f9b2d688051fe505 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 17 Nov 2025 17:57:11 +0100 Subject: [PATCH 10/48] fix error in charmcraft.yaml, update tests --- haproxy_spoe_auth_operator/charmcraft.yaml | 11 +- haproxy_spoe_auth_operator/src/state.py | 3 +- .../tests/integration/conftest.py | 75 +++++++++---- .../tests/integration/test_charm.py | 43 +------- .../tests/unit/conftest.py | 69 +++--------- .../tests/unit/test_charm.py | 101 +----------------- 6 files changed, 81 insertions(+), 221 deletions(-) diff --git a/haproxy_spoe_auth_operator/charmcraft.yaml b/haproxy_spoe_auth_operator/charmcraft.yaml index 28c7e5cc6..193e4c8cf 100644 --- a/haproxy_spoe_auth_operator/charmcraft.yaml +++ b/haproxy_spoe_auth_operator/charmcraft.yaml @@ -57,11 +57,12 @@ provides: optional: false config: - hostname: - type: string - description: | - Public hostname of the agent. Used to store client credentials - and to build the redirect URI + options: + hostname: + type: string + description: | + Public hostname of the agent. Used to store client credentials + and to build the redirect URI charm-libs: - lib: hydra.oauth diff --git a/haproxy_spoe_auth_operator/src/state.py b/haproxy_spoe_auth_operator/src/state.py index c9325e12a..2d27c2af9 100644 --- a/haproxy_spoe_auth_operator/src/state.py +++ b/haproxy_spoe_auth_operator/src/state.py @@ -4,8 +4,9 @@ """haproxy-spoe-auth-operator charm state.""" import logging -import typing import secrets +import typing + import ops from pydantic import Field, ValidationError from pydantic.dataclasses import dataclass diff --git a/haproxy_spoe_auth_operator/tests/integration/conftest.py b/haproxy_spoe_auth_operator/tests/integration/conftest.py index b5e8887b4..5bc4f9909 100644 --- a/haproxy_spoe_auth_operator/tests/integration/conftest.py +++ b/haproxy_spoe_auth_operator/tests/integration/conftest.py @@ -3,44 +3,73 @@ """Integration test fixtures for haproxy-spoe-auth-operator.""" +import logging import pathlib +import typing import jubilant import pytest +import yaml +logger = logging.getLogger(__name__) -@pytest.fixture(name="juju", scope="module") -def fixture_juju() -> jubilant.Juju: - """Create a Juju instance for integration testing. +JUJU_WAIT_TIMEOUT = 10 * 60 # 10 minutes - Returns: - A Juju instance. - """ - return jubilant.Juju(model_config={"logging-config": "=INFO"}) +@pytest.fixture(scope="session", name="charm") +def charm_fixture(pytestconfig: pytest.Config): + """Pytest fixture that packs the charm and returns the filename, or --charm-file if set.""" + charm = pytestconfig.getoption("--charm-file") + assert charm, "--charm-file must be set" + return charm -@pytest.fixture(name="charm_path", scope="module") -def fixture_charm_path() -> str: - """Return the path to the charm directory. - Returns: - Path to the charm directory. - """ - return str(pathlib.Path(__file__).parent.parent.parent) +@pytest.fixture(scope="module", name="juju") +def juju_fixture(request: pytest.FixtureRequest): + """Pytest fixture that wraps :meth:`jubilant.with_model`.""" + + def show_debug_log(juju: jubilant.Juju): + """Show the debug log if tests failed. + + Args: + juju: Jubilant juju instance. + """ + if request.session.testsfailed: + log = juju.debug_log(limit=1000) + print(log, end="") + + model = request.config.getoption("--model") + if model: + juju = jubilant.Juju(model=model) + juju.wait_timeout = JUJU_WAIT_TIMEOUT + yield juju + show_debug_log(juju) + return + + keep_models = typing.cast(bool, request.config.getoption("--keep-models")) + with jubilant.temp_model(keep=keep_models) as juju: + juju.wait_timeout = JUJU_WAIT_TIMEOUT + yield juju -@pytest.fixture(name="application", scope="module") -def fixture_application(juju: jubilant.Juju, charm_path: str) -> str: - """Deploy the haproxy-spoe-auth charm. +@pytest.fixture(scope="module", name="application") +def application_fixture(pytestconfig: pytest.Config, juju: jubilant.Juju, charm: str): + """Deploy the haproxy application. Args: - juju: The Juju instance. - charm_path: Path to the charm. + juju: Jubilant juju fixture. + charm_file: Path to the packed charm file. Returns: - The application name. + The haproxy app name. """ - app_name = "haproxy-spoe-auth" - juju.deploy(charm_path, app_name) - juju.wait(lambda status: jubilant.all_active(status, app_name)) + metadata = yaml.safe_load(pathlib.Path("./charmcraft.yaml").read_text(encoding="UTF-8")) + app_name = metadata["name"] + if pytestconfig.getoption("--no-deploy") and app_name in juju.status().apps: + return app_name + juju.deploy( + charm=charm, + app=app_name, + base="ubuntu@24.04", + ) return app_name diff --git a/haproxy_spoe_auth_operator/tests/integration/test_charm.py b/haproxy_spoe_auth_operator/tests/integration/test_charm.py index 95384bbda..73bf6f45e 100644 --- a/haproxy_spoe_auth_operator/tests/integration/test_charm.py +++ b/haproxy_spoe_auth_operator/tests/integration/test_charm.py @@ -7,58 +7,21 @@ import pytest -@pytest.mark.abort_on_fail -def test_deploy_and_status(application: str, juju: jubilant.Juju): - """Test charm deployment and basic status. - - Args: - application: The application name. - juju: The Juju instance. - - Assert: - The charm deploys successfully and reaches active status. - """ - status = juju.status() - assert application in status["applications"] - app_status = status["applications"][application] - - # Check that we have at least one unit - assert len(app_status["units"]) > 0 - - # Check the unit status - unit_name = f"{application}/0" - unit = app_status["units"][unit_name] - assert unit["workload-status"]["current"] == "active" - - @pytest.mark.abort_on_fail def test_snap_installed(application: str, juju: jubilant.Juju): - """Test that the haproxy-spoe-auth snap is installed. + """Test that the haproxy-spoe-auth snap is installed and its configuration file present. Args: application: The application name. juju: The Juju instance. Assert: - The snap is installed on the unit. + The snap is installed on the unit and the configuration file is present. """ result = juju.ssh(f"{application}/0", "snap list haproxy-spoe-auth") assert "haproxy-spoe-auth" in result - - -@pytest.mark.abort_on_fail -def test_config_file_exists(application: str, juju: jubilant.Juju): - """Test that the configuration file is created. - - Args: - application: The application name. - juju: The Juju instance. - - Assert: - The config file exists at the expected location. - """ result = juju.ssh( f"{application}/0", - "sudo test -f /var/snap/haproxy-spoe-auth/current/config.yaml && echo 'exists'", + "test -f /var/snap/haproxy-spoe-auth/current/config.yaml && echo 'exists'", ) assert "exists" in result diff --git a/haproxy_spoe_auth_operator/tests/unit/conftest.py b/haproxy_spoe_auth_operator/tests/unit/conftest.py index f7a8e4322..a1bbbc7ad 100644 --- a/haproxy_spoe_auth_operator/tests/unit/conftest.py +++ b/haproxy_spoe_auth_operator/tests/unit/conftest.py @@ -5,64 +5,25 @@ from unittest.mock import patch -import ops.testing import pytest +from haproxy_spoe_auth_operator.src.charm import HaproxySpoeAuthCharm +from ops.testing import Context -@pytest.fixture(name="context") -def fixture_context() -> ops.testing.Context: - """Create a testing context for the charm. +@pytest.fixture(name="context_with_install_mock") +def context_with_install_mock_fixture(): + """Context relation fixture. - Returns: - A Context object for testing. - """ - from charm import HaproxySpoeAuthCharm - - return ops.testing.Context( - HaproxySpoeAuthCharm, - meta={ - "name": "haproxy-spoe-auth", - "requires": { - "oauth": {"interface": "oauth"}, - }, - }, - config={ - "options": { - "spoe-address": { - "default": "127.0.0.1:3000", - "type": "string", - } - } - }, - ) - - -@pytest.fixture(name="base_state") -def fixture_base_state() -> dict: - """Create a base state for testing. - - Returns: - A dictionary representing the base state. - """ - return { - "config": {"spoe-address": "127.0.0.1:3000"}, - } - - -@pytest.fixture(name="context_with_mocks") -def fixture_context_with_mocks( - context: ops.testing.Context, -): # type: ignore[misc] - """Create a context with mocked service methods. - - Args: - context: The testing context. - - Yields: - A tuple of the context and mocked methods. + Yield: The modeled haproxy-peers relation. """ with ( - patch("charm.SpoeAuthService.install") as install_mock, - patch("charm.SpoeAuthService.reconcile") as reconcile_mock, + patch( + "haproxy_spoe_auth_operator.src.haproxy_spoe_auth_service.SpoeAuthService.install" + ) as install_mock, ): - yield context, (install_mock, reconcile_mock) + yield ( + Context( + charm_type=HaproxySpoeAuthCharm, + ), + (install_mock,), + ) diff --git a/haproxy_spoe_auth_operator/tests/unit/test_charm.py b/haproxy_spoe_auth_operator/tests/unit/test_charm.py index ba5c932e9..014a81d0e 100644 --- a/haproxy_spoe_auth_operator/tests/unit/test_charm.py +++ b/haproxy_spoe_auth_operator/tests/unit/test_charm.py @@ -10,109 +10,14 @@ logger = logging.getLogger(__name__) -def test_install(context_with_mocks, base_state): +def test_install(context_with_install_mock): """Test charm install event. arrange: prepare base state act: run install hook assert: service install is called """ - context, (install_mock, reconcile_mock) = context_with_mocks - state = ops.testing.State(**base_state) + context, install_mock = context_with_install_mock + state = ops.testing.State(relations=[]) context.run(context.on.install(), state) install_mock.assert_called_once() - reconcile_mock.assert_called_once() - - -def test_config_changed(context_with_mocks, base_state): - """Test charm config changed event. - - arrange: prepare base state - act: trigger config changed hook - assert: reconcile is called - """ - context, (_, reconcile_mock) = context_with_mocks - state = ops.testing.State(**base_state) - context.run(context.on.config_changed(), state) - reconcile_mock.assert_called_once() - - -def test_oauth_relation_changed(context_with_mocks, base_state): - """Test oauth relation changed event. - - arrange: prepare state with oauth relation - act: trigger relation changed hook - assert: reconcile is called - """ - context, (_, reconcile_mock) = context_with_mocks - oauth_relation = ops.testing.Relation( - endpoint="oauth", - remote_app_name="oauth-provider", - remote_app_data={ - "client_id": "test-client-id", - "client_secret": "test-client-secret", - "provider_url": "https://oauth.example.com", - }, - ) - state = ops.testing.State(relations=[oauth_relation], **base_state) - context.run(context.on.relation_changed(oauth_relation), state) - reconcile_mock.assert_called_once() - - -def test_oauth_relation_broken(context_with_mocks, base_state): - """Test oauth relation broken event. - - arrange: prepare state with oauth relation - act: trigger relation broken hook - assert: reconcile is called - """ - context, (_, reconcile_mock) = context_with_mocks - oauth_relation = ops.testing.Relation( - endpoint="oauth", - remote_app_name="oauth-provider", - remote_app_data={ - "client_id": "test-client-id", - "client_secret": "test-client-secret", - "provider_url": "https://oauth.example.com", - }, - ) - state = ops.testing.State(relations=[oauth_relation], **base_state) - context.run(context.on.relation_broken(oauth_relation), state) - reconcile_mock.assert_called_once() - - -def test_charm_state_without_oauth(context_with_mocks, base_state): - """Test charm status when oauth is not configured. - - arrange: prepare base state without oauth - act: trigger config changed - assert: charm is active without authentication - """ - context, (_, reconcile_mock) = context_with_mocks - state = ops.testing.State(**base_state) - out = context.run(context.on.config_changed(), state) - reconcile_mock.assert_called_once() - assert out.unit_status == ops.testing.ActiveStatus("Service running without authentication") - - -def test_charm_state_with_oauth(context_with_mocks, base_state): - """Test charm status when oauth is configured. - - arrange: prepare state with oauth relation - act: trigger config changed - assert: charm is active with oauth - """ - context, (_, reconcile_mock) = context_with_mocks - oauth_relation = ops.testing.Relation( - endpoint="oauth", - remote_app_name="oauth-provider", - remote_app_data={ - "client_id": "test-client-id", - "client_secret": "test-client-secret", - "provider_url": "https://oauth.example.com", - }, - ) - state = ops.testing.State(relations=[oauth_relation], **base_state) - out = context.run(context.on.config_changed(), state) - reconcile_mock.assert_called_once() - assert out.unit_status == ops.testing.ActiveStatus("OAuth authentication enabled") From d5f237cbf1c16ada2d3edd26941eaada532cb161 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 17 Nov 2025 18:42:46 +0100 Subject: [PATCH 11/48] fix tox.ini, add missing jsonschema deps --- haproxy_spoe_auth_operator/pyproject.toml | 63 +++++--- haproxy_spoe_auth_operator/src/charm.py | 3 +- .../tests/unit/conftest.py | 3 +- haproxy_spoe_auth_operator/uv.lock | 135 +++++++++++++++++- tox.ini | 14 +- 5 files changed, 184 insertions(+), 34 deletions(-) diff --git a/haproxy_spoe_auth_operator/pyproject.toml b/haproxy_spoe_auth_operator/pyproject.toml index d321745b8..536f0a04a 100644 --- a/haproxy_spoe_auth_operator/pyproject.toml +++ b/haproxy_spoe_auth_operator/pyproject.toml @@ -3,7 +3,7 @@ [project] name = "haproxy-spoe-auth-operator" -version = "0.0.0" +version = "0.0.1" description = "HAProxy SPOE authentication agent" readme = "README.md" requires-python = ">=3.12" @@ -17,29 +17,18 @@ dependencies = [ "charmlibs-interfaces-haproxy-spoe-auth", "charmlibs-snap==1.0.0", "jinja2==3.1.6", + "jsonschema==4.25.1", "ops==3.3.1", "pydantic==2.12.4", ] [dependency-groups] -fmt = [ "ruff" ] -lint = [ - "codespell", - "mypy", - "ops[testing]", - "pytest", - "ruff", - "types-pyyaml", -] -unit = [ "coverage[toml]", "ops[testing]", "pytest" ] -coverage-report = [ "coverage[toml]", "pytest" ] -static = [ "bandit[toml]" ] -integration = [ - "jubilant==1.1.1", - "juju==3.6.1.0", - "pytest", - "pytest-asyncio", -] +fmt = ["ruff"] +lint = ["codespell", "mypy", "ops[testing]", "pytest", "ruff", "types-pyyaml"] +unit = ["coverage[toml]", "ops[testing]", "pytest"] +coverage-report = ["coverage[toml]", "pytest"] +static = ["bandit[toml]"] +integration = ["jubilant==1.1.1", "juju==3.6.1.0", "pytest", "pytest-asyncio"] [tool.uv] package = false @@ -51,7 +40,23 @@ charmlibs-interfaces-haproxy-spoe-auth = { git = "https://github.com/Thanhphan11 target-version = "py310" line-length = 99 -lint.select = [ "A", "B", "C", "CPY", "D", "E", "F", "I", "N", "RUF", "S", "SIM", "TC", "UP", "W" ] +lint.select = [ + "A", + "B", + "C", + "CPY", + "D", + "E", + "F", + "I", + "N", + "RUF", + "S", + "SIM", + "TC", + "UP", + "W", +] lint.ignore = [ "B904", "D107", @@ -78,7 +83,18 @@ lint.ignore = [ "UP035", "UP045", ] -lint.per-file-ignores."tests/*" = [ "B011", "D100", "D101", "D102", "D103", "D104", "D212", "D415", "D417", "S" ] +lint.per-file-ignores."tests/*" = [ + "B011", + "D100", + "D101", + "D102", + "D103", + "D104", + "D212", + "D415", + "D417", + "S", +] lint.flake8-copyright.author = "Canonical Ltd." lint.flake8-copyright.min-file-size = 1 lint.flake8-copyright.notice-rgx = "Copyright\\s\\d{4}([-,]\\d{4})*\\s+" @@ -104,13 +120,14 @@ disallow_untyped_defs = true explicit_package_bases = true ignore_missing_imports = true namespace_packages = true +follow_imports = "skip" [[tool.mypy.overrides]] disallow_untyped_defs = false module = "tests.*" [tool.bandit] -exclude_dirs = [ "/venv/" ] +exclude_dirs = ["/venv/"] [tool.bandit.assert_used] -skips = [ "*/*test.py", "*/test_*.py", "*tests/*.py" ] +skips = ["*/*test.py", "*/test_*.py", "*tests/*.py"] diff --git a/haproxy_spoe_auth_operator/src/charm.py b/haproxy_spoe_auth_operator/src/charm.py index f5a43cfa2..daa0220f4 100644 --- a/haproxy_spoe_auth_operator/src/charm.py +++ b/haproxy_spoe_auth_operator/src/charm.py @@ -10,7 +10,7 @@ import ops from charmlibs.interfaces.haproxy_spoe_auth import HaproxyEvent, SpoeAuthProvider -from haproxy_spoe_auth_operator.lib.charms.hydra.v0.oauth import ClientConfig, OAuthRequirer +from charms.hydra.v0.oauth import ClientConfig, OAuthRequirer from haproxy_spoe_auth_operator.src.haproxy_spoe_auth_service import ( SpoeAuthService, SpoeAuthServiceConfigError, @@ -83,5 +83,6 @@ def _reconcile(self, _: ops.EventBase) -> None: logger.exception("Service configuration failed") self.unit.status = ops.BlockedStatus(f"Service configuration failed: {exc}") + if __name__ == "__main__": # pragma: nocover ops.main(HaproxySpoeAuthCharm) diff --git a/haproxy_spoe_auth_operator/tests/unit/conftest.py b/haproxy_spoe_auth_operator/tests/unit/conftest.py index a1bbbc7ad..48876eb07 100644 --- a/haproxy_spoe_auth_operator/tests/unit/conftest.py +++ b/haproxy_spoe_auth_operator/tests/unit/conftest.py @@ -6,9 +6,10 @@ from unittest.mock import patch import pytest -from haproxy_spoe_auth_operator.src.charm import HaproxySpoeAuthCharm from ops.testing import Context +from charm import HaproxySpoeAuthCharm + @pytest.fixture(name="context_with_install_mock") def context_with_install_mock_fixture(): diff --git a/haproxy_spoe_auth_operator/uv.lock b/haproxy_spoe_auth_operator/uv.lock index 2d2ce608d..a2c9e2be8 100644 --- a/haproxy_spoe_auth_operator/uv.lock +++ b/haproxy_spoe_auth_operator/uv.lock @@ -11,6 +11,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + [[package]] name = "bandit" version = "1.8.6" @@ -409,12 +418,13 @@ wheels = [ [[package]] name = "haproxy-spoe-auth-operator" -version = "0.0.0" +version = "0.0.1" source = { virtual = "." } dependencies = [ { name = "charmlibs-interfaces-haproxy-spoe-auth" }, { name = "charmlibs-snap" }, { name = "jinja2" }, + { name = "jsonschema" }, { name = "ops" }, { name = "pydantic" }, ] @@ -455,6 +465,7 @@ requires-dist = [ { name = "charmlibs-interfaces-haproxy-spoe-auth", git = "https://github.com/Thanhphan1147/charmlibs?subdirectory=interfaces%2Fhaproxy_spoe_auth&rev=feat%2Fadd-spoe-auth-lib" }, { name = "charmlibs-snap", specifier = "==1.0.0" }, { name = "jinja2", specifier = "==3.1.6" }, + { name = "jsonschema", specifier = "==4.25.1" }, { name = "ops", specifier = "==3.3.1" }, { name = "pydantic", specifier = "==2.12.4" }, ] @@ -549,6 +560,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "jubilant" version = "1.1.1" @@ -1141,6 +1179,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -1182,6 +1234,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] +[[package]] +name = "rpds-py" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/33/23b3b3419b6a3e0f559c7c0d2ca8fc1b9448382b25245033788785921332/rpds_py-0.29.0.tar.gz", hash = "sha256:fe55fe686908f50154d1dc599232016e50c243b438c3b7432f24e2895b0e5359", size = 69359, upload-time = "2025-11-16T14:50:39.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/50/bc0e6e736d94e420df79be4deb5c9476b63165c87bb8f19ef75d100d21b3/rpds_py-0.29.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0891cfd8db43e085c0ab93ab7e9b0c8fee84780d436d3b266b113e51e79f954", size = 376000, upload-time = "2025-11-16T14:48:19.141Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3a/46676277160f014ae95f24de53bed0e3b7ea66c235e7de0b9df7bd5d68ba/rpds_py-0.29.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3897924d3f9a0361472d884051f9a2460358f9a45b1d85a39a158d2f8f1ad71c", size = 360575, upload-time = "2025-11-16T14:48:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ba/411d414ed99ea1afdd185bbabeeaac00624bd1e4b22840b5e9967ade6337/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a21deb8e0d1571508c6491ce5ea5e25669b1dd4adf1c9d64b6314842f708b5d", size = 392159, upload-time = "2025-11-16T14:48:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b1/e18aa3a331f705467a48d0296778dc1fea9d7f6cf675bd261f9a846c7e90/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9efe71687d6427737a0a2de9ca1c0a216510e6cd08925c44162be23ed7bed2d5", size = 410602, upload-time = "2025-11-16T14:48:23.563Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/04f27f0c9f2299274c76612ac9d2c36c5048bb2c6c2e52c38c60bf3868d9/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40f65470919dc189c833e86b2c4bd21bd355f98436a2cef9e0a9a92aebc8e57e", size = 515808, upload-time = "2025-11-16T14:48:24.949Z" }, + { url = "https://files.pythonhosted.org/packages/83/56/a8412aa464fb151f8bc0d91fb0bb888adc9039bd41c1c6ba8d94990d8cf8/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:def48ff59f181130f1a2cb7c517d16328efac3ec03951cca40c1dc2049747e83", size = 416015, upload-time = "2025-11-16T14:48:26.782Z" }, + { url = "https://files.pythonhosted.org/packages/04/4c/f9b8a05faca3d9e0a6397c90d13acb9307c9792b2bff621430c58b1d6e76/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7bd570be92695d89285a4b373006930715b78d96449f686af422debb4d3949", size = 395325, upload-time = "2025-11-16T14:48:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/34/60/869f3bfbf8ed7b54f1ad9a5543e0fdffdd40b5a8f587fe300ee7b4f19340/rpds_py-0.29.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:5a572911cd053137bbff8e3a52d31c5d2dba51d3a67ad902629c70185f3f2181", size = 410160, upload-time = "2025-11-16T14:48:29.338Z" }, + { url = "https://files.pythonhosted.org/packages/91/aa/e5b496334e3aba4fe4c8a80187b89f3c1294c5c36f2a926da74338fa5a73/rpds_py-0.29.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d583d4403bcbf10cffc3ab5cee23d7643fcc960dff85973fd3c2d6c86e8dbb0c", size = 425309, upload-time = "2025-11-16T14:48:30.691Z" }, + { url = "https://files.pythonhosted.org/packages/85/68/4e24a34189751ceb6d66b28f18159922828dd84155876551f7ca5b25f14f/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:070befbb868f257d24c3bb350dbd6e2f645e83731f31264b19d7231dd5c396c7", size = 574644, upload-time = "2025-11-16T14:48:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/474a005ea4ea9c3b4f17b6108b6b13cebfc98ebaff11d6e1b193204b3a93/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fc935f6b20b0c9f919a8ff024739174522abd331978f750a74bb68abd117bd19", size = 601605, upload-time = "2025-11-16T14:48:33.252Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b1/c56f6a9ab8c5f6bb5c65c4b5f8229167a3a525245b0773f2c0896686b64e/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c5a8ecaa44ce2d8d9d20a68a2483a74c07f05d72e94a4dff88906c8807e77b0", size = 564593, upload-time = "2025-11-16T14:48:34.643Z" }, + { url = "https://files.pythonhosted.org/packages/b3/13/0494cecce4848f68501e0a229432620b4b57022388b071eeff95f3e1e75b/rpds_py-0.29.0-cp312-cp312-win32.whl", hash = "sha256:ba5e1aeaf8dd6d8f6caba1f5539cddda87d511331714b7b5fc908b6cfc3636b7", size = 223853, upload-time = "2025-11-16T14:48:36.419Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6a/51e9aeb444a00cdc520b032a28b07e5f8dc7bc328b57760c53e7f96997b4/rpds_py-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:b5f6134faf54b3cb83375db0f113506f8b7770785be1f95a631e7e2892101977", size = 239895, upload-time = "2025-11-16T14:48:37.956Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d4/8bce56cdad1ab873e3f27cb31c6a51d8f384d66b022b820525b879f8bed1/rpds_py-0.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:b016eddf00dca7944721bf0cd85b6af7f6c4efaf83ee0b37c4133bd39757a8c7", size = 230321, upload-time = "2025-11-16T14:48:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/c5de60d9d371bbb186c3e9bf75f4fc5665e11117a25a06a6b2e0afb7380e/rpds_py-0.29.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1585648d0760b88292eecab5181f5651111a69d90eff35d6b78aa32998886a61", size = 375710, upload-time = "2025-11-16T14:48:41.063Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b3/0860cdd012291dc21272895ce107f1e98e335509ba986dd83d72658b82b9/rpds_py-0.29.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:521807963971a23996ddaf764c682b3e46459b3c58ccd79fefbe16718db43154", size = 360582, upload-time = "2025-11-16T14:48:42.423Z" }, + { url = "https://files.pythonhosted.org/packages/92/8a/a18c2f4a61b3407e56175f6aab6deacdf9d360191a3d6f38566e1eaf7266/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8896986efaa243ab713c69e6491a4138410f0fe36f2f4c71e18bd5501e8014", size = 391172, upload-time = "2025-11-16T14:48:43.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/49/e93354258508c50abc15cdcd5fcf7ac4117f67bb6233ad7859f75e7372a0/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d24564a700ef41480a984c5ebed62b74e6ce5860429b98b1fede76049e953e6", size = 409586, upload-time = "2025-11-16T14:48:45.498Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8d/a27860dae1c19a6bdc901f90c81f0d581df1943355802961a57cdb5b6cd1/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6596b93c010d386ae46c9fba9bfc9fc5965fa8228edeac51576299182c2e31c", size = 516339, upload-time = "2025-11-16T14:48:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/a75e603161e79b7110c647163d130872b271c6b28712c803c65d492100f7/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5cc58aac218826d054c7da7f95821eba94125d88be673ff44267bb89d12a5866", size = 416201, upload-time = "2025-11-16T14:48:48.615Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/555b4ee17508beafac135c8b450816ace5a96194ce97fefc49d58e5652ea/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de73e40ebc04dd5d9556f50180395322193a78ec247e637e741c1b954810f295", size = 395095, upload-time = "2025-11-16T14:48:50.027Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f0/c90b671b9031e800ec45112be42ea9f027f94f9ac25faaac8770596a16a1/rpds_py-0.29.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:295ce5ac7f0cf69a651ea75c8f76d02a31f98e5698e82a50a5f4d4982fbbae3b", size = 410077, upload-time = "2025-11-16T14:48:51.515Z" }, + { url = "https://files.pythonhosted.org/packages/3d/80/9af8b640b81fe21e6f718e9dec36c0b5f670332747243130a5490f292245/rpds_py-0.29.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ea59b23ea931d494459c8338056fe7d93458c0bf3ecc061cd03916505369d55", size = 424548, upload-time = "2025-11-16T14:48:53.237Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0b/b5647446e991736e6a495ef510e6710df91e880575a586e763baeb0aa770/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f49d41559cebd608042fdcf54ba597a4a7555b49ad5c1c0c03e0af82692661cd", size = 573661, upload-time = "2025-11-16T14:48:54.769Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/1b1c9576839ff583d1428efbf59f9ee70498d8ce6c0b328ac02f1e470879/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:05a2bd42768ea988294ca328206efbcc66e220d2d9b7836ee5712c07ad6340ea", size = 600937, upload-time = "2025-11-16T14:48:56.247Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/b6cfca2f9fee4c4494ce54f7fb1b9f578867495a9aa9fc0d44f5f735c8e0/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33ca7bdfedd83339ca55da3a5e1527ee5870d4b8369456b5777b197756f3ca22", size = 564496, upload-time = "2025-11-16T14:48:57.691Z" }, + { url = "https://files.pythonhosted.org/packages/b9/fb/ba29ec7f0f06eb801bac5a23057a9ff7670623b5e8013bd59bec4aa09de8/rpds_py-0.29.0-cp313-cp313-win32.whl", hash = "sha256:20c51ae86a0bb9accc9ad4e6cdeec58d5ebb7f1b09dd4466331fc65e1766aae7", size = 223126, upload-time = "2025-11-16T14:48:59.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6b/0229d3bed4ddaa409e6d90b0ae967ed4380e4bdd0dad6e59b92c17d42457/rpds_py-0.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:6410e66f02803600edb0b1889541f4b5cc298a5ccda0ad789cc50ef23b54813e", size = 239771, upload-time = "2025-11-16T14:49:00.872Z" }, + { url = "https://files.pythonhosted.org/packages/e4/38/d2868f058b164f8efd89754d85d7b1c08b454f5c07ac2e6cc2e9bd4bd05b/rpds_py-0.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:56838e1cd9174dc23c5691ee29f1d1be9eab357f27efef6bded1328b23e1ced2", size = 229994, upload-time = "2025-11-16T14:49:02.673Z" }, + { url = "https://files.pythonhosted.org/packages/52/91/5de91c5ec7d41759beec9b251630824dbb8e32d20c3756da1a9a9d309709/rpds_py-0.29.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:37d94eadf764d16b9a04307f2ab1d7af6dc28774bbe0535c9323101e14877b4c", size = 365886, upload-time = "2025-11-16T14:49:04.133Z" }, + { url = "https://files.pythonhosted.org/packages/85/7c/415d8c1b016d5f47ecec5145d9d6d21002d39dce8761b30f6c88810b455a/rpds_py-0.29.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d472cf73efe5726a067dce63eebe8215b14beabea7c12606fd9994267b3cfe2b", size = 355262, upload-time = "2025-11-16T14:49:05.543Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/bf83e2daa4f980e4dc848aed9299792a8b84af95e12541d9e7562f84a6ef/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72fdfd5ff8992e4636621826371e3ac5f3e3b8323e9d0e48378e9c13c3dac9d0", size = 384826, upload-time = "2025-11-16T14:49:07.301Z" }, + { url = "https://files.pythonhosted.org/packages/33/b8/53330c50a810ae22b4fbba5e6cf961b68b9d72d9bd6780a7c0a79b070857/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2549d833abdf8275c901313b9e8ff8fba57e50f6a495035a2a4e30621a2f7cc4", size = 394234, upload-time = "2025-11-16T14:49:08.782Z" }, + { url = "https://files.pythonhosted.org/packages/cc/32/01e2e9645cef0e584f518cfde4567563e57db2257244632b603f61b40e50/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4448dad428f28a6a767c3e3b80cde3446a22a0efbddaa2360f4bb4dc836d0688", size = 520008, upload-time = "2025-11-16T14:49:10.253Z" }, + { url = "https://files.pythonhosted.org/packages/98/c3/0d1b95a81affae2b10f950782e33a1fd2edd6ce2a479966cac98c9a66f57/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:115f48170fd4296a33938d8c11f697f5f26e0472e43d28f35624764173a60e4d", size = 409569, upload-time = "2025-11-16T14:49:12.478Z" }, + { url = "https://files.pythonhosted.org/packages/fa/60/aa3b8678f3f009f675b99174fa2754302a7fbfe749162e8043d111de2d88/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e5bb73ffc029820f4348e9b66b3027493ae00bca6629129cd433fd7a76308ee", size = 385188, upload-time = "2025-11-16T14:49:13.88Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/5546c1c8aa89c18d40c1fcffdcc957ba730dee53fb7c3ca3a46f114761d2/rpds_py-0.29.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b1581fcde18fcdf42ea2403a16a6b646f8eb1e58d7f90a0ce693da441f76942e", size = 398587, upload-time = "2025-11-16T14:49:15.339Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e0/ad6eeaf47e236eba052fa34c4073078b9e092bd44da6bbb35aaae9580669/rpds_py-0.29.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16e9da2bda9eb17ea318b4c335ec9ac1818e88922cbe03a5743ea0da9ecf74fb", size = 416641, upload-time = "2025-11-16T14:49:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/1a/93/0acedfd50ad9cdd3879c615a6dc8c5f1ce78d2fdf8b87727468bb5bb4077/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:28fd300326dd21198f311534bdb6d7e989dd09b3418b3a91d54a0f384c700967", size = 566683, upload-time = "2025-11-16T14:49:18.342Z" }, + { url = "https://files.pythonhosted.org/packages/62/53/8c64e0f340a9e801459fc6456821abc15b3582cb5dc3932d48705a9d9ac7/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2aba991e041d031c7939e1358f583ae405a7bf04804ca806b97a5c0e0af1ea5e", size = 592730, upload-time = "2025-11-16T14:49:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/85/ef/3109b6584f8c4b0d2490747c916df833c127ecfa82be04d9a40a376f2090/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f437026dbbc3f08c99cc41a5b2570c6e1a1ddbe48ab19a9b814254128d4ea7a", size = 557361, upload-time = "2025-11-16T14:49:21.574Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3b/61586475e82d57f01da2c16edb9115a618afe00ce86fe1b58936880b15af/rpds_py-0.29.0-cp313-cp313t-win32.whl", hash = "sha256:6e97846e9800a5d0fe7be4d008f0c93d0feeb2700da7b1f7528dabafb31dfadb", size = 211227, upload-time = "2025-11-16T14:49:23.03Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3a/12dc43f13594a54ea0c9d7e9d43002116557330e3ad45bc56097ddf266e2/rpds_py-0.29.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f49196aec7c4b406495f60e6f947ad71f317a765f956d74bbd83996b9edc0352", size = 225248, upload-time = "2025-11-16T14:49:24.841Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/0b1474e7899371d9540d3bbb2a499a3427ae1fc39c998563fe9035a1073b/rpds_py-0.29.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:394d27e4453d3b4d82bb85665dc1fcf4b0badc30fc84282defed71643b50e1a1", size = 363731, upload-time = "2025-11-16T14:49:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/28/12/3b7cf2068d0a334ed1d7b385a9c3c8509f4c2bcba3d4648ea71369de0881/rpds_py-0.29.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55d827b2ae95425d3be9bc9a5838b6c29d664924f98146557f7715e331d06df8", size = 354343, upload-time = "2025-11-16T14:49:28.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/73/5afcf8924bc02a749416eda64e17ac9c9b28f825f4737385295a0e99b0c1/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc31a07ed352e5462d3ee1b22e89285f4ce97d5266f6d1169da1142e78045626", size = 385406, upload-time = "2025-11-16T14:49:29.943Z" }, + { url = "https://files.pythonhosted.org/packages/c8/37/5db736730662508535221737a21563591b6f43c77f2e388951c42f143242/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4695dd224212f6105db7ea62197144230b808d6b2bba52238906a2762f1d1e7", size = 396162, upload-time = "2025-11-16T14:49:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/70/0d/491c1017d14f62ce7bac07c32768d209a50ec567d76d9f383b4cfad19b80/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcae1770b401167f8b9e1e3f566562e6966ffa9ce63639916248a9e25fa8a244", size = 517719, upload-time = "2025-11-16T14:49:33.804Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/b11132afcb17cd5d82db173f0c8dab270ffdfaba43e5ce7a591837ae9649/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90f30d15f45048448b8da21c41703b31c61119c06c216a1bf8c245812a0f0c17", size = 409498, upload-time = "2025-11-16T14:49:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/e6543cedfb2e6403a1845710a5ab0e0ccf8fc288e0b5af9a70bfe2c12053/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44a91e0ab77bdc0004b43261a4b8cd6d6b451e8d443754cfda830002b5745b32", size = 382743, upload-time = "2025-11-16T14:49:36.704Z" }, + { url = "https://files.pythonhosted.org/packages/75/11/a4ebc9f654293ae9fefb83b2b6be7f3253e85ea42a5db2f77d50ad19aaeb/rpds_py-0.29.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4aa195e5804d32c682e453b34474f411ca108e4291c6a0f824ebdc30a91c973c", size = 400317, upload-time = "2025-11-16T14:49:39.132Z" }, + { url = "https://files.pythonhosted.org/packages/52/18/97677a60a81c7f0e5f64e51fb3f8271c5c8fcabf3a2df18e97af53d7c2bf/rpds_py-0.29.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7971bdb7bf4ee0f7e6f67fa4c7fbc6019d9850cc977d126904392d363f6f8318", size = 416979, upload-time = "2025-11-16T14:49:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/28ab391a9968f6c746b2a2db181eaa4d16afaa859fedc9c2f682d19f7e18/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8ae33ad9ce580c7a47452c3b3f7d8a9095ef6208e0a0c7e4e2384f9fc5bf8212", size = 567288, upload-time = "2025-11-16T14:49:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d3/0c7afdcdb830eee94f5611b64e71354ffe6ac8df82d00c2faf2bfffd1d4e/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c661132ab2fb4eeede2ef69670fd60da5235209874d001a98f1542f31f2a8a94", size = 593157, upload-time = "2025-11-16T14:49:43.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ac/a0fcbc2feed4241cf26d32268c195eb88ddd4bd862adfc9d4b25edfba535/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb78b3a0d31ac1bde132c67015a809948db751cb4e92cdb3f0b242e430b6ed0d", size = 554741, upload-time = "2025-11-16T14:49:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f1/fcc24137c470df8588674a677f33719d5800ec053aaacd1de8a5d5d84d9e/rpds_py-0.29.0-cp314-cp314-win32.whl", hash = "sha256:f475f103488312e9bd4000bc890a95955a07b2d0b6e8884aef4be56132adbbf1", size = 215508, upload-time = "2025-11-16T14:49:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/1d169b2045512eac019918fc1021ea07c30e84a4343f9f344e3e0aa8c788/rpds_py-0.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:b9cf2359a4fca87cfb6801fae83a76aedf66ee1254a7a151f1341632acf67f1b", size = 228125, upload-time = "2025-11-16T14:49:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/be/36/0cec88aaba70ec4a6e381c444b0d916738497d27f0c30406e3d9fcbd3bc2/rpds_py-0.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:9ba8028597e824854f0f1733d8b964e914ae3003b22a10c2c664cb6927e0feb9", size = 221992, upload-time = "2025-11-16T14:49:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fa/a2e524631717c9c0eb5d90d30f648cfba6b731047821c994acacb618406c/rpds_py-0.29.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e71136fd0612556b35c575dc2726ae04a1669e6a6c378f2240312cf5d1a2ab10", size = 366425, upload-time = "2025-11-16T14:49:52.691Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/6d43ebe0746ff694a30233f63f454aed1677bd50ab7a59ff6b2bb5ac61f2/rpds_py-0.29.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:76fe96632d53f3bf0ea31ede2f53bbe3540cc2736d4aec3b3801b0458499ef3a", size = 355282, upload-time = "2025-11-16T14:49:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a7/52fd8270e0320b09eaf295766ae81dd175f65394687906709b3e75c71d06/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9459a33f077130dbb2c7c3cea72ee9932271fb3126404ba2a2661e4fe9eb7b79", size = 384968, upload-time = "2025-11-16T14:49:55.857Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/e6bc526b7a14e1ef80579a52c1d4ad39260a058a51d66c6039035d14db9d/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9546cfdd5d45e562cc0444b6dddc191e625c62e866bf567a2c69487c7ad28a", size = 394714, upload-time = "2025-11-16T14:49:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/f0ade3954e7db95c791e7eaf978aa7e08a756d2046e8bdd04d08146ed188/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12597d11d97b8f7e376c88929a6e17acb980e234547c92992f9f7c058f1a7310", size = 520136, upload-time = "2025-11-16T14:49:59.162Z" }, + { url = "https://files.pythonhosted.org/packages/87/b3/07122ead1b97009715ab9d4082be6d9bd9546099b2b03fae37c3116f72be/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28de03cf48b8a9e6ec10318f2197b83946ed91e2891f651a109611be4106ac4b", size = 409250, upload-time = "2025-11-16T14:50:00.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/dcbee61fd1dc892aedcb1b489ba661313101aa82ec84b1a015d4c63ebfda/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7951c964069039acc9d67a8ff1f0a7f34845ae180ca542b17dc1456b1f1808", size = 384940, upload-time = "2025-11-16T14:50:02.312Z" }, + { url = "https://files.pythonhosted.org/packages/47/11/914ecb6f3574cf9bf8b38aced4063e0f787d6e1eb30b181a7efbc6c1da9a/rpds_py-0.29.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:c07d107b7316088f1ac0177a7661ca0c6670d443f6fe72e836069025e6266761", size = 399392, upload-time = "2025-11-16T14:50:03.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/fd/2f4bd9433f58f816434bb934313584caa47dbc6f03ce5484df8ac8980561/rpds_py-0.29.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de2345af363d25696969befc0c1688a6cb5e8b1d32b515ef84fc245c6cddba3", size = 416796, upload-time = "2025-11-16T14:50:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/79/a5/449f0281af33efa29d5c71014399d74842342ae908d8cd38260320167692/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00e56b12d2199ca96068057e1ae7f9998ab6e99cda82431afafd32f3ec98cca9", size = 566843, upload-time = "2025-11-16T14:50:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/0a6a1ccee2e37fcb1b7ba9afde762b77182dbb57937352a729c6cd3cf2bb/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3919a3bbecee589300ed25000b6944174e07cd20db70552159207b3f4bbb45b8", size = 593956, upload-time = "2025-11-16T14:50:09.029Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3d/eb820f95dce4306f07a495ede02fb61bef36ea201d9137d4fcd5ab94ec1e/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7fa2ccc312bbd91e43aa5e0869e46bc03278a3dddb8d58833150a18b0f0283a", size = 557288, upload-time = "2025-11-16T14:50:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/b8ff786f40470462a252918e0836e0db903c28e88e3eec66bc4a7856ee5d/rpds_py-0.29.0-cp314-cp314t-win32.whl", hash = "sha256:97c817863ffc397f1e6a6e9d2d89fe5408c0a9922dac0329672fb0f35c867ea5", size = 211382, upload-time = "2025-11-16T14:50:12.827Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" }, +] + [[package]] name = "rsa" version = "4.9.1" diff --git a/tox.ini b/tox.ini index a956bc75f..03d1c0e35 100644 --- a/tox.ini +++ b/tox.ini @@ -9,14 +9,13 @@ envlist = lint, unit, static, coverage-report [vars] src_path = {toxinidir}/src/ tst_path = {toxinidir}/tests/ -spoe_auth_src_path = {toxinidir}/haproxy_spoe_auth_operator/src/ -spoe_auth_tst_path = {toxinidir}/haproxy_spoe_auth_operator/tests/ +haproxy_spoe_auth_path = {toxinidir}/haproxy_spoe_auth_operator/ ;lib_path = {toxinidir}/lib/charms/operator_name_with_underscores -all_path = {[vars]src_path} {[vars]tst_path} {toxinidir}/lib/charms/haproxy/v1 {toxinidir}/lib/charms/haproxy/v0 {[vars]spoe_auth_src_path} {[vars]spoe_auth_tst_path} +all_path = {[vars]src_path} {[vars]tst_path} {toxinidir}/lib/charms/haproxy/v1 {toxinidir}/lib/charms/haproxy/v0 [testenv] setenv = - PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path}:{[vars]spoe_auth_src_path} + PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path} PYTHONBREAKPOINT=ipdb.set_trace PY_COLORS=1 passenv = @@ -63,7 +62,6 @@ deps = -r{toxinidir}/requirements.txt commands = pydocstyle {[vars]src_path} - pydocstyle {[vars]spoe_auth_src_path} # uncomment the following line if this charm owns a lib # codespell {[vars]lib_path} codespell {toxinidir} --skip {toxinidir}/.git --skip {toxinidir}/.tox \ @@ -84,8 +82,8 @@ deps = pytest -r{toxinidir}/requirements.txt commands = - coverage run --source={[vars]src_path},{toxinidir}/lib/charms/haproxy/v1/,{[vars]src_path},{toxinidir}/lib/charms/haproxy/v0/,{[vars]spoe_auth_src_path} \ - -m pytest --ignore={[vars]tst_path}integration --ignore={[vars]spoe_auth_tst_path}integration -v --tb native --log-cli-level=INFO -s {posargs} + coverage run --source={[vars]src_path},{toxinidir}/lib/charms/haproxy/v1/,{[vars]src_path},{toxinidir}/lib/charms/haproxy/v0/\ + -m pytest --ignore={[vars]tst_path}integration --ignore={[vars]haproxy_spoe_auth_path} -v --tb native --log-cli-level=INFO -s {posargs} coverage report [testenv:coverage-report] @@ -120,4 +118,4 @@ deps = pytest-operator websockets<14.0 # https://github.com/juju/python-libjuju/issues/1184 commands = - pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} + pytest -v --tb native --ignore={[vars]tst_path}unit --ignore={[vars]haproxy_spoe_auth_path} --log-cli-level=INFO -s {posargs} From 0d26db91820464be14e1db3fa653313457baff16 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 17 Nov 2025 18:43:37 +0100 Subject: [PATCH 12/48] fix import --- haproxy_spoe_auth_operator/src/charm.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/haproxy_spoe_auth_operator/src/charm.py b/haproxy_spoe_auth_operator/src/charm.py index daa0220f4..b37991211 100644 --- a/haproxy_spoe_auth_operator/src/charm.py +++ b/haproxy_spoe_auth_operator/src/charm.py @@ -11,7 +11,8 @@ import ops from charmlibs.interfaces.haproxy_spoe_auth import HaproxyEvent, SpoeAuthProvider from charms.hydra.v0.oauth import ClientConfig, OAuthRequirer -from haproxy_spoe_auth_operator.src.haproxy_spoe_auth_service import ( + +from haproxy_spoe_auth_service import ( SpoeAuthService, SpoeAuthServiceConfigError, ) From 82a3cdedf92e630c3c06c9891af77a7a841b22ea Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 17 Nov 2025 18:54:33 +0100 Subject: [PATCH 13/48] minor fix for secret labels, import logic in monorepo, etc .. --- haproxy_spoe_auth_operator/src/charm.py | 31 ++++++++++--------- .../src/haproxy_spoe_auth_service.py | 2 +- haproxy_spoe_auth_operator/src/state.py | 20 ++++++------ 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/haproxy_spoe_auth_operator/src/charm.py b/haproxy_spoe_auth_operator/src/charm.py index b37991211..bca618de8 100644 --- a/haproxy_spoe_auth_operator/src/charm.py +++ b/haproxy_spoe_auth_operator/src/charm.py @@ -16,8 +16,7 @@ SpoeAuthService, SpoeAuthServiceConfigError, ) - -from .state import CharmState, InvalidCharmConfigError, OauthInformation +from state import CharmState, InvalidCharmConfigError, OauthInformation logger = logging.getLogger(__name__) @@ -29,6 +28,7 @@ VAR_AUTHENTICATED = "sess.auth.is_authenticated" VAR_REDIRECT_URL = "sess.auth.redirect_url" COOKIE_NAME = "authsession" +SPOE_AUTH_RELATION = "spoe-auth" class HaproxySpoeAuthCharm(ops.CharmBase): @@ -42,7 +42,7 @@ def __init__(self, *args: typing.Any): """ super().__init__(*args) self.service = SpoeAuthService() - self._spoe_auth_provider = SpoeAuthProvider(self, relation_name="spoe-auth") + self._spoe_auth_provider = SpoeAuthProvider(self, relation_name=SPOE_AUTH_RELATION) self._oauth = OAuthRequirer(self, relation_name=OAUTH_RELATION) self.framework.observe(self.on.install, self._reconcile) @@ -63,17 +63,20 @@ def _reconcile(self, _: ops.EventBase) -> None: ) ) oauth_information = OauthInformation.from_charm(self, self._oauth) - self._spoe_auth_provider.provide_spoe_auth_requirements( - relation=oauth_information.spoe_auth_relation, - spop_port=SPOP_PORT, - event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, - oidc_callback_port=OIDC_CALLBACK_PORT, - var_authenticated=VAR_AUTHENTICATED, - var_redirect_url=VAR_REDIRECT_URL, - cookie_name=COOKIE_NAME, - oidc_callback_hostname=state.hostname, - oidc_callback_path=OIDC_CALLBACK_PATH, - ) + if relation := self.model.get_relation( + SPOE_AUTH_RELATION, oauth_information.spoe_auth_relation_id + ): + self._spoe_auth_provider.provide_spoe_auth_requirements( + relation=relation, + spop_port=SPOP_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + oidc_callback_port=OIDC_CALLBACK_PORT, + var_authenticated=VAR_AUTHENTICATED, + var_redirect_url=VAR_REDIRECT_URL, + cookie_name=COOKIE_NAME, + oidc_callback_hostname=state.hostname, + oidc_callback_path=OIDC_CALLBACK_PATH, + ) self.service.reconcile(charm_state=state, oauth_information=oauth_information) self.unit.status = ops.ActiveStatus() diff --git a/haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py b/haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py index fa5b1791a..6fab5950e 100644 --- a/haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py +++ b/haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py @@ -9,7 +9,7 @@ from charmlibs import snap from jinja2 import Environment, FileSystemLoader, select_autoescape -from .state import CharmState, OauthInformation +from state import CharmState, OauthInformation SNAP_NAME = "haproxy-spoe-auth" CONFIG_PATH = Path("/var/snap/haproxy-spoe-auth/current/config.yaml") diff --git a/haproxy_spoe_auth_operator/src/state.py b/haproxy_spoe_auth_operator/src/state.py index 2d27c2af9..b4447c29d 100644 --- a/haproxy_spoe_auth_operator/src/state.py +++ b/haproxy_spoe_auth_operator/src/state.py @@ -8,11 +8,10 @@ import typing import ops +from charms.hydra.v0.oauth import OAuthRequirer from pydantic import Field, ValidationError from pydantic.dataclasses import dataclass -from lib.charms.hydra.v0.oauth import OAuthRequirer - logger = logging.getLogger(__name__) SPOE_AUTH_RELATION = "spoe-auth" @@ -29,9 +28,8 @@ class CharmState: Attributes: hostname: The hostname of the charm. - client_id: The OAuth client ID. - client_secret: The OAuth client secret. - issuer_url: The OAuth issuer URL. + signature_secret: The SPOE agent signature secret. + encryption_secret: The SPOE agent encryption secret. """ hostname: str = Field(description="The hostname part of the redirect URL.") @@ -64,15 +62,15 @@ def from_charm( try: secret = charm.model.get_secret(label=AGENT_SECRETS_LABEL) signing_and_encryption_secrets = secret.get_content(refresh=True) - signature_secret = signing_and_encryption_secrets.get("signature_secret") - encryption_secret = signing_and_encryption_secrets.get("encryption_secret") + signature_secret = signing_and_encryption_secrets.get("signature-secret") + encryption_secret = signing_and_encryption_secrets.get("encryption-secret") except ops.SecretNotFoundError: signature_secret = secrets.token_urlsafe(32) encryption_secret = secrets.token_urlsafe(32) secret = charm.model.app.add_secret( content={ - "signature_secret": signature_secret, - "encryption_secret": encryption_secret, + "signature-secret": signature_secret, + "encryption-secret": encryption_secret, }, label=AGENT_SECRETS_LABEL, ) @@ -104,7 +102,7 @@ class OauthInformation: issuer_url: str = Field(description="The OAuth issuer URL.", min_length=1) client_id: str = Field(description="The OAuth client ID.", min_length=1) client_secret: str = Field(description="The OAuth client secret.", min_length=1) - spoe_auth_relation: ops.Relation = Field(description="The spoe-auth relation.") + spoe_auth_relation_id: int = Field(description="The spoe-auth relation ID.") @classmethod def from_charm( @@ -142,7 +140,7 @@ def from_charm( issuer_url=oauth_provider_information.issuer_url, client_id=oauth_provider_information.client_id, client_secret=oauth_provider_information.client_secret, - spoe_auth_relation=spoe_auth_relation, + spoe_auth_relation_id=spoe_auth_relation.id, ) except ValidationError as exc: raise InvalidCharmConfigError("Invalid configuration") from exc From f0b255d396d43dc48c1ddc745f53f983f0ca1028 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 17 Nov 2025 19:13:10 +0100 Subject: [PATCH 14/48] update tests --- haproxy_spoe_auth_operator/src/charm.py | 6 ++++-- haproxy_spoe_auth_operator/tests/unit/conftest.py | 4 ++-- haproxy_spoe_auth_operator/tox.toml | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/haproxy_spoe_auth_operator/src/charm.py b/haproxy_spoe_auth_operator/src/charm.py index bca618de8..6d6848341 100644 --- a/haproxy_spoe_auth_operator/src/charm.py +++ b/haproxy_spoe_auth_operator/src/charm.py @@ -15,6 +15,7 @@ from haproxy_spoe_auth_service import ( SpoeAuthService, SpoeAuthServiceConfigError, + SpoeAuthServiceInstallError ) from state import CharmState, InvalidCharmConfigError, OauthInformation @@ -53,6 +54,7 @@ def __init__(self, *args: typing.Any): def _reconcile(self, _: ops.EventBase) -> None: """Reconcile the charm state and service configuration.""" try: + self.service.install() state = CharmState.from_charm(self) self._oauth.update_client_config( @@ -83,8 +85,8 @@ def _reconcile(self, _: ops.EventBase) -> None: except InvalidCharmConfigError as exc: logger.exception("Charm state validation failed") self.unit.status = ops.BlockedStatus(f"Configuration error: {exc}") - except SpoeAuthServiceConfigError as exc: - logger.exception("Service configuration failed") + except (SpoeAuthServiceInstallError, SpoeAuthServiceConfigError) as exc: + logger.exception("Error configuring the haproxy-spoe-auth service.") self.unit.status = ops.BlockedStatus(f"Service configuration failed: {exc}") diff --git a/haproxy_spoe_auth_operator/tests/unit/conftest.py b/haproxy_spoe_auth_operator/tests/unit/conftest.py index 48876eb07..d2a0c1ae8 100644 --- a/haproxy_spoe_auth_operator/tests/unit/conftest.py +++ b/haproxy_spoe_auth_operator/tests/unit/conftest.py @@ -19,12 +19,12 @@ def context_with_install_mock_fixture(): """ with ( patch( - "haproxy_spoe_auth_operator.src.haproxy_spoe_auth_service.SpoeAuthService.install" + "haproxy_spoe_auth_service.SpoeAuthService.install" ) as install_mock, ): yield ( Context( charm_type=HaproxySpoeAuthCharm, ), - (install_mock,), + install_mock, ) diff --git a/haproxy_spoe_auth_operator/tox.toml b/haproxy_spoe_auth_operator/tox.toml index a03e47f18..7529f9110 100644 --- a/haproxy_spoe_auth_operator/tox.toml +++ b/haproxy_spoe_auth_operator/tox.toml @@ -12,7 +12,7 @@ passenv = [ "PYTHONPATH", "CHARM_BUILD_DIR", "MODEL_SETTINGS" ] runner = "uv-venv-lock-runner" [env_run_base.setenv] -PYTHONPATH = "{toxinidir}/src" +PYTHONPATH = "{toxinidir}/src:{toxinidir}/lib" PYTHONBREAKPOINT = "ipdb.set_trace" PY_COLORS = "1" From a3657b6261f871b6bbca5ea5ae14c0a767514d02 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 17 Nov 2025 19:14:20 +0100 Subject: [PATCH 15/48] code formatting --- haproxy_spoe_auth_operator/src/charm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haproxy_spoe_auth_operator/src/charm.py b/haproxy_spoe_auth_operator/src/charm.py index 6d6848341..0aab442f9 100644 --- a/haproxy_spoe_auth_operator/src/charm.py +++ b/haproxy_spoe_auth_operator/src/charm.py @@ -15,7 +15,7 @@ from haproxy_spoe_auth_service import ( SpoeAuthService, SpoeAuthServiceConfigError, - SpoeAuthServiceInstallError + SpoeAuthServiceInstallError, ) from state import CharmState, InvalidCharmConfigError, OauthInformation From 776bfa3215b62a5ad85a90a9d21056cade6da87e Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 17 Nov 2025 19:14:41 +0100 Subject: [PATCH 16/48] code formatting --- haproxy_spoe_auth_operator/tests/unit/conftest.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/haproxy_spoe_auth_operator/tests/unit/conftest.py b/haproxy_spoe_auth_operator/tests/unit/conftest.py index d2a0c1ae8..f48f91bd1 100644 --- a/haproxy_spoe_auth_operator/tests/unit/conftest.py +++ b/haproxy_spoe_auth_operator/tests/unit/conftest.py @@ -18,9 +18,7 @@ def context_with_install_mock_fixture(): Yield: The modeled haproxy-peers relation. """ with ( - patch( - "haproxy_spoe_auth_service.SpoeAuthService.install" - ) as install_mock, + patch("haproxy_spoe_auth_service.SpoeAuthService.install") as install_mock, ): yield ( Context( From 6d3791a9434697aefb44396e9516e98de12782c5 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 17 Nov 2025 19:42:43 +0100 Subject: [PATCH 17/48] fix lint --- haproxy_spoe_auth_operator/pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/haproxy_spoe_auth_operator/pyproject.toml b/haproxy_spoe_auth_operator/pyproject.toml index 536f0a04a..2fb0b4733 100644 --- a/haproxy_spoe_auth_operator/pyproject.toml +++ b/haproxy_spoe_auth_operator/pyproject.toml @@ -120,7 +120,6 @@ disallow_untyped_defs = true explicit_package_bases = true ignore_missing_imports = true namespace_packages = true -follow_imports = "skip" [[tool.mypy.overrides]] disallow_untyped_defs = false From c36c863ff3cfa9f52306c53a6f1dec103931228a Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 17 Nov 2025 19:57:06 +0100 Subject: [PATCH 18/48] update test workflows --- .github/workflows/integration_test.yaml | 10 ++++++++++ .github/workflows/test.yaml | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 567be6a10..b47f3a2b1 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -18,3 +18,13 @@ jobs: needs: - integration-tests uses: canonical/operator-workflows/.github/workflows/allure_report.yaml@main + haproxy-spoe-auth-operator: + name: Integration tests for the haproxy-spoe-auth-operator charm + uses: canonical/operator-workflows/.github/workflows/integration_test.yaml@main + secrets: inherit + with: + juju-channel: '3/stable' + provider: lxd + working-directory: ./haproxy_spoe_auth_operator + with-uv: True + modules: '["test_charm.py",]' diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c4e7ffc6b..0b4c08fda 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -11,3 +11,13 @@ jobs: self-hosted-runner: true self-hosted-runner-image: "noble" charmcraft-channel: latest/edge + haproxy-spoe-auth-operator: + name: Unit tests for the haproxy-spoe-auth-operator charm + uses: canonical/operator-workflows/.github/workflows/test.yaml@main + secrets: inherit + with: + self-hosted-runner: true + self-hosted-runner-image: "noble" + python-version: 3.12 + working-directory: ./haproxy_spoe_auth_operator + with-uv: True From 2fd88ae0513297a0773b6dd29a950072643ac99b Mon Sep 17 00:00:00 2001 From: Javier de la Puente Date: Tue, 18 Nov 2025 10:41:07 +0100 Subject: [PATCH 19/48] haproxy-spoe-auth edege -> edge for the track --- haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py b/haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py index 6fab5950e..494bee603 100644 --- a/haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py +++ b/haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py @@ -43,7 +43,7 @@ def install(self) -> None: """ try: if not self.haproxy_spoe_auth_snap.present: - self.haproxy_spoe_auth_snap.ensure(snap.SnapState.Latest, channel="edege") + self.haproxy_spoe_auth_snap.ensure(snap.SnapState.Latest, channel="edge") self.haproxy_spoe_auth_snap.restart(reload=True) except snap.SnapError as e: logger.error("An exception occurred when installing charmcraft. Reason: %s", e.message) From 829f8089af46fa014f12da6a70bd5ad934628601 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 18 Nov 2025 10:44:53 +0100 Subject: [PATCH 20/48] update licencerc --- .licenserc.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.licenserc.yaml b/.licenserc.yaml index 893f811f5..459fa0645 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -28,4 +28,6 @@ header: - 'docs/release-notes/**/*.yaml' - '**/*.md.j2' - '**/lib/**' + - '**/uv.lock' + - '**/templates/**' comment: on-failure From 1e73f9c498c8deda63bb38a8e49d3b7898e0aef7 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 18 Nov 2025 14:27:41 +0100 Subject: [PATCH 21/48] ignore haproxy_spoe_auth_operator in lint and unit tests --- tox.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tox.toml b/tox.toml index e5426fe50..5c4e11418 100644 --- a/tox.toml +++ b/tox.toml @@ -86,6 +86,7 @@ commands = [ "-m", "pytest", "--ignore={[vars]tst_path}integration", + "--ignore={toxinidir}/haproxy_spoe_auth_operator", "-v", "--tb", "native", @@ -119,6 +120,7 @@ commands = [ "--tb", "native", "--ignore={[vars]tst_path}unit", + "--ignore={toxinidir}/haproxy_spoe_auth_operator", "--log-cli-level=INFO", "-s", { replace = "posargs", extend = "true" }, From 71bfb0bf4262eb483ea4b54085a9a5f179068d2b Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 18 Nov 2025 14:29:35 +0100 Subject: [PATCH 22/48] remove config for spoe-auth in main repo --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5877f9a7f..36f11cb35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,7 +120,7 @@ namespace_packages = true [[tool.mypy.overrides]] disallow_untyped_defs = false -module = ["tests.*", "haproxy_spoe_auth_operator.tests.*"] +module = ["tests.*"] [tool.bandit] exclude_dirs = [ "/venv/" ] From ced404b2e89b9b296ca53465e2640fa8c0b6d48e Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 25 Nov 2025 15:12:10 +0100 Subject: [PATCH 23/48] update source in snapcraft.yaml --- spoe_auth_snap/snapcraft.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spoe_auth_snap/snapcraft.yaml b/spoe_auth_snap/snapcraft.yaml index e2c75b617..5a04dc0b4 100644 --- a/spoe_auth_snap/snapcraft.yaml +++ b/spoe_auth_snap/snapcraft.yaml @@ -15,7 +15,7 @@ confinement: strict parts: haproxy-spoe-auth: plugin: dump - source: https://github.com/Thanhphan1147/haproxy-spoe-auth.git + source: https://github.com/criteo/haproxy-spoe-auth.git source-type: git build-snaps: - go/1.22/stable From f64d20187cec4353158481b2c8a6cad49a225a9e Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 25 Nov 2025 15:13:30 +0100 Subject: [PATCH 24/48] update integration test workflow --- .github/workflows/integration_test.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 3340433ca..731ab1d7a 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -30,6 +30,8 @@ jobs: with: juju-channel: '3/stable' provider: lxd + self-hosted-runner: true + charmcraft-channel: latest/edge working-directory: ./haproxy_spoe_auth_operator with-uv: True modules: '["test_charm.py",]' From fbeed9a68a2629bb5f71176c675b1a1458c9481c Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 25 Nov 2025 22:56:25 +0100 Subject: [PATCH 25/48] Move haproxy-operator to a monorepo --- .github/workflows/integration_test.yaml | 1 + .github/workflows/publish_charm.yaml | 1 + .github/workflows/test.yaml | 2 +- .gitignore | 23 ++++++++++--------- README.md => haproxy_operator/README.md | 0 .../charmcraft.yaml | 0 .../v1/certificate_transfer.py | 0 .../lib}/charms/grafana_agent/v0/cos_agent.py | 0 .../charms/haproxy/v0/haproxy_route_tcp.py | 0 .../lib}/charms/haproxy/v1/haproxy_route.py | 0 .../lib}/charms/operator_libs_linux/v0/apt.py | 0 .../charms/operator_libs_linux/v1/systemd.py | 0 .../v4/tls_certificates.py | 0 .../charms/traefik_k8s/v1/ingress_per_unit.py | 0 .../lib}/charms/traefik_k8s/v2/ingress.py | 0 .../pyproject.toml | 0 {src => haproxy_operator/src}/charm.py | 0 .../src}/grafana_dashboards/haproxy.json | 0 {src => haproxy_operator/src}/haproxy.py | 0 .../src}/http_interface.py | 0 {src => haproxy_operator/src}/legacy.py | 0 .../loki_alert_rules/hacluster_no_quorum.rule | 0 .../loki_alert_rules/hacluster_node_lost.rule | 0 .../hacluster_vip_started.rule | 0 .../hacluster_vip_stopped.rule | 0 .../src}/state/charm_state.py | 0 .../src}/state/exception.py | 0 {src => haproxy_operator/src}/state/ha.py | 0 .../src}/state/haproxy_route.py | 0 .../src}/state/haproxy_route_tcp.py | 0 .../src}/state/ingress.py | 0 .../src}/state/ingress_per_unit.py | 0 {src => haproxy_operator/src}/state/tls.py | 0 .../src}/state/validation.py | 0 {src => haproxy_operator/src}/tls_relation.py | 0 .../templates}/haproxy.cfg.j2 | 0 .../templates}/haproxy_ingress.cfg.j2 | 0 .../haproxy_ingress_per_unit.cfg.j2 | 0 .../templates}/haproxy_legacy.cfg.j2 | 0 .../templates}/haproxy_route.cfg.j2 | 0 .../templates}/haproxy_route_tcp.cfg.j2 | 0 {tests => haproxy_operator/tests}/conftest.py | 0 .../tests}/integration/__init__.py | 0 .../tests}/integration/conftest.py | 0 .../integration/haproxy_route_requirer.py | 0 .../integration/haproxy_route_tcp_requirer.py | 0 .../tests}/integration/helper.py | 0 .../integration/ingress_per_unit_requirer.py | 0 .../tests}/integration/legacy/__init__.py | 0 .../tests}/integration/legacy/conftest.py | 0 .../legacy/haproxy_route_requirer.py | 0 .../tests}/integration/legacy/helper.py | 0 .../tests}/integration/legacy/test_action.py | 0 .../tests}/integration/legacy/test_charm.py | 0 .../tests}/integration/legacy/test_config.py | 0 .../tests}/integration/legacy/test_cos.py | 0 .../tests}/integration/legacy/test_ha.py | 0 .../integration/legacy/test_haproxy_route.py | 0 .../integration/legacy/test_http_interface.py | 0 .../tests}/integration/legacy/test_ingress.py | 0 .../tests}/integration/legacy/test_website.py | 0 .../tests}/integration/test_actions.py | 0 .../tests}/integration/test_haproxy_route.py | 0 .../integration/test_haproxy_route_tcp.py | 0 .../integration/test_ingress_per_unit.py | 0 .../tests}/unit/__init__.py | 0 .../tests}/unit/conftest.py | 0 .../tests}/unit/legacy/conftest.py | 0 .../tests}/unit/legacy/test_ha.py | 0 .../unit/legacy/test_haproxy_route_lib.py | 0 .../tests}/unit/legacy/test_tls_relation.py | 0 .../tests}/unit/test_charm.py | 0 .../tests}/unit/test_haproxy_route.py | 0 .../tests}/unit/test_haproxy_route_options.py | 0 .../tests}/unit/test_haproxy_route_tcp_lib.py | 0 .../tests}/unit/test_haproxy_service.py | 0 .../tests}/unit/test_state.py | 0 tox.toml => haproxy_operator/tox.toml | 0 uv.lock => haproxy_operator/uv.lock | 0 haproxy_spoe_auth_operator/pyproject.toml | 2 +- haproxy_spoe_auth_operator/src/charm.py | 4 +++- haproxy_spoe_auth_operator/uv.lock | 4 ++-- 82 files changed, 21 insertions(+), 16 deletions(-) rename README.md => haproxy_operator/README.md (100%) rename charmcraft.yaml => haproxy_operator/charmcraft.yaml (100%) rename {lib => haproxy_operator/lib}/charms/certificate_transfer_interface/v1/certificate_transfer.py (100%) rename {lib => haproxy_operator/lib}/charms/grafana_agent/v0/cos_agent.py (100%) rename {lib => haproxy_operator/lib}/charms/haproxy/v0/haproxy_route_tcp.py (100%) rename {lib => haproxy_operator/lib}/charms/haproxy/v1/haproxy_route.py (100%) rename {lib => haproxy_operator/lib}/charms/operator_libs_linux/v0/apt.py (100%) rename {lib => haproxy_operator/lib}/charms/operator_libs_linux/v1/systemd.py (100%) rename {lib => haproxy_operator/lib}/charms/tls_certificates_interface/v4/tls_certificates.py (100%) rename {lib => haproxy_operator/lib}/charms/traefik_k8s/v1/ingress_per_unit.py (100%) rename {lib => haproxy_operator/lib}/charms/traefik_k8s/v2/ingress.py (100%) rename pyproject.toml => haproxy_operator/pyproject.toml (100%) rename {src => haproxy_operator/src}/charm.py (100%) rename {src => haproxy_operator/src}/grafana_dashboards/haproxy.json (100%) rename {src => haproxy_operator/src}/haproxy.py (100%) rename {src => haproxy_operator/src}/http_interface.py (100%) rename {src => haproxy_operator/src}/legacy.py (100%) rename {src => haproxy_operator/src}/loki_alert_rules/hacluster_no_quorum.rule (100%) rename {src => haproxy_operator/src}/loki_alert_rules/hacluster_node_lost.rule (100%) rename {src => haproxy_operator/src}/loki_alert_rules/hacluster_vip_started.rule (100%) rename {src => haproxy_operator/src}/loki_alert_rules/hacluster_vip_stopped.rule (100%) rename {src => haproxy_operator/src}/state/charm_state.py (100%) rename {src => haproxy_operator/src}/state/exception.py (100%) rename {src => haproxy_operator/src}/state/ha.py (100%) rename {src => haproxy_operator/src}/state/haproxy_route.py (100%) rename {src => haproxy_operator/src}/state/haproxy_route_tcp.py (100%) rename {src => haproxy_operator/src}/state/ingress.py (100%) rename {src => haproxy_operator/src}/state/ingress_per_unit.py (100%) rename {src => haproxy_operator/src}/state/tls.py (100%) rename {src => haproxy_operator/src}/state/validation.py (100%) rename {src => haproxy_operator/src}/tls_relation.py (100%) rename {templates => haproxy_operator/templates}/haproxy.cfg.j2 (100%) rename {templates => haproxy_operator/templates}/haproxy_ingress.cfg.j2 (100%) rename {templates => haproxy_operator/templates}/haproxy_ingress_per_unit.cfg.j2 (100%) rename {templates => haproxy_operator/templates}/haproxy_legacy.cfg.j2 (100%) rename {templates => haproxy_operator/templates}/haproxy_route.cfg.j2 (100%) rename {templates => haproxy_operator/templates}/haproxy_route_tcp.cfg.j2 (100%) rename {tests => haproxy_operator/tests}/conftest.py (100%) rename {tests => haproxy_operator/tests}/integration/__init__.py (100%) rename {tests => haproxy_operator/tests}/integration/conftest.py (100%) rename {tests => haproxy_operator/tests}/integration/haproxy_route_requirer.py (100%) rename {tests => haproxy_operator/tests}/integration/haproxy_route_tcp_requirer.py (100%) rename {tests => haproxy_operator/tests}/integration/helper.py (100%) rename {tests => haproxy_operator/tests}/integration/ingress_per_unit_requirer.py (100%) rename {tests => haproxy_operator/tests}/integration/legacy/__init__.py (100%) rename {tests => haproxy_operator/tests}/integration/legacy/conftest.py (100%) rename {tests => haproxy_operator/tests}/integration/legacy/haproxy_route_requirer.py (100%) rename {tests => haproxy_operator/tests}/integration/legacy/helper.py (100%) rename {tests => haproxy_operator/tests}/integration/legacy/test_action.py (100%) rename {tests => haproxy_operator/tests}/integration/legacy/test_charm.py (100%) rename {tests => haproxy_operator/tests}/integration/legacy/test_config.py (100%) rename {tests => haproxy_operator/tests}/integration/legacy/test_cos.py (100%) rename {tests => haproxy_operator/tests}/integration/legacy/test_ha.py (100%) rename {tests => haproxy_operator/tests}/integration/legacy/test_haproxy_route.py (100%) rename {tests => haproxy_operator/tests}/integration/legacy/test_http_interface.py (100%) rename {tests => haproxy_operator/tests}/integration/legacy/test_ingress.py (100%) rename {tests => haproxy_operator/tests}/integration/legacy/test_website.py (100%) rename {tests => haproxy_operator/tests}/integration/test_actions.py (100%) rename {tests => haproxy_operator/tests}/integration/test_haproxy_route.py (100%) rename {tests => haproxy_operator/tests}/integration/test_haproxy_route_tcp.py (100%) rename {tests => haproxy_operator/tests}/integration/test_ingress_per_unit.py (100%) rename {tests => haproxy_operator/tests}/unit/__init__.py (100%) rename {tests => haproxy_operator/tests}/unit/conftest.py (100%) rename {tests => haproxy_operator/tests}/unit/legacy/conftest.py (100%) rename {tests => haproxy_operator/tests}/unit/legacy/test_ha.py (100%) rename {tests => haproxy_operator/tests}/unit/legacy/test_haproxy_route_lib.py (100%) rename {tests => haproxy_operator/tests}/unit/legacy/test_tls_relation.py (100%) rename {tests => haproxy_operator/tests}/unit/test_charm.py (100%) rename {tests => haproxy_operator/tests}/unit/test_haproxy_route.py (100%) rename {tests => haproxy_operator/tests}/unit/test_haproxy_route_options.py (100%) rename {tests => haproxy_operator/tests}/unit/test_haproxy_route_tcp_lib.py (100%) rename {tests => haproxy_operator/tests}/unit/test_haproxy_service.py (100%) rename {tests => haproxy_operator/tests}/unit/test_state.py (100%) rename tox.toml => haproxy_operator/tox.toml (100%) rename uv.lock => haproxy_operator/uv.lock (100%) diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 731ab1d7a..67188b39a 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -15,6 +15,7 @@ jobs: juju-channel: 3/stable self-hosted-runner: true charmcraft-channel: latest/edge + working-directory: ./haproxy_operator modules: '["test_action.py","test_actions.py","test_charm.py","test_config.py","test_cos.py","test_ha.py","test_haproxy_route.py","test_http_interface.py","test_ingress.py", "test_ingress_per_unit.py", "test_haproxy_route_tcp.py"]' with-uv: true diff --git a/.github/workflows/publish_charm.yaml b/.github/workflows/publish_charm.yaml index f5cb19309..8471d66c7 100644 --- a/.github/workflows/publish_charm.yaml +++ b/.github/workflows/publish_charm.yaml @@ -16,3 +16,4 @@ jobs: secrets: inherit with: channel: 2.8/edge + working-directory: ./haproxy_operator diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 181a8e727..5c98ebe4e 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,7 +10,7 @@ jobs: with: self-hosted-runner: true self-hosted-runner-image: "noble" - charmcraft-channel: latest/edge + working-directory: ./haproxy_operator with-uv: true haproxy-spoe-auth-operator: name: Unit tests for the haproxy-spoe-auth-operator charm diff --git a/.gitignore b/.gitignore index 6393cbb28..1fba8d8f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,18 @@ -venv/ -build/ -*.charm -.tox/ -.coverage +**/venv/ +**/build/ +**/*.charm +**/*.snap +**/.tox/ +**/.coverage __pycache__/ -*.py[cod] -.idea -.vscode -.mypy_cache +**/*.py[cod] +**/.idea +**/.vscode +**/.mypy_cache +**/.ruff_cache *.egg-info/ */*.rock -.venv/ +**/.venv/ # BEGIN VALE WORKFLOW IGNORE .vale/styles/* @@ -24,7 +26,6 @@ __pycache__/ !.vale/styles/config/vocabularies/local # END VALE WORKFLOW IGNORE -**/*.snap terraform/**/.terraform* terraform/**/.tfvars terraform/**/*.tfstate* diff --git a/README.md b/haproxy_operator/README.md similarity index 100% rename from README.md rename to haproxy_operator/README.md diff --git a/charmcraft.yaml b/haproxy_operator/charmcraft.yaml similarity index 100% rename from charmcraft.yaml rename to haproxy_operator/charmcraft.yaml diff --git a/lib/charms/certificate_transfer_interface/v1/certificate_transfer.py b/haproxy_operator/lib/charms/certificate_transfer_interface/v1/certificate_transfer.py similarity index 100% rename from lib/charms/certificate_transfer_interface/v1/certificate_transfer.py rename to haproxy_operator/lib/charms/certificate_transfer_interface/v1/certificate_transfer.py diff --git a/lib/charms/grafana_agent/v0/cos_agent.py b/haproxy_operator/lib/charms/grafana_agent/v0/cos_agent.py similarity index 100% rename from lib/charms/grafana_agent/v0/cos_agent.py rename to haproxy_operator/lib/charms/grafana_agent/v0/cos_agent.py diff --git a/lib/charms/haproxy/v0/haproxy_route_tcp.py b/haproxy_operator/lib/charms/haproxy/v0/haproxy_route_tcp.py similarity index 100% rename from lib/charms/haproxy/v0/haproxy_route_tcp.py rename to haproxy_operator/lib/charms/haproxy/v0/haproxy_route_tcp.py diff --git a/lib/charms/haproxy/v1/haproxy_route.py b/haproxy_operator/lib/charms/haproxy/v1/haproxy_route.py similarity index 100% rename from lib/charms/haproxy/v1/haproxy_route.py rename to haproxy_operator/lib/charms/haproxy/v1/haproxy_route.py diff --git a/lib/charms/operator_libs_linux/v0/apt.py b/haproxy_operator/lib/charms/operator_libs_linux/v0/apt.py similarity index 100% rename from lib/charms/operator_libs_linux/v0/apt.py rename to haproxy_operator/lib/charms/operator_libs_linux/v0/apt.py diff --git a/lib/charms/operator_libs_linux/v1/systemd.py b/haproxy_operator/lib/charms/operator_libs_linux/v1/systemd.py similarity index 100% rename from lib/charms/operator_libs_linux/v1/systemd.py rename to haproxy_operator/lib/charms/operator_libs_linux/v1/systemd.py diff --git a/lib/charms/tls_certificates_interface/v4/tls_certificates.py b/haproxy_operator/lib/charms/tls_certificates_interface/v4/tls_certificates.py similarity index 100% rename from lib/charms/tls_certificates_interface/v4/tls_certificates.py rename to haproxy_operator/lib/charms/tls_certificates_interface/v4/tls_certificates.py diff --git a/lib/charms/traefik_k8s/v1/ingress_per_unit.py b/haproxy_operator/lib/charms/traefik_k8s/v1/ingress_per_unit.py similarity index 100% rename from lib/charms/traefik_k8s/v1/ingress_per_unit.py rename to haproxy_operator/lib/charms/traefik_k8s/v1/ingress_per_unit.py diff --git a/lib/charms/traefik_k8s/v2/ingress.py b/haproxy_operator/lib/charms/traefik_k8s/v2/ingress.py similarity index 100% rename from lib/charms/traefik_k8s/v2/ingress.py rename to haproxy_operator/lib/charms/traefik_k8s/v2/ingress.py diff --git a/pyproject.toml b/haproxy_operator/pyproject.toml similarity index 100% rename from pyproject.toml rename to haproxy_operator/pyproject.toml diff --git a/src/charm.py b/haproxy_operator/src/charm.py similarity index 100% rename from src/charm.py rename to haproxy_operator/src/charm.py diff --git a/src/grafana_dashboards/haproxy.json b/haproxy_operator/src/grafana_dashboards/haproxy.json similarity index 100% rename from src/grafana_dashboards/haproxy.json rename to haproxy_operator/src/grafana_dashboards/haproxy.json diff --git a/src/haproxy.py b/haproxy_operator/src/haproxy.py similarity index 100% rename from src/haproxy.py rename to haproxy_operator/src/haproxy.py diff --git a/src/http_interface.py b/haproxy_operator/src/http_interface.py similarity index 100% rename from src/http_interface.py rename to haproxy_operator/src/http_interface.py diff --git a/src/legacy.py b/haproxy_operator/src/legacy.py similarity index 100% rename from src/legacy.py rename to haproxy_operator/src/legacy.py diff --git a/src/loki_alert_rules/hacluster_no_quorum.rule b/haproxy_operator/src/loki_alert_rules/hacluster_no_quorum.rule similarity index 100% rename from src/loki_alert_rules/hacluster_no_quorum.rule rename to haproxy_operator/src/loki_alert_rules/hacluster_no_quorum.rule diff --git a/src/loki_alert_rules/hacluster_node_lost.rule b/haproxy_operator/src/loki_alert_rules/hacluster_node_lost.rule similarity index 100% rename from src/loki_alert_rules/hacluster_node_lost.rule rename to haproxy_operator/src/loki_alert_rules/hacluster_node_lost.rule diff --git a/src/loki_alert_rules/hacluster_vip_started.rule b/haproxy_operator/src/loki_alert_rules/hacluster_vip_started.rule similarity index 100% rename from src/loki_alert_rules/hacluster_vip_started.rule rename to haproxy_operator/src/loki_alert_rules/hacluster_vip_started.rule diff --git a/src/loki_alert_rules/hacluster_vip_stopped.rule b/haproxy_operator/src/loki_alert_rules/hacluster_vip_stopped.rule similarity index 100% rename from src/loki_alert_rules/hacluster_vip_stopped.rule rename to haproxy_operator/src/loki_alert_rules/hacluster_vip_stopped.rule diff --git a/src/state/charm_state.py b/haproxy_operator/src/state/charm_state.py similarity index 100% rename from src/state/charm_state.py rename to haproxy_operator/src/state/charm_state.py diff --git a/src/state/exception.py b/haproxy_operator/src/state/exception.py similarity index 100% rename from src/state/exception.py rename to haproxy_operator/src/state/exception.py diff --git a/src/state/ha.py b/haproxy_operator/src/state/ha.py similarity index 100% rename from src/state/ha.py rename to haproxy_operator/src/state/ha.py diff --git a/src/state/haproxy_route.py b/haproxy_operator/src/state/haproxy_route.py similarity index 100% rename from src/state/haproxy_route.py rename to haproxy_operator/src/state/haproxy_route.py diff --git a/src/state/haproxy_route_tcp.py b/haproxy_operator/src/state/haproxy_route_tcp.py similarity index 100% rename from src/state/haproxy_route_tcp.py rename to haproxy_operator/src/state/haproxy_route_tcp.py diff --git a/src/state/ingress.py b/haproxy_operator/src/state/ingress.py similarity index 100% rename from src/state/ingress.py rename to haproxy_operator/src/state/ingress.py diff --git a/src/state/ingress_per_unit.py b/haproxy_operator/src/state/ingress_per_unit.py similarity index 100% rename from src/state/ingress_per_unit.py rename to haproxy_operator/src/state/ingress_per_unit.py diff --git a/src/state/tls.py b/haproxy_operator/src/state/tls.py similarity index 100% rename from src/state/tls.py rename to haproxy_operator/src/state/tls.py diff --git a/src/state/validation.py b/haproxy_operator/src/state/validation.py similarity index 100% rename from src/state/validation.py rename to haproxy_operator/src/state/validation.py diff --git a/src/tls_relation.py b/haproxy_operator/src/tls_relation.py similarity index 100% rename from src/tls_relation.py rename to haproxy_operator/src/tls_relation.py diff --git a/templates/haproxy.cfg.j2 b/haproxy_operator/templates/haproxy.cfg.j2 similarity index 100% rename from templates/haproxy.cfg.j2 rename to haproxy_operator/templates/haproxy.cfg.j2 diff --git a/templates/haproxy_ingress.cfg.j2 b/haproxy_operator/templates/haproxy_ingress.cfg.j2 similarity index 100% rename from templates/haproxy_ingress.cfg.j2 rename to haproxy_operator/templates/haproxy_ingress.cfg.j2 diff --git a/templates/haproxy_ingress_per_unit.cfg.j2 b/haproxy_operator/templates/haproxy_ingress_per_unit.cfg.j2 similarity index 100% rename from templates/haproxy_ingress_per_unit.cfg.j2 rename to haproxy_operator/templates/haproxy_ingress_per_unit.cfg.j2 diff --git a/templates/haproxy_legacy.cfg.j2 b/haproxy_operator/templates/haproxy_legacy.cfg.j2 similarity index 100% rename from templates/haproxy_legacy.cfg.j2 rename to haproxy_operator/templates/haproxy_legacy.cfg.j2 diff --git a/templates/haproxy_route.cfg.j2 b/haproxy_operator/templates/haproxy_route.cfg.j2 similarity index 100% rename from templates/haproxy_route.cfg.j2 rename to haproxy_operator/templates/haproxy_route.cfg.j2 diff --git a/templates/haproxy_route_tcp.cfg.j2 b/haproxy_operator/templates/haproxy_route_tcp.cfg.j2 similarity index 100% rename from templates/haproxy_route_tcp.cfg.j2 rename to haproxy_operator/templates/haproxy_route_tcp.cfg.j2 diff --git a/tests/conftest.py b/haproxy_operator/tests/conftest.py similarity index 100% rename from tests/conftest.py rename to haproxy_operator/tests/conftest.py diff --git a/tests/integration/__init__.py b/haproxy_operator/tests/integration/__init__.py similarity index 100% rename from tests/integration/__init__.py rename to haproxy_operator/tests/integration/__init__.py diff --git a/tests/integration/conftest.py b/haproxy_operator/tests/integration/conftest.py similarity index 100% rename from tests/integration/conftest.py rename to haproxy_operator/tests/integration/conftest.py diff --git a/tests/integration/haproxy_route_requirer.py b/haproxy_operator/tests/integration/haproxy_route_requirer.py similarity index 100% rename from tests/integration/haproxy_route_requirer.py rename to haproxy_operator/tests/integration/haproxy_route_requirer.py diff --git a/tests/integration/haproxy_route_tcp_requirer.py b/haproxy_operator/tests/integration/haproxy_route_tcp_requirer.py similarity index 100% rename from tests/integration/haproxy_route_tcp_requirer.py rename to haproxy_operator/tests/integration/haproxy_route_tcp_requirer.py diff --git a/tests/integration/helper.py b/haproxy_operator/tests/integration/helper.py similarity index 100% rename from tests/integration/helper.py rename to haproxy_operator/tests/integration/helper.py diff --git a/tests/integration/ingress_per_unit_requirer.py b/haproxy_operator/tests/integration/ingress_per_unit_requirer.py similarity index 100% rename from tests/integration/ingress_per_unit_requirer.py rename to haproxy_operator/tests/integration/ingress_per_unit_requirer.py diff --git a/tests/integration/legacy/__init__.py b/haproxy_operator/tests/integration/legacy/__init__.py similarity index 100% rename from tests/integration/legacy/__init__.py rename to haproxy_operator/tests/integration/legacy/__init__.py diff --git a/tests/integration/legacy/conftest.py b/haproxy_operator/tests/integration/legacy/conftest.py similarity index 100% rename from tests/integration/legacy/conftest.py rename to haproxy_operator/tests/integration/legacy/conftest.py diff --git a/tests/integration/legacy/haproxy_route_requirer.py b/haproxy_operator/tests/integration/legacy/haproxy_route_requirer.py similarity index 100% rename from tests/integration/legacy/haproxy_route_requirer.py rename to haproxy_operator/tests/integration/legacy/haproxy_route_requirer.py diff --git a/tests/integration/legacy/helper.py b/haproxy_operator/tests/integration/legacy/helper.py similarity index 100% rename from tests/integration/legacy/helper.py rename to haproxy_operator/tests/integration/legacy/helper.py diff --git a/tests/integration/legacy/test_action.py b/haproxy_operator/tests/integration/legacy/test_action.py similarity index 100% rename from tests/integration/legacy/test_action.py rename to haproxy_operator/tests/integration/legacy/test_action.py diff --git a/tests/integration/legacy/test_charm.py b/haproxy_operator/tests/integration/legacy/test_charm.py similarity index 100% rename from tests/integration/legacy/test_charm.py rename to haproxy_operator/tests/integration/legacy/test_charm.py diff --git a/tests/integration/legacy/test_config.py b/haproxy_operator/tests/integration/legacy/test_config.py similarity index 100% rename from tests/integration/legacy/test_config.py rename to haproxy_operator/tests/integration/legacy/test_config.py diff --git a/tests/integration/legacy/test_cos.py b/haproxy_operator/tests/integration/legacy/test_cos.py similarity index 100% rename from tests/integration/legacy/test_cos.py rename to haproxy_operator/tests/integration/legacy/test_cos.py diff --git a/tests/integration/legacy/test_ha.py b/haproxy_operator/tests/integration/legacy/test_ha.py similarity index 100% rename from tests/integration/legacy/test_ha.py rename to haproxy_operator/tests/integration/legacy/test_ha.py diff --git a/tests/integration/legacy/test_haproxy_route.py b/haproxy_operator/tests/integration/legacy/test_haproxy_route.py similarity index 100% rename from tests/integration/legacy/test_haproxy_route.py rename to haproxy_operator/tests/integration/legacy/test_haproxy_route.py diff --git a/tests/integration/legacy/test_http_interface.py b/haproxy_operator/tests/integration/legacy/test_http_interface.py similarity index 100% rename from tests/integration/legacy/test_http_interface.py rename to haproxy_operator/tests/integration/legacy/test_http_interface.py diff --git a/tests/integration/legacy/test_ingress.py b/haproxy_operator/tests/integration/legacy/test_ingress.py similarity index 100% rename from tests/integration/legacy/test_ingress.py rename to haproxy_operator/tests/integration/legacy/test_ingress.py diff --git a/tests/integration/legacy/test_website.py b/haproxy_operator/tests/integration/legacy/test_website.py similarity index 100% rename from tests/integration/legacy/test_website.py rename to haproxy_operator/tests/integration/legacy/test_website.py diff --git a/tests/integration/test_actions.py b/haproxy_operator/tests/integration/test_actions.py similarity index 100% rename from tests/integration/test_actions.py rename to haproxy_operator/tests/integration/test_actions.py diff --git a/tests/integration/test_haproxy_route.py b/haproxy_operator/tests/integration/test_haproxy_route.py similarity index 100% rename from tests/integration/test_haproxy_route.py rename to haproxy_operator/tests/integration/test_haproxy_route.py diff --git a/tests/integration/test_haproxy_route_tcp.py b/haproxy_operator/tests/integration/test_haproxy_route_tcp.py similarity index 100% rename from tests/integration/test_haproxy_route_tcp.py rename to haproxy_operator/tests/integration/test_haproxy_route_tcp.py diff --git a/tests/integration/test_ingress_per_unit.py b/haproxy_operator/tests/integration/test_ingress_per_unit.py similarity index 100% rename from tests/integration/test_ingress_per_unit.py rename to haproxy_operator/tests/integration/test_ingress_per_unit.py diff --git a/tests/unit/__init__.py b/haproxy_operator/tests/unit/__init__.py similarity index 100% rename from tests/unit/__init__.py rename to haproxy_operator/tests/unit/__init__.py diff --git a/tests/unit/conftest.py b/haproxy_operator/tests/unit/conftest.py similarity index 100% rename from tests/unit/conftest.py rename to haproxy_operator/tests/unit/conftest.py diff --git a/tests/unit/legacy/conftest.py b/haproxy_operator/tests/unit/legacy/conftest.py similarity index 100% rename from tests/unit/legacy/conftest.py rename to haproxy_operator/tests/unit/legacy/conftest.py diff --git a/tests/unit/legacy/test_ha.py b/haproxy_operator/tests/unit/legacy/test_ha.py similarity index 100% rename from tests/unit/legacy/test_ha.py rename to haproxy_operator/tests/unit/legacy/test_ha.py diff --git a/tests/unit/legacy/test_haproxy_route_lib.py b/haproxy_operator/tests/unit/legacy/test_haproxy_route_lib.py similarity index 100% rename from tests/unit/legacy/test_haproxy_route_lib.py rename to haproxy_operator/tests/unit/legacy/test_haproxy_route_lib.py diff --git a/tests/unit/legacy/test_tls_relation.py b/haproxy_operator/tests/unit/legacy/test_tls_relation.py similarity index 100% rename from tests/unit/legacy/test_tls_relation.py rename to haproxy_operator/tests/unit/legacy/test_tls_relation.py diff --git a/tests/unit/test_charm.py b/haproxy_operator/tests/unit/test_charm.py similarity index 100% rename from tests/unit/test_charm.py rename to haproxy_operator/tests/unit/test_charm.py diff --git a/tests/unit/test_haproxy_route.py b/haproxy_operator/tests/unit/test_haproxy_route.py similarity index 100% rename from tests/unit/test_haproxy_route.py rename to haproxy_operator/tests/unit/test_haproxy_route.py diff --git a/tests/unit/test_haproxy_route_options.py b/haproxy_operator/tests/unit/test_haproxy_route_options.py similarity index 100% rename from tests/unit/test_haproxy_route_options.py rename to haproxy_operator/tests/unit/test_haproxy_route_options.py diff --git a/tests/unit/test_haproxy_route_tcp_lib.py b/haproxy_operator/tests/unit/test_haproxy_route_tcp_lib.py similarity index 100% rename from tests/unit/test_haproxy_route_tcp_lib.py rename to haproxy_operator/tests/unit/test_haproxy_route_tcp_lib.py diff --git a/tests/unit/test_haproxy_service.py b/haproxy_operator/tests/unit/test_haproxy_service.py similarity index 100% rename from tests/unit/test_haproxy_service.py rename to haproxy_operator/tests/unit/test_haproxy_service.py diff --git a/tests/unit/test_state.py b/haproxy_operator/tests/unit/test_state.py similarity index 100% rename from tests/unit/test_state.py rename to haproxy_operator/tests/unit/test_state.py diff --git a/tox.toml b/haproxy_operator/tox.toml similarity index 100% rename from tox.toml rename to haproxy_operator/tox.toml diff --git a/uv.lock b/haproxy_operator/uv.lock similarity index 100% rename from uv.lock rename to haproxy_operator/uv.lock diff --git a/haproxy_spoe_auth_operator/pyproject.toml b/haproxy_spoe_auth_operator/pyproject.toml index 2fb0b4733..67862e6ab 100644 --- a/haproxy_spoe_auth_operator/pyproject.toml +++ b/haproxy_spoe_auth_operator/pyproject.toml @@ -34,7 +34,7 @@ integration = ["jubilant==1.1.1", "juju==3.6.1.0", "pytest", "pytest-asyncio"] package = false [tool.uv.sources] -charmlibs-interfaces-haproxy-spoe-auth = { git = "https://github.com/Thanhphan1147/charmlibs", subdirectory = "interfaces/haproxy_spoe_auth", rev = "feat/add-spoe-auth-lib" } +charmlibs-interfaces-haproxy-spoe-auth = { git = "https://github.com/Thanhphan1147/charmlibs", subdirectory = "interfaces/haproxy_spoe_auth", rev = "d0b2b27fa454394cc54d438ed18d25d88afa3ed5" } [tool.ruff] target-version = "py310" diff --git a/haproxy_spoe_auth_operator/src/charm.py b/haproxy_spoe_auth_operator/src/charm.py index 0aab442f9..8de0e03a6 100644 --- a/haproxy_spoe_auth_operator/src/charm.py +++ b/haproxy_spoe_auth_operator/src/charm.py @@ -30,6 +30,7 @@ VAR_REDIRECT_URL = "sess.auth.redirect_url" COOKIE_NAME = "authsession" SPOE_AUTH_RELATION = "spoe-auth" +SPOE_AUTH_MESSAGE_NAME = "try-auth-oidc" class HaproxySpoeAuthCharm(ops.CharmBase): @@ -72,11 +73,12 @@ def _reconcile(self, _: ops.EventBase) -> None: relation=relation, spop_port=SPOP_PORT, event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + message_name=SPOE_AUTH_MESSAGE_NAME, oidc_callback_port=OIDC_CALLBACK_PORT, var_authenticated=VAR_AUTHENTICATED, var_redirect_url=VAR_REDIRECT_URL, cookie_name=COOKIE_NAME, - oidc_callback_hostname=state.hostname, + hostname=state.hostname, oidc_callback_path=OIDC_CALLBACK_PATH, ) self.service.reconcile(charm_state=state, oauth_information=oauth_information) diff --git a/haproxy_spoe_auth_operator/uv.lock b/haproxy_spoe_auth_operator/uv.lock index a2c9e2be8..97fc4104f 100644 --- a/haproxy_spoe_auth_operator/uv.lock +++ b/haproxy_spoe_auth_operator/uv.lock @@ -179,7 +179,7 @@ wheels = [ [[package]] name = "charmlibs-interfaces-haproxy-spoe-auth" version = "0.0.1" -source = { git = "https://github.com/Thanhphan1147/charmlibs?subdirectory=interfaces%2Fhaproxy_spoe_auth&rev=feat%2Fadd-spoe-auth-lib#350ddd0a76df1cd239240a43eb90418555b28321" } +source = { git = "https://github.com/Thanhphan1147/charmlibs?subdirectory=interfaces%2Fhaproxy_spoe_auth&rev=d0b2b27fa454394cc54d438ed18d25d88afa3ed5#d0b2b27fa454394cc54d438ed18d25d88afa3ed5" } dependencies = [ { name = "ops" }, { name = "pydantic" }, @@ -462,7 +462,7 @@ unit = [ [package.metadata] requires-dist = [ - { name = "charmlibs-interfaces-haproxy-spoe-auth", git = "https://github.com/Thanhphan1147/charmlibs?subdirectory=interfaces%2Fhaproxy_spoe_auth&rev=feat%2Fadd-spoe-auth-lib" }, + { name = "charmlibs-interfaces-haproxy-spoe-auth", git = "https://github.com/Thanhphan1147/charmlibs?subdirectory=interfaces%2Fhaproxy_spoe_auth&rev=d0b2b27fa454394cc54d438ed18d25d88afa3ed5" }, { name = "charmlibs-snap", specifier = "==1.0.0" }, { name = "jinja2", specifier = "==3.1.6" }, { name = "jsonschema", specifier = "==4.25.1" }, From d066024fe453d3d2a4b8e49e72faef796e79624e Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 26 Nov 2025 12:02:12 +0100 Subject: [PATCH 26/48] rename repo, add readme for the rmonorepo, update license ignore file --- .github/workflows/integration_test.yaml | 4 ++-- .github/workflows/promote_charm.yaml | 1 + .github/workflows/publish_charm.yaml | 2 +- .github/workflows/publish_spoe_auth_snap.yaml | 4 ++-- .github/workflows/test.yaml | 4 ++-- .licenserc.yaml | 2 +- README.md | 20 +++++++++++++++++++ .../README.md | 0 .../charmcraft.yaml | 0 .../v1/certificate_transfer.py | 0 .../lib/charms/grafana_agent/v0/cos_agent.py | 0 .../charms/haproxy/v0/haproxy_route_tcp.py | 0 .../lib/charms/haproxy/v1/haproxy_route.py | 0 .../lib/charms/operator_libs_linux/v0/apt.py | 0 .../charms/operator_libs_linux/v1/systemd.py | 0 .../v4/tls_certificates.py | 0 .../charms/traefik_k8s/v1/ingress_per_unit.py | 0 .../lib/charms/traefik_k8s/v2/ingress.py | 0 .../pyproject.toml | 0 .../src/charm.py | 0 .../src/grafana_dashboards/haproxy.json | 0 .../src/haproxy.py | 0 .../src/http_interface.py | 0 .../src/legacy.py | 0 .../loki_alert_rules/hacluster_no_quorum.rule | 0 .../loki_alert_rules/hacluster_node_lost.rule | 0 .../hacluster_vip_started.rule | 0 .../hacluster_vip_stopped.rule | 0 .../src/state/charm_state.py | 0 .../src/state/exception.py | 0 .../src/state/ha.py | 0 .../src/state/haproxy_route.py | 0 .../src/state/haproxy_route_tcp.py | 0 .../src/state/ingress.py | 0 .../src/state/ingress_per_unit.py | 0 .../src/state/tls.py | 0 .../src/state/validation.py | 0 .../src/tls_relation.py | 0 .../templates/haproxy.cfg.j2 | 0 .../templates/haproxy_ingress.cfg.j2 | 0 .../templates/haproxy_ingress_per_unit.cfg.j2 | 0 .../templates/haproxy_legacy.cfg.j2 | 0 .../templates/haproxy_route.cfg.j2 | 0 .../templates/haproxy_route_tcp.cfg.j2 | 0 .../tests/conftest.py | 0 .../tests/integration/__init__.py | 0 .../tests/integration/conftest.py | 0 .../integration/haproxy_route_requirer.py | 0 .../integration/haproxy_route_tcp_requirer.py | 0 .../tests/integration/helper.py | 0 .../integration/ingress_per_unit_requirer.py | 0 .../tests/integration/legacy/__init__.py | 0 .../tests/integration/legacy/conftest.py | 0 .../legacy/haproxy_route_requirer.py | 0 .../tests/integration/legacy/helper.py | 0 .../tests/integration/legacy/test_action.py | 0 .../tests/integration/legacy/test_charm.py | 0 .../tests/integration/legacy/test_config.py | 0 .../tests/integration/legacy/test_cos.py | 0 .../tests/integration/legacy/test_ha.py | 0 .../integration/legacy/test_haproxy_route.py | 0 .../integration/legacy/test_http_interface.py | 0 .../tests/integration/legacy/test_ingress.py | 0 .../tests/integration/legacy/test_website.py | 0 .../tests/integration/test_actions.py | 0 .../tests/integration/test_haproxy_route.py | 0 .../integration/test_haproxy_route_tcp.py | 0 .../integration/test_ingress_per_unit.py | 0 .../tests/unit/__init__.py | 0 .../tests/unit/conftest.py | 0 .../tests/unit/legacy/conftest.py | 0 .../tests/unit/legacy/test_ha.py | 0 .../unit/legacy/test_haproxy_route_lib.py | 0 .../tests/unit/legacy/test_tls_relation.py | 0 .../tests/unit/test_charm.py | 0 .../tests/unit/test_haproxy_route.py | 0 .../tests/unit/test_haproxy_route_options.py | 0 .../tests/unit/test_haproxy_route_tcp_lib.py | 0 .../tests/unit/test_haproxy_service.py | 0 .../tests/unit/test_state.py | 0 .../tox.toml | 0 .../uv.lock | 0 .../README.md | 0 .../charmcraft.yaml | 0 .../lib/charms/hydra/v0/oauth.py | 0 .../pyproject.toml | 2 +- .../src/charm.py | 0 .../src/haproxy_spoe_auth_service.py | 0 .../src/state.py | 0 .../templates/config.yaml.j2 | 0 .../tests/integration/__init__.py | 0 .../tests/integration/conftest.py | 0 .../tests/integration/test_charm.py | 0 .../tests/unit/__init__.py | 0 .../tests/unit/conftest.py | 0 .../tests/unit/test_charm.py | 0 .../tox.toml | 0 .../uv.lock | 0 .../README.md | 0 .../hooks/bin/configure | 0 .../hooks/bin/default-configure | 0 .../snapcraft.yaml | 0 102 files changed, 30 insertions(+), 9 deletions(-) create mode 100644 README.md rename {haproxy_operator => haproxy-operator}/README.md (100%) rename {haproxy_operator => haproxy-operator}/charmcraft.yaml (100%) rename {haproxy_operator => haproxy-operator}/lib/charms/certificate_transfer_interface/v1/certificate_transfer.py (100%) rename {haproxy_operator => haproxy-operator}/lib/charms/grafana_agent/v0/cos_agent.py (100%) rename {haproxy_operator => haproxy-operator}/lib/charms/haproxy/v0/haproxy_route_tcp.py (100%) rename {haproxy_operator => haproxy-operator}/lib/charms/haproxy/v1/haproxy_route.py (100%) rename {haproxy_operator => haproxy-operator}/lib/charms/operator_libs_linux/v0/apt.py (100%) rename {haproxy_operator => haproxy-operator}/lib/charms/operator_libs_linux/v1/systemd.py (100%) rename {haproxy_operator => haproxy-operator}/lib/charms/tls_certificates_interface/v4/tls_certificates.py (100%) rename {haproxy_operator => haproxy-operator}/lib/charms/traefik_k8s/v1/ingress_per_unit.py (100%) rename {haproxy_operator => haproxy-operator}/lib/charms/traefik_k8s/v2/ingress.py (100%) rename {haproxy_operator => haproxy-operator}/pyproject.toml (100%) rename {haproxy_operator => haproxy-operator}/src/charm.py (100%) rename {haproxy_operator => haproxy-operator}/src/grafana_dashboards/haproxy.json (100%) rename {haproxy_operator => haproxy-operator}/src/haproxy.py (100%) rename {haproxy_operator => haproxy-operator}/src/http_interface.py (100%) rename {haproxy_operator => haproxy-operator}/src/legacy.py (100%) rename {haproxy_operator => haproxy-operator}/src/loki_alert_rules/hacluster_no_quorum.rule (100%) rename {haproxy_operator => haproxy-operator}/src/loki_alert_rules/hacluster_node_lost.rule (100%) rename {haproxy_operator => haproxy-operator}/src/loki_alert_rules/hacluster_vip_started.rule (100%) rename {haproxy_operator => haproxy-operator}/src/loki_alert_rules/hacluster_vip_stopped.rule (100%) rename {haproxy_operator => haproxy-operator}/src/state/charm_state.py (100%) rename {haproxy_operator => haproxy-operator}/src/state/exception.py (100%) rename {haproxy_operator => haproxy-operator}/src/state/ha.py (100%) rename {haproxy_operator => haproxy-operator}/src/state/haproxy_route.py (100%) rename {haproxy_operator => haproxy-operator}/src/state/haproxy_route_tcp.py (100%) rename {haproxy_operator => haproxy-operator}/src/state/ingress.py (100%) rename {haproxy_operator => haproxy-operator}/src/state/ingress_per_unit.py (100%) rename {haproxy_operator => haproxy-operator}/src/state/tls.py (100%) rename {haproxy_operator => haproxy-operator}/src/state/validation.py (100%) rename {haproxy_operator => haproxy-operator}/src/tls_relation.py (100%) rename {haproxy_operator => haproxy-operator}/templates/haproxy.cfg.j2 (100%) rename {haproxy_operator => haproxy-operator}/templates/haproxy_ingress.cfg.j2 (100%) rename {haproxy_operator => haproxy-operator}/templates/haproxy_ingress_per_unit.cfg.j2 (100%) rename {haproxy_operator => haproxy-operator}/templates/haproxy_legacy.cfg.j2 (100%) rename {haproxy_operator => haproxy-operator}/templates/haproxy_route.cfg.j2 (100%) rename {haproxy_operator => haproxy-operator}/templates/haproxy_route_tcp.cfg.j2 (100%) rename {haproxy_operator => haproxy-operator}/tests/conftest.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/__init__.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/conftest.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/haproxy_route_requirer.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/haproxy_route_tcp_requirer.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/helper.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/ingress_per_unit_requirer.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/legacy/__init__.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/legacy/conftest.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/legacy/haproxy_route_requirer.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/legacy/helper.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/legacy/test_action.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/legacy/test_charm.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/legacy/test_config.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/legacy/test_cos.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/legacy/test_ha.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/legacy/test_haproxy_route.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/legacy/test_http_interface.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/legacy/test_ingress.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/legacy/test_website.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/test_actions.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/test_haproxy_route.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/test_haproxy_route_tcp.py (100%) rename {haproxy_operator => haproxy-operator}/tests/integration/test_ingress_per_unit.py (100%) rename {haproxy_operator => haproxy-operator}/tests/unit/__init__.py (100%) rename {haproxy_operator => haproxy-operator}/tests/unit/conftest.py (100%) rename {haproxy_operator => haproxy-operator}/tests/unit/legacy/conftest.py (100%) rename {haproxy_operator => haproxy-operator}/tests/unit/legacy/test_ha.py (100%) rename {haproxy_operator => haproxy-operator}/tests/unit/legacy/test_haproxy_route_lib.py (100%) rename {haproxy_operator => haproxy-operator}/tests/unit/legacy/test_tls_relation.py (100%) rename {haproxy_operator => haproxy-operator}/tests/unit/test_charm.py (100%) rename {haproxy_operator => haproxy-operator}/tests/unit/test_haproxy_route.py (100%) rename {haproxy_operator => haproxy-operator}/tests/unit/test_haproxy_route_options.py (100%) rename {haproxy_operator => haproxy-operator}/tests/unit/test_haproxy_route_tcp_lib.py (100%) rename {haproxy_operator => haproxy-operator}/tests/unit/test_haproxy_service.py (100%) rename {haproxy_operator => haproxy-operator}/tests/unit/test_state.py (100%) rename {haproxy_operator => haproxy-operator}/tox.toml (100%) rename {haproxy_operator => haproxy-operator}/uv.lock (100%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/README.md (100%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/charmcraft.yaml (100%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/lib/charms/hydra/v0/oauth.py (100%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/pyproject.toml (96%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/src/charm.py (100%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/src/haproxy_spoe_auth_service.py (100%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/src/state.py (100%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/templates/config.yaml.j2 (100%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/tests/integration/__init__.py (100%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/tests/integration/conftest.py (100%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/tests/integration/test_charm.py (100%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/tests/unit/__init__.py (100%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/tests/unit/conftest.py (100%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/tests/unit/test_charm.py (100%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/tox.toml (100%) rename {haproxy_spoe_auth_operator => haproxy-spoe-auth-operator}/uv.lock (100%) rename {spoe_auth_snap => haproxy-spoe-auth-snap}/README.md (100%) rename {spoe_auth_snap => haproxy-spoe-auth-snap}/hooks/bin/configure (100%) rename {spoe_auth_snap => haproxy-spoe-auth-snap}/hooks/bin/default-configure (100%) rename {spoe_auth_snap => haproxy-spoe-auth-snap}/snapcraft.yaml (100%) diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 67188b39a..685cf3e1a 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -15,7 +15,7 @@ jobs: juju-channel: 3/stable self-hosted-runner: true charmcraft-channel: latest/edge - working-directory: ./haproxy_operator + working-directory: ./haproxy-operator modules: '["test_action.py","test_actions.py","test_charm.py","test_config.py","test_cos.py","test_ha.py","test_haproxy_route.py","test_http_interface.py","test_ingress.py", "test_ingress_per_unit.py", "test_haproxy_route_tcp.py"]' with-uv: true @@ -33,6 +33,6 @@ jobs: provider: lxd self-hosted-runner: true charmcraft-channel: latest/edge - working-directory: ./haproxy_spoe_auth_operator + working-directory: ./haproxy-spoe-auth-operator with-uv: True modules: '["test_charm.py",]' diff --git a/.github/workflows/promote_charm.yaml b/.github/workflows/promote_charm.yaml index 9132f62ce..f32a345e6 100644 --- a/.github/workflows/promote_charm.yaml +++ b/.github/workflows/promote_charm.yaml @@ -23,6 +23,7 @@ jobs: with: origin-channel: ${{ github.event.inputs.origin-channel }} destination-channel: ${{ github.event.inputs.destination-channel }} + working-directory: ./haproxy-operator base-channel: 24.04 base-name: ubuntu base-architecture: amd64 diff --git a/.github/workflows/publish_charm.yaml b/.github/workflows/publish_charm.yaml index 8471d66c7..bca906199 100644 --- a/.github/workflows/publish_charm.yaml +++ b/.github/workflows/publish_charm.yaml @@ -16,4 +16,4 @@ jobs: secrets: inherit with: channel: 2.8/edge - working-directory: ./haproxy_operator + working-directory: ./haproxy-operator diff --git a/.github/workflows/publish_spoe_auth_snap.yaml b/.github/workflows/publish_spoe_auth_snap.yaml index 5461ce76a..0d842e08b 100644 --- a/.github/workflows/publish_spoe_auth_snap.yaml +++ b/.github/workflows/publish_spoe_auth_snap.yaml @@ -6,7 +6,7 @@ on: branches: - main paths: - - spoe_auth_snap/** + - haproxy-spoe-auth-snap/** jobs: build: @@ -16,7 +16,7 @@ jobs: - uses: snapcore/action-build@v1.3.0 id: build with: - path: ./spoe_auth_snap + path: ./haproxy-spoe-auth-snap - uses: snapcore/action-publish@v1.2.0 env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.STORE_LOGIN }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5c98ebe4e..db9592077 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,7 +10,7 @@ jobs: with: self-hosted-runner: true self-hosted-runner-image: "noble" - working-directory: ./haproxy_operator + working-directory: ./haproxy-operator with-uv: true haproxy-spoe-auth-operator: name: Unit tests for the haproxy-spoe-auth-operator charm @@ -20,5 +20,5 @@ jobs: self-hosted-runner: true self-hosted-runner-image: "noble" python-version: 3.12 - working-directory: ./haproxy_spoe_auth_operator + working-directory: ./haproxy-spoe-auth-operator with-uv: True diff --git a/.licenserc.yaml b/.licenserc.yaml index 459fa0645..0536343ff 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -24,7 +24,7 @@ header: - 'lib/**' - '.woke.yaml' - 'templates/**' - - 'src/loki_alert_rules/*.rule' + - '**/src/loki_alert_rules/*.rule' - 'docs/release-notes/**/*.yaml' - '**/*.md.j2' - '**/lib/**' diff --git a/README.md b/README.md new file mode 100644 index 000000000..8071d1cf1 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# haproxy-operator + +This repository provides a collection of operators related to HAproxy + +This repository contains the code for the following charms: +1. `haproxy`: A machine charm managing HAproxy. See the [haproxy README](haproxy_-operator/README.md) for more information. +2. `haproxy-spoe-auth-operator`: A machine charm deploying an SPOE agent that serves as an authentication proxy. See the [haproxy-spoe-auth-operator README](haproxy-spoe-auth-operator/README.md) for more information. + +The repository also contains the snapped workload of some charms: +1. `haproxy-spoe-auth-snap`: A snap of the SPOE agent made for the haproxy-spoe-auth-operator charm. See the [haproxy-spoe-auth-snap README](haproxy-spoe-auth-snap/README.md) for more information. + +## Project and community + +The haproxy-operator project is a member of the Ubuntu family. It is an open source project that warmly welcomes community projects, contributions, suggestions, fixes and constructive feedback. + +* [Code of conduct](https://ubuntu.com/community/code-of-conduct) +* [Get support](https://discourse.charmhub.io/) +* [Issues](https://github.com/canonical/haproxy-operator/issues) +* [Matrix](https://matrix.to/#/#charmhub-charmdev:ubuntu.com) +* [Contribute](https://github.com/canonical/haproxy-operator/blob/main/CONTRIBUTING.md) diff --git a/haproxy_operator/README.md b/haproxy-operator/README.md similarity index 100% rename from haproxy_operator/README.md rename to haproxy-operator/README.md diff --git a/haproxy_operator/charmcraft.yaml b/haproxy-operator/charmcraft.yaml similarity index 100% rename from haproxy_operator/charmcraft.yaml rename to haproxy-operator/charmcraft.yaml diff --git a/haproxy_operator/lib/charms/certificate_transfer_interface/v1/certificate_transfer.py b/haproxy-operator/lib/charms/certificate_transfer_interface/v1/certificate_transfer.py similarity index 100% rename from haproxy_operator/lib/charms/certificate_transfer_interface/v1/certificate_transfer.py rename to haproxy-operator/lib/charms/certificate_transfer_interface/v1/certificate_transfer.py diff --git a/haproxy_operator/lib/charms/grafana_agent/v0/cos_agent.py b/haproxy-operator/lib/charms/grafana_agent/v0/cos_agent.py similarity index 100% rename from haproxy_operator/lib/charms/grafana_agent/v0/cos_agent.py rename to haproxy-operator/lib/charms/grafana_agent/v0/cos_agent.py diff --git a/haproxy_operator/lib/charms/haproxy/v0/haproxy_route_tcp.py b/haproxy-operator/lib/charms/haproxy/v0/haproxy_route_tcp.py similarity index 100% rename from haproxy_operator/lib/charms/haproxy/v0/haproxy_route_tcp.py rename to haproxy-operator/lib/charms/haproxy/v0/haproxy_route_tcp.py diff --git a/haproxy_operator/lib/charms/haproxy/v1/haproxy_route.py b/haproxy-operator/lib/charms/haproxy/v1/haproxy_route.py similarity index 100% rename from haproxy_operator/lib/charms/haproxy/v1/haproxy_route.py rename to haproxy-operator/lib/charms/haproxy/v1/haproxy_route.py diff --git a/haproxy_operator/lib/charms/operator_libs_linux/v0/apt.py b/haproxy-operator/lib/charms/operator_libs_linux/v0/apt.py similarity index 100% rename from haproxy_operator/lib/charms/operator_libs_linux/v0/apt.py rename to haproxy-operator/lib/charms/operator_libs_linux/v0/apt.py diff --git a/haproxy_operator/lib/charms/operator_libs_linux/v1/systemd.py b/haproxy-operator/lib/charms/operator_libs_linux/v1/systemd.py similarity index 100% rename from haproxy_operator/lib/charms/operator_libs_linux/v1/systemd.py rename to haproxy-operator/lib/charms/operator_libs_linux/v1/systemd.py diff --git a/haproxy_operator/lib/charms/tls_certificates_interface/v4/tls_certificates.py b/haproxy-operator/lib/charms/tls_certificates_interface/v4/tls_certificates.py similarity index 100% rename from haproxy_operator/lib/charms/tls_certificates_interface/v4/tls_certificates.py rename to haproxy-operator/lib/charms/tls_certificates_interface/v4/tls_certificates.py diff --git a/haproxy_operator/lib/charms/traefik_k8s/v1/ingress_per_unit.py b/haproxy-operator/lib/charms/traefik_k8s/v1/ingress_per_unit.py similarity index 100% rename from haproxy_operator/lib/charms/traefik_k8s/v1/ingress_per_unit.py rename to haproxy-operator/lib/charms/traefik_k8s/v1/ingress_per_unit.py diff --git a/haproxy_operator/lib/charms/traefik_k8s/v2/ingress.py b/haproxy-operator/lib/charms/traefik_k8s/v2/ingress.py similarity index 100% rename from haproxy_operator/lib/charms/traefik_k8s/v2/ingress.py rename to haproxy-operator/lib/charms/traefik_k8s/v2/ingress.py diff --git a/haproxy_operator/pyproject.toml b/haproxy-operator/pyproject.toml similarity index 100% rename from haproxy_operator/pyproject.toml rename to haproxy-operator/pyproject.toml diff --git a/haproxy_operator/src/charm.py b/haproxy-operator/src/charm.py similarity index 100% rename from haproxy_operator/src/charm.py rename to haproxy-operator/src/charm.py diff --git a/haproxy_operator/src/grafana_dashboards/haproxy.json b/haproxy-operator/src/grafana_dashboards/haproxy.json similarity index 100% rename from haproxy_operator/src/grafana_dashboards/haproxy.json rename to haproxy-operator/src/grafana_dashboards/haproxy.json diff --git a/haproxy_operator/src/haproxy.py b/haproxy-operator/src/haproxy.py similarity index 100% rename from haproxy_operator/src/haproxy.py rename to haproxy-operator/src/haproxy.py diff --git a/haproxy_operator/src/http_interface.py b/haproxy-operator/src/http_interface.py similarity index 100% rename from haproxy_operator/src/http_interface.py rename to haproxy-operator/src/http_interface.py diff --git a/haproxy_operator/src/legacy.py b/haproxy-operator/src/legacy.py similarity index 100% rename from haproxy_operator/src/legacy.py rename to haproxy-operator/src/legacy.py diff --git a/haproxy_operator/src/loki_alert_rules/hacluster_no_quorum.rule b/haproxy-operator/src/loki_alert_rules/hacluster_no_quorum.rule similarity index 100% rename from haproxy_operator/src/loki_alert_rules/hacluster_no_quorum.rule rename to haproxy-operator/src/loki_alert_rules/hacluster_no_quorum.rule diff --git a/haproxy_operator/src/loki_alert_rules/hacluster_node_lost.rule b/haproxy-operator/src/loki_alert_rules/hacluster_node_lost.rule similarity index 100% rename from haproxy_operator/src/loki_alert_rules/hacluster_node_lost.rule rename to haproxy-operator/src/loki_alert_rules/hacluster_node_lost.rule diff --git a/haproxy_operator/src/loki_alert_rules/hacluster_vip_started.rule b/haproxy-operator/src/loki_alert_rules/hacluster_vip_started.rule similarity index 100% rename from haproxy_operator/src/loki_alert_rules/hacluster_vip_started.rule rename to haproxy-operator/src/loki_alert_rules/hacluster_vip_started.rule diff --git a/haproxy_operator/src/loki_alert_rules/hacluster_vip_stopped.rule b/haproxy-operator/src/loki_alert_rules/hacluster_vip_stopped.rule similarity index 100% rename from haproxy_operator/src/loki_alert_rules/hacluster_vip_stopped.rule rename to haproxy-operator/src/loki_alert_rules/hacluster_vip_stopped.rule diff --git a/haproxy_operator/src/state/charm_state.py b/haproxy-operator/src/state/charm_state.py similarity index 100% rename from haproxy_operator/src/state/charm_state.py rename to haproxy-operator/src/state/charm_state.py diff --git a/haproxy_operator/src/state/exception.py b/haproxy-operator/src/state/exception.py similarity index 100% rename from haproxy_operator/src/state/exception.py rename to haproxy-operator/src/state/exception.py diff --git a/haproxy_operator/src/state/ha.py b/haproxy-operator/src/state/ha.py similarity index 100% rename from haproxy_operator/src/state/ha.py rename to haproxy-operator/src/state/ha.py diff --git a/haproxy_operator/src/state/haproxy_route.py b/haproxy-operator/src/state/haproxy_route.py similarity index 100% rename from haproxy_operator/src/state/haproxy_route.py rename to haproxy-operator/src/state/haproxy_route.py diff --git a/haproxy_operator/src/state/haproxy_route_tcp.py b/haproxy-operator/src/state/haproxy_route_tcp.py similarity index 100% rename from haproxy_operator/src/state/haproxy_route_tcp.py rename to haproxy-operator/src/state/haproxy_route_tcp.py diff --git a/haproxy_operator/src/state/ingress.py b/haproxy-operator/src/state/ingress.py similarity index 100% rename from haproxy_operator/src/state/ingress.py rename to haproxy-operator/src/state/ingress.py diff --git a/haproxy_operator/src/state/ingress_per_unit.py b/haproxy-operator/src/state/ingress_per_unit.py similarity index 100% rename from haproxy_operator/src/state/ingress_per_unit.py rename to haproxy-operator/src/state/ingress_per_unit.py diff --git a/haproxy_operator/src/state/tls.py b/haproxy-operator/src/state/tls.py similarity index 100% rename from haproxy_operator/src/state/tls.py rename to haproxy-operator/src/state/tls.py diff --git a/haproxy_operator/src/state/validation.py b/haproxy-operator/src/state/validation.py similarity index 100% rename from haproxy_operator/src/state/validation.py rename to haproxy-operator/src/state/validation.py diff --git a/haproxy_operator/src/tls_relation.py b/haproxy-operator/src/tls_relation.py similarity index 100% rename from haproxy_operator/src/tls_relation.py rename to haproxy-operator/src/tls_relation.py diff --git a/haproxy_operator/templates/haproxy.cfg.j2 b/haproxy-operator/templates/haproxy.cfg.j2 similarity index 100% rename from haproxy_operator/templates/haproxy.cfg.j2 rename to haproxy-operator/templates/haproxy.cfg.j2 diff --git a/haproxy_operator/templates/haproxy_ingress.cfg.j2 b/haproxy-operator/templates/haproxy_ingress.cfg.j2 similarity index 100% rename from haproxy_operator/templates/haproxy_ingress.cfg.j2 rename to haproxy-operator/templates/haproxy_ingress.cfg.j2 diff --git a/haproxy_operator/templates/haproxy_ingress_per_unit.cfg.j2 b/haproxy-operator/templates/haproxy_ingress_per_unit.cfg.j2 similarity index 100% rename from haproxy_operator/templates/haproxy_ingress_per_unit.cfg.j2 rename to haproxy-operator/templates/haproxy_ingress_per_unit.cfg.j2 diff --git a/haproxy_operator/templates/haproxy_legacy.cfg.j2 b/haproxy-operator/templates/haproxy_legacy.cfg.j2 similarity index 100% rename from haproxy_operator/templates/haproxy_legacy.cfg.j2 rename to haproxy-operator/templates/haproxy_legacy.cfg.j2 diff --git a/haproxy_operator/templates/haproxy_route.cfg.j2 b/haproxy-operator/templates/haproxy_route.cfg.j2 similarity index 100% rename from haproxy_operator/templates/haproxy_route.cfg.j2 rename to haproxy-operator/templates/haproxy_route.cfg.j2 diff --git a/haproxy_operator/templates/haproxy_route_tcp.cfg.j2 b/haproxy-operator/templates/haproxy_route_tcp.cfg.j2 similarity index 100% rename from haproxy_operator/templates/haproxy_route_tcp.cfg.j2 rename to haproxy-operator/templates/haproxy_route_tcp.cfg.j2 diff --git a/haproxy_operator/tests/conftest.py b/haproxy-operator/tests/conftest.py similarity index 100% rename from haproxy_operator/tests/conftest.py rename to haproxy-operator/tests/conftest.py diff --git a/haproxy_operator/tests/integration/__init__.py b/haproxy-operator/tests/integration/__init__.py similarity index 100% rename from haproxy_operator/tests/integration/__init__.py rename to haproxy-operator/tests/integration/__init__.py diff --git a/haproxy_operator/tests/integration/conftest.py b/haproxy-operator/tests/integration/conftest.py similarity index 100% rename from haproxy_operator/tests/integration/conftest.py rename to haproxy-operator/tests/integration/conftest.py diff --git a/haproxy_operator/tests/integration/haproxy_route_requirer.py b/haproxy-operator/tests/integration/haproxy_route_requirer.py similarity index 100% rename from haproxy_operator/tests/integration/haproxy_route_requirer.py rename to haproxy-operator/tests/integration/haproxy_route_requirer.py diff --git a/haproxy_operator/tests/integration/haproxy_route_tcp_requirer.py b/haproxy-operator/tests/integration/haproxy_route_tcp_requirer.py similarity index 100% rename from haproxy_operator/tests/integration/haproxy_route_tcp_requirer.py rename to haproxy-operator/tests/integration/haproxy_route_tcp_requirer.py diff --git a/haproxy_operator/tests/integration/helper.py b/haproxy-operator/tests/integration/helper.py similarity index 100% rename from haproxy_operator/tests/integration/helper.py rename to haproxy-operator/tests/integration/helper.py diff --git a/haproxy_operator/tests/integration/ingress_per_unit_requirer.py b/haproxy-operator/tests/integration/ingress_per_unit_requirer.py similarity index 100% rename from haproxy_operator/tests/integration/ingress_per_unit_requirer.py rename to haproxy-operator/tests/integration/ingress_per_unit_requirer.py diff --git a/haproxy_operator/tests/integration/legacy/__init__.py b/haproxy-operator/tests/integration/legacy/__init__.py similarity index 100% rename from haproxy_operator/tests/integration/legacy/__init__.py rename to haproxy-operator/tests/integration/legacy/__init__.py diff --git a/haproxy_operator/tests/integration/legacy/conftest.py b/haproxy-operator/tests/integration/legacy/conftest.py similarity index 100% rename from haproxy_operator/tests/integration/legacy/conftest.py rename to haproxy-operator/tests/integration/legacy/conftest.py diff --git a/haproxy_operator/tests/integration/legacy/haproxy_route_requirer.py b/haproxy-operator/tests/integration/legacy/haproxy_route_requirer.py similarity index 100% rename from haproxy_operator/tests/integration/legacy/haproxy_route_requirer.py rename to haproxy-operator/tests/integration/legacy/haproxy_route_requirer.py diff --git a/haproxy_operator/tests/integration/legacy/helper.py b/haproxy-operator/tests/integration/legacy/helper.py similarity index 100% rename from haproxy_operator/tests/integration/legacy/helper.py rename to haproxy-operator/tests/integration/legacy/helper.py diff --git a/haproxy_operator/tests/integration/legacy/test_action.py b/haproxy-operator/tests/integration/legacy/test_action.py similarity index 100% rename from haproxy_operator/tests/integration/legacy/test_action.py rename to haproxy-operator/tests/integration/legacy/test_action.py diff --git a/haproxy_operator/tests/integration/legacy/test_charm.py b/haproxy-operator/tests/integration/legacy/test_charm.py similarity index 100% rename from haproxy_operator/tests/integration/legacy/test_charm.py rename to haproxy-operator/tests/integration/legacy/test_charm.py diff --git a/haproxy_operator/tests/integration/legacy/test_config.py b/haproxy-operator/tests/integration/legacy/test_config.py similarity index 100% rename from haproxy_operator/tests/integration/legacy/test_config.py rename to haproxy-operator/tests/integration/legacy/test_config.py diff --git a/haproxy_operator/tests/integration/legacy/test_cos.py b/haproxy-operator/tests/integration/legacy/test_cos.py similarity index 100% rename from haproxy_operator/tests/integration/legacy/test_cos.py rename to haproxy-operator/tests/integration/legacy/test_cos.py diff --git a/haproxy_operator/tests/integration/legacy/test_ha.py b/haproxy-operator/tests/integration/legacy/test_ha.py similarity index 100% rename from haproxy_operator/tests/integration/legacy/test_ha.py rename to haproxy-operator/tests/integration/legacy/test_ha.py diff --git a/haproxy_operator/tests/integration/legacy/test_haproxy_route.py b/haproxy-operator/tests/integration/legacy/test_haproxy_route.py similarity index 100% rename from haproxy_operator/tests/integration/legacy/test_haproxy_route.py rename to haproxy-operator/tests/integration/legacy/test_haproxy_route.py diff --git a/haproxy_operator/tests/integration/legacy/test_http_interface.py b/haproxy-operator/tests/integration/legacy/test_http_interface.py similarity index 100% rename from haproxy_operator/tests/integration/legacy/test_http_interface.py rename to haproxy-operator/tests/integration/legacy/test_http_interface.py diff --git a/haproxy_operator/tests/integration/legacy/test_ingress.py b/haproxy-operator/tests/integration/legacy/test_ingress.py similarity index 100% rename from haproxy_operator/tests/integration/legacy/test_ingress.py rename to haproxy-operator/tests/integration/legacy/test_ingress.py diff --git a/haproxy_operator/tests/integration/legacy/test_website.py b/haproxy-operator/tests/integration/legacy/test_website.py similarity index 100% rename from haproxy_operator/tests/integration/legacy/test_website.py rename to haproxy-operator/tests/integration/legacy/test_website.py diff --git a/haproxy_operator/tests/integration/test_actions.py b/haproxy-operator/tests/integration/test_actions.py similarity index 100% rename from haproxy_operator/tests/integration/test_actions.py rename to haproxy-operator/tests/integration/test_actions.py diff --git a/haproxy_operator/tests/integration/test_haproxy_route.py b/haproxy-operator/tests/integration/test_haproxy_route.py similarity index 100% rename from haproxy_operator/tests/integration/test_haproxy_route.py rename to haproxy-operator/tests/integration/test_haproxy_route.py diff --git a/haproxy_operator/tests/integration/test_haproxy_route_tcp.py b/haproxy-operator/tests/integration/test_haproxy_route_tcp.py similarity index 100% rename from haproxy_operator/tests/integration/test_haproxy_route_tcp.py rename to haproxy-operator/tests/integration/test_haproxy_route_tcp.py diff --git a/haproxy_operator/tests/integration/test_ingress_per_unit.py b/haproxy-operator/tests/integration/test_ingress_per_unit.py similarity index 100% rename from haproxy_operator/tests/integration/test_ingress_per_unit.py rename to haproxy-operator/tests/integration/test_ingress_per_unit.py diff --git a/haproxy_operator/tests/unit/__init__.py b/haproxy-operator/tests/unit/__init__.py similarity index 100% rename from haproxy_operator/tests/unit/__init__.py rename to haproxy-operator/tests/unit/__init__.py diff --git a/haproxy_operator/tests/unit/conftest.py b/haproxy-operator/tests/unit/conftest.py similarity index 100% rename from haproxy_operator/tests/unit/conftest.py rename to haproxy-operator/tests/unit/conftest.py diff --git a/haproxy_operator/tests/unit/legacy/conftest.py b/haproxy-operator/tests/unit/legacy/conftest.py similarity index 100% rename from haproxy_operator/tests/unit/legacy/conftest.py rename to haproxy-operator/tests/unit/legacy/conftest.py diff --git a/haproxy_operator/tests/unit/legacy/test_ha.py b/haproxy-operator/tests/unit/legacy/test_ha.py similarity index 100% rename from haproxy_operator/tests/unit/legacy/test_ha.py rename to haproxy-operator/tests/unit/legacy/test_ha.py diff --git a/haproxy_operator/tests/unit/legacy/test_haproxy_route_lib.py b/haproxy-operator/tests/unit/legacy/test_haproxy_route_lib.py similarity index 100% rename from haproxy_operator/tests/unit/legacy/test_haproxy_route_lib.py rename to haproxy-operator/tests/unit/legacy/test_haproxy_route_lib.py diff --git a/haproxy_operator/tests/unit/legacy/test_tls_relation.py b/haproxy-operator/tests/unit/legacy/test_tls_relation.py similarity index 100% rename from haproxy_operator/tests/unit/legacy/test_tls_relation.py rename to haproxy-operator/tests/unit/legacy/test_tls_relation.py diff --git a/haproxy_operator/tests/unit/test_charm.py b/haproxy-operator/tests/unit/test_charm.py similarity index 100% rename from haproxy_operator/tests/unit/test_charm.py rename to haproxy-operator/tests/unit/test_charm.py diff --git a/haproxy_operator/tests/unit/test_haproxy_route.py b/haproxy-operator/tests/unit/test_haproxy_route.py similarity index 100% rename from haproxy_operator/tests/unit/test_haproxy_route.py rename to haproxy-operator/tests/unit/test_haproxy_route.py diff --git a/haproxy_operator/tests/unit/test_haproxy_route_options.py b/haproxy-operator/tests/unit/test_haproxy_route_options.py similarity index 100% rename from haproxy_operator/tests/unit/test_haproxy_route_options.py rename to haproxy-operator/tests/unit/test_haproxy_route_options.py diff --git a/haproxy_operator/tests/unit/test_haproxy_route_tcp_lib.py b/haproxy-operator/tests/unit/test_haproxy_route_tcp_lib.py similarity index 100% rename from haproxy_operator/tests/unit/test_haproxy_route_tcp_lib.py rename to haproxy-operator/tests/unit/test_haproxy_route_tcp_lib.py diff --git a/haproxy_operator/tests/unit/test_haproxy_service.py b/haproxy-operator/tests/unit/test_haproxy_service.py similarity index 100% rename from haproxy_operator/tests/unit/test_haproxy_service.py rename to haproxy-operator/tests/unit/test_haproxy_service.py diff --git a/haproxy_operator/tests/unit/test_state.py b/haproxy-operator/tests/unit/test_state.py similarity index 100% rename from haproxy_operator/tests/unit/test_state.py rename to haproxy-operator/tests/unit/test_state.py diff --git a/haproxy_operator/tox.toml b/haproxy-operator/tox.toml similarity index 100% rename from haproxy_operator/tox.toml rename to haproxy-operator/tox.toml diff --git a/haproxy_operator/uv.lock b/haproxy-operator/uv.lock similarity index 100% rename from haproxy_operator/uv.lock rename to haproxy-operator/uv.lock diff --git a/haproxy_spoe_auth_operator/README.md b/haproxy-spoe-auth-operator/README.md similarity index 100% rename from haproxy_spoe_auth_operator/README.md rename to haproxy-spoe-auth-operator/README.md diff --git a/haproxy_spoe_auth_operator/charmcraft.yaml b/haproxy-spoe-auth-operator/charmcraft.yaml similarity index 100% rename from haproxy_spoe_auth_operator/charmcraft.yaml rename to haproxy-spoe-auth-operator/charmcraft.yaml diff --git a/haproxy_spoe_auth_operator/lib/charms/hydra/v0/oauth.py b/haproxy-spoe-auth-operator/lib/charms/hydra/v0/oauth.py similarity index 100% rename from haproxy_spoe_auth_operator/lib/charms/hydra/v0/oauth.py rename to haproxy-spoe-auth-operator/lib/charms/hydra/v0/oauth.py diff --git a/haproxy_spoe_auth_operator/pyproject.toml b/haproxy-spoe-auth-operator/pyproject.toml similarity index 96% rename from haproxy_spoe_auth_operator/pyproject.toml rename to haproxy-spoe-auth-operator/pyproject.toml index 67862e6ab..56f27378d 100644 --- a/haproxy_spoe_auth_operator/pyproject.toml +++ b/haproxy-spoe-auth-operator/pyproject.toml @@ -28,7 +28,7 @@ lint = ["codespell", "mypy", "ops[testing]", "pytest", "ruff", "types-pyyaml"] unit = ["coverage[toml]", "ops[testing]", "pytest"] coverage-report = ["coverage[toml]", "pytest"] static = ["bandit[toml]"] -integration = ["jubilant==1.1.1", "juju==3.6.1.0", "pytest", "pytest-asyncio"] +integration = ["jubilant==1.1.1", "juju==3.6.1.3", "pytest", "pytest-operator"] [tool.uv] package = false diff --git a/haproxy_spoe_auth_operator/src/charm.py b/haproxy-spoe-auth-operator/src/charm.py similarity index 100% rename from haproxy_spoe_auth_operator/src/charm.py rename to haproxy-spoe-auth-operator/src/charm.py diff --git a/haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py b/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py similarity index 100% rename from haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py rename to haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py diff --git a/haproxy_spoe_auth_operator/src/state.py b/haproxy-spoe-auth-operator/src/state.py similarity index 100% rename from haproxy_spoe_auth_operator/src/state.py rename to haproxy-spoe-auth-operator/src/state.py diff --git a/haproxy_spoe_auth_operator/templates/config.yaml.j2 b/haproxy-spoe-auth-operator/templates/config.yaml.j2 similarity index 100% rename from haproxy_spoe_auth_operator/templates/config.yaml.j2 rename to haproxy-spoe-auth-operator/templates/config.yaml.j2 diff --git a/haproxy_spoe_auth_operator/tests/integration/__init__.py b/haproxy-spoe-auth-operator/tests/integration/__init__.py similarity index 100% rename from haproxy_spoe_auth_operator/tests/integration/__init__.py rename to haproxy-spoe-auth-operator/tests/integration/__init__.py diff --git a/haproxy_spoe_auth_operator/tests/integration/conftest.py b/haproxy-spoe-auth-operator/tests/integration/conftest.py similarity index 100% rename from haproxy_spoe_auth_operator/tests/integration/conftest.py rename to haproxy-spoe-auth-operator/tests/integration/conftest.py diff --git a/haproxy_spoe_auth_operator/tests/integration/test_charm.py b/haproxy-spoe-auth-operator/tests/integration/test_charm.py similarity index 100% rename from haproxy_spoe_auth_operator/tests/integration/test_charm.py rename to haproxy-spoe-auth-operator/tests/integration/test_charm.py diff --git a/haproxy_spoe_auth_operator/tests/unit/__init__.py b/haproxy-spoe-auth-operator/tests/unit/__init__.py similarity index 100% rename from haproxy_spoe_auth_operator/tests/unit/__init__.py rename to haproxy-spoe-auth-operator/tests/unit/__init__.py diff --git a/haproxy_spoe_auth_operator/tests/unit/conftest.py b/haproxy-spoe-auth-operator/tests/unit/conftest.py similarity index 100% rename from haproxy_spoe_auth_operator/tests/unit/conftest.py rename to haproxy-spoe-auth-operator/tests/unit/conftest.py diff --git a/haproxy_spoe_auth_operator/tests/unit/test_charm.py b/haproxy-spoe-auth-operator/tests/unit/test_charm.py similarity index 100% rename from haproxy_spoe_auth_operator/tests/unit/test_charm.py rename to haproxy-spoe-auth-operator/tests/unit/test_charm.py diff --git a/haproxy_spoe_auth_operator/tox.toml b/haproxy-spoe-auth-operator/tox.toml similarity index 100% rename from haproxy_spoe_auth_operator/tox.toml rename to haproxy-spoe-auth-operator/tox.toml diff --git a/haproxy_spoe_auth_operator/uv.lock b/haproxy-spoe-auth-operator/uv.lock similarity index 100% rename from haproxy_spoe_auth_operator/uv.lock rename to haproxy-spoe-auth-operator/uv.lock diff --git a/spoe_auth_snap/README.md b/haproxy-spoe-auth-snap/README.md similarity index 100% rename from spoe_auth_snap/README.md rename to haproxy-spoe-auth-snap/README.md diff --git a/spoe_auth_snap/hooks/bin/configure b/haproxy-spoe-auth-snap/hooks/bin/configure similarity index 100% rename from spoe_auth_snap/hooks/bin/configure rename to haproxy-spoe-auth-snap/hooks/bin/configure diff --git a/spoe_auth_snap/hooks/bin/default-configure b/haproxy-spoe-auth-snap/hooks/bin/default-configure similarity index 100% rename from spoe_auth_snap/hooks/bin/default-configure rename to haproxy-spoe-auth-snap/hooks/bin/default-configure diff --git a/spoe_auth_snap/snapcraft.yaml b/haproxy-spoe-auth-snap/snapcraft.yaml similarity index 100% rename from spoe_auth_snap/snapcraft.yaml rename to haproxy-spoe-auth-snap/snapcraft.yaml From ecdd87985dd23de33fb45088f2efd2fb74e40202 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 26 Nov 2025 12:09:48 +0100 Subject: [PATCH 27/48] fix uvlock and link in readme --- README.md | 2 +- haproxy-spoe-auth-operator/uv.lock | 234 +++++++++++++++++++++++++++-- 2 files changed, 224 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8071d1cf1..a3f181813 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ This repository provides a collection of operators related to HAproxy This repository contains the code for the following charms: -1. `haproxy`: A machine charm managing HAproxy. See the [haproxy README](haproxy_-operator/README.md) for more information. +1. `haproxy`: A machine charm managing HAproxy. See the [haproxy README](haproxy-operator/README.md) for more information. 2. `haproxy-spoe-auth-operator`: A machine charm deploying an SPOE agent that serves as an authentication proxy. See the [haproxy-spoe-auth-operator README](haproxy-spoe-auth-operator/README.md) for more information. The repository also contains the snapped workload of some charms: diff --git a/haproxy-spoe-auth-operator/uv.lock b/haproxy-spoe-auth-operator/uv.lock index 97fc4104f..f8b655533 100644 --- a/haproxy-spoe-auth-operator/uv.lock +++ b/haproxy-spoe-auth-operator/uv.lock @@ -11,6 +11,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + [[package]] name = "attrs" version = "25.4.0" @@ -20,6 +29,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "backports-datetime-fromisoformat" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/81/eff3184acb1d9dc3ce95a98b6f3c81a49b4be296e664db8e1c2eeabef3d9/backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d", size = 23588, upload-time = "2024-12-28T20:18:15.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/0f/69bbdde2e1e57c09b5f01788804c50e68b29890aada999f2b1a40519def9/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147", size = 27630, upload-time = "2024-12-28T20:17:19.442Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1d/1c84a50c673c87518b1adfeafcfd149991ed1f7aedc45d6e5eac2f7d19d7/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4", size = 34707, upload-time = "2024-12-28T20:17:21.79Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/27eae384e7e045cda83f70b551d04b4a0b294f9822d32dea1cbf1592de59/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35", size = 27280, upload-time = "2024-12-28T20:17:24.503Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7a/a4075187eb6bbb1ff6beb7229db5f66d1070e6968abeb61e056fa51afa5e/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1", size = 55094, upload-time = "2024-12-28T20:17:25.546Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/3fced4230c10af14aacadc195fe58e2ced91d011217b450c2e16a09a98c8/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32", size = 55605, upload-time = "2024-12-28T20:17:29.208Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0a/4b34a838c57bd16d3e5861ab963845e73a1041034651f7459e9935289cfd/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d", size = 55353, upload-time = "2024-12-28T20:17:32.433Z" }, + { url = "https://files.pythonhosted.org/packages/d9/68/07d13c6e98e1cad85606a876367ede2de46af859833a1da12c413c201d78/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7", size = 55298, upload-time = "2024-12-28T20:17:34.919Z" }, + { url = "https://files.pythonhosted.org/packages/60/33/45b4d5311f42360f9b900dea53ab2bb20a3d61d7f9b7c37ddfcb3962f86f/backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d", size = 29375, upload-time = "2024-12-28T20:17:36.018Z" }, +] + [[package]] name = "bandit" version = "1.8.6" @@ -402,6 +427,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, ] +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + [[package]] name = "google-auth" version = "2.43.0" @@ -441,7 +484,7 @@ integration = [ { name = "jubilant" }, { name = "juju" }, { name = "pytest" }, - { name = "pytest-asyncio" }, + { name = "pytest-operator" }, ] lint = [ { name = "codespell" }, @@ -478,9 +521,9 @@ coverage-report = [ fmt = [{ name = "ruff" }] integration = [ { name = "jubilant", specifier = "==1.1.1" }, - { name = "juju", specifier = "==3.6.1.0" }, + { name = "juju", specifier = "==3.6.1.3" }, { name = "pytest" }, - { name = "pytest-asyncio" }, + { name = "pytest-operator" }, ] lint = [ { name = "codespell" }, @@ -548,6 +591,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" }, ] +[[package]] +name = "ipdb" +version = "0.13.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "ipython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/1b/7e07e7b752017f7693a0f4d41c13e5ca29ce8cbcfdcc1fd6c4ad8c0a27a0/ipdb-0.13.13.tar.gz", hash = "sha256:e3ac6018ef05126d442af680aad863006ec19d02290561ac88b8b1c0b0cfc726", size = 17042, upload-time = "2023-03-09T15:40:57.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/4c/b075da0092003d9a55cf2ecc1cae9384a1ca4f650d51b00fc59875fe76f6/ipdb-0.13.13-py3-none-any.whl", hash = "sha256:45529994741c4ab6d2388bfa5d7b725c2cf7fe9deffabdb8a6113aa5ed449ed4", size = 12130, upload-time = "2023-03-09T15:40:55.021Z" }, +] + +[[package]] +name = "ipython" +version = "9.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/e6/48c74d54039241a456add616464ea28c6ebf782e4110d419411b83dae06f/ipython-9.7.0.tar.gz", hash = "sha256:5f6de88c905a566c6a9d6c400a8fed54a638e1f7543d17aae2551133216b1e4e", size = 4422115, upload-time = "2025-11-05T12:18:54.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/aa/62893d6a591d337aa59dcc4c6f6c842f1fe20cd72c8c5c1f980255243252/ipython-9.7.0-py3-none-any.whl", hash = "sha256:bce8ac85eb9521adc94e1845b4c03d88365fd6ac2f4908ec4ed1eb1b0a065f9f", size = 618911, upload-time = "2025-11-05T12:18:52.484Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -601,25 +702,25 @@ wheels = [ [[package]] name = "juju" -version = "3.6.1.0" +version = "3.6.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "backports-datetime-fromisoformat" }, { name = "hvac" }, { name = "kubernetes" }, { name = "macaroonbakery" }, { name = "packaging" }, { name = "paramiko" }, { name = "pyasn1" }, - { name = "pyrfc3339" }, { name = "pyyaml" }, { name = "toposort" }, { name = "typing-extensions" }, { name = "typing-inspect" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/86e724a37ce2167a5145bd122a10ea36e5a1a3b2b28a27017d46b6fdc306/juju-3.6.1.0.tar.gz", hash = "sha256:59cfde55185bb53877a2bddc2855f3c48471537e130653d77984681676a448bc", size = 291387, upload-time = "2024-12-20T00:16:34.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/ac/42ed7565d1b031856fb7e8884089acb3eab5aa6a2dabfee3fcf09660f885/juju-3.6.1.3.tar.gz", hash = "sha256:2fcf510fa35b387abb382da3a8b2227f38852ae7e9dc1058afb228588e1aec51", size = 305052, upload-time = "2025-07-11T02:15:59.237Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/68/f70c41ab95990b610798809aed792d0efebd79cc322d54e5a3c5685fa4f0/juju-3.6.1.0-py3-none-any.whl", hash = "sha256:28b6a10093f2e0243ad0ddd5ef25a3f59d710e9da5a188456ba704142819fbb3", size = 287063, upload-time = "2024-12-20T00:16:32.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/13/e31f9b9c24e723a161bc3b75729acb109d80bf3f75f937e4c3d408f19e5a/juju-3.6.1.3-py3-none-any.whl", hash = "sha256:87469500a0a4e6a3976ddf0595e316379868d5cea96e15af2d2d4b94188f76e5", size = 287075, upload-time = "2025-07-11T02:15:57.118Z" }, ] [[package]] @@ -735,6 +836,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "matplotlib-inline" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -863,6 +976,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" }, ] +[[package]] +name = "parso" +version = "0.8.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, +] + [[package]] name = "pathspec" version = "0.12.1" @@ -872,6 +994,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -881,6 +1015,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "protobuf" version = "6.33.0" @@ -896,6 +1042,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/d1/0a28c21707807c6aacd5dc9c3704b2aa1effbf37adebd8caeaf68b17a636/protobuf-6.33.0-py3-none-any.whl", hash = "sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995", size = 170477, upload-time = "2025-10-15T20:39:51.311Z" }, ] +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + [[package]] name = "pyasn1" version = "0.6.1" @@ -1101,15 +1265,31 @@ wheels = [ [[package]] name = "pytest-asyncio" -version = "1.3.0" +version = "0.21.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/53/57663d99acaac2fcdafdc697e52a9b1b7d6fcf36616281ff9768a44e7ff3/pytest_asyncio-0.21.2.tar.gz", hash = "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45", size = 30656, upload-time = "2024-04-29T13:23:24.738Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ce/1e4b53c213dce25d6e8b163697fbce2d43799d76fa08eea6ad270451c370/pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b", size = 13368, upload-time = "2024-04-29T13:23:23.126Z" }, +] + +[[package]] +name = "pytest-operator" +version = "0.43.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipdb" }, + { name = "jinja2" }, + { name = "juju" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/48/880facbcaca080b852854f60fd46b37ca2985b27dc7f1713857d9de6d469/pytest_operator-0.43.2.tar.gz", hash = "sha256:3db34dcd9c114a2e41a9bc61da72daf1264e7644fd5b92e855f250cb337e01c3", size = 149628, upload-time = "2025-10-04T14:38:45.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/a0/22b018287f4ff7a36cdb79745c196f28897184d27e18b90177f5dfc2a0f4/pytest_operator-0.43.2-py3-none-any.whl", hash = "sha256:d7d01ffe35d14b75577fd80a07c34f0a9f4835cfc6d373b8e2f995bcb4146bda", size = 48322, upload-time = "2025-10-04T14:38:43.763Z" }, ] [[package]] @@ -1362,6 +1542,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + [[package]] name = "stevedore" version = "5.5.0" @@ -1380,6 +1574,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/17/57b444fd314d5e1593350b9a31d000e7411ba8e17ce12dc7ad54ca76b810/toposort-1.10-py3-none-any.whl", hash = "sha256:cbdbc0d0bee4d2695ab2ceec97fe0679e9c10eab4b2a87a9372b929e70563a87", size = 8500, upload-time = "2023-02-25T20:07:06.538Z" }, ] +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20250915" @@ -1432,6 +1635,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, +] + [[package]] name = "websocket-client" version = "1.9.0" From a6ed4979bfc7ac75f65c26f0b74ba5a2ac4adea0 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 26 Nov 2025 12:13:16 +0100 Subject: [PATCH 28/48] update heading for vale check --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a3f181813..e6c05f47f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# haproxy-operator +# Haproxy operator This repository provides a collection of operators related to HAproxy From f9740122d992828d76374dee085d8ba0c65591ce Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 26 Nov 2025 12:16:15 +0100 Subject: [PATCH 29/48] update heading for vale check --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e6c05f47f..204c03505 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Haproxy operator +# HAProxy operator This repository provides a collection of operators related to HAproxy From 8481a2c8d254eec964786e209df7258c18765d70 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 26 Nov 2025 17:00:37 +0100 Subject: [PATCH 30/48] add --charm-file config option for pytest --- haproxy-spoe-auth-operator/tests/conftest.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 haproxy-spoe-auth-operator/tests/conftest.py diff --git a/haproxy-spoe-auth-operator/tests/conftest.py b/haproxy-spoe-auth-operator/tests/conftest.py new file mode 100644 index 000000000..09a84dda8 --- /dev/null +++ b/haproxy-spoe-auth-operator/tests/conftest.py @@ -0,0 +1,13 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Fixtures for charm tests.""" + + +def pytest_addoption(parser): + """Parse additional pytest options. + + Args: + parser: Pytest parser. + """ + parser.addoption("--charm-file", action="store") From a935ad7eedeb7ed9b984a994fdf2430a60741368 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 26 Nov 2025 17:47:01 +0100 Subject: [PATCH 31/48] wait until the charm finishes installation before continuing --- haproxy-spoe-auth-operator/tests/integration/conftest.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/haproxy-spoe-auth-operator/tests/integration/conftest.py b/haproxy-spoe-auth-operator/tests/integration/conftest.py index 5bc4f9909..34aa5cedb 100644 --- a/haproxy-spoe-auth-operator/tests/integration/conftest.py +++ b/haproxy-spoe-auth-operator/tests/integration/conftest.py @@ -72,4 +72,8 @@ def application_fixture(pytestconfig: pytest.Config, juju: jubilant.Juju, charm: app=app_name, base="ubuntu@24.04", ) + juju.wait( + lambda status: (jubilant.all_blocked(status, app_name)), + timeout=JUJU_WAIT_TIMEOUT, + ) return app_name From 80068e1a81aed2ed248f0c4dd1ecabf54050142d Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 26 Nov 2025 17:54:11 +0100 Subject: [PATCH 32/48] add change artifact --- docs/release-notes/artifacts/pr0232.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 docs/release-notes/artifacts/pr0232.yaml diff --git a/docs/release-notes/artifacts/pr0232.yaml b/docs/release-notes/artifacts/pr0232.yaml new file mode 100644 index 000000000..599b17831 --- /dev/null +++ b/docs/release-notes/artifacts/pr0232.yaml @@ -0,0 +1,17 @@ +# --- Release notes change artifact ---- + +schema_version: 1 +changes: + - title: Add HAProxy SPOE Auth Charm + author: tphan025 + type: major + description: | + Add a new charm for HAProxy SPOE authentication. + Refactor the haproxy-operator charm code to a folder in the monorepo. + Update the relevant workflows to support the new directory structure. + urls: + pr: "https://github.com/canonical/haproxy-operator/pull/232" + related_doc: + related_issue: + visibility: public + highlight: false From eb7193fa56ca3898d7df59bd3582ed492bf5c047 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 27 Nov 2025 08:24:24 +0100 Subject: [PATCH 33/48] remove extra space --- docs/release-notes/artifacts/pr0229.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/artifacts/pr0229.yaml b/docs/release-notes/artifacts/pr0229.yaml index 88d521d59..b01ed33e6 100644 --- a/docs/release-notes/artifacts/pr0229.yaml +++ b/docs/release-notes/artifacts/pr0229.yaml @@ -7,7 +7,7 @@ changes: type: major description: | Add the `charms.haproxy.v0.spoe_auth` library to enable SPOE authentication integration. - urls: + urls: pr: https://github.com/canonical/haproxy-operator/pull/229 related_doc: related_issue: From 796f5d62b1da6b3639a986d2b3f48cc7c993d4cc Mon Sep 17 00:00:00 2001 From: Phan Trung Thanh Date: Thu, 27 Nov 2025 16:28:52 +0100 Subject: [PATCH 34/48] Update docs/release-notes/artifacts/pr0232.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sébastien Georget --- docs/release-notes/artifacts/pr0232.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/artifacts/pr0232.yaml b/docs/release-notes/artifacts/pr0232.yaml index 599b17831..e3c598af6 100644 --- a/docs/release-notes/artifacts/pr0232.yaml +++ b/docs/release-notes/artifacts/pr0232.yaml @@ -6,7 +6,7 @@ changes: author: tphan025 type: major description: | - Add a new charm for HAProxy SPOE authentication. + Added a new charm for HAProxy SPOE authentication. Refactor the haproxy-operator charm code to a folder in the monorepo. Update the relevant workflows to support the new directory structure. urls: From af2e7de82f6e8995fae1a0dff2e181c6e7ac5657 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 27 Nov 2025 16:33:50 +0100 Subject: [PATCH 35/48] move lib dependency from charmlibs to lib folder --- haproxy-spoe-auth-operator/charmcraft.yaml | 2 + .../lib/charms/haproxy/v0/spoe_auth.py | 559 ++++++++++++++++++ haproxy-spoe-auth-operator/pyproject.toml | 4 - haproxy-spoe-auth-operator/src/charm.py | 2 +- haproxy-spoe-auth-operator/uv.lock | 11 - 5 files changed, 562 insertions(+), 16 deletions(-) create mode 100644 haproxy-spoe-auth-operator/lib/charms/haproxy/v0/spoe_auth.py diff --git a/haproxy-spoe-auth-operator/charmcraft.yaml b/haproxy-spoe-auth-operator/charmcraft.yaml index 193e4c8cf..a782ab1ff 100644 --- a/haproxy-spoe-auth-operator/charmcraft.yaml +++ b/haproxy-spoe-auth-operator/charmcraft.yaml @@ -67,3 +67,5 @@ config: charm-libs: - lib: hydra.oauth version: "0" +- lib: haproxy.spoe_auth + version: "0" diff --git a/haproxy-spoe-auth-operator/lib/charms/haproxy/v0/spoe_auth.py b/haproxy-spoe-auth-operator/lib/charms/haproxy/v0/spoe_auth.py new file mode 100644 index 000000000..7901452cf --- /dev/null +++ b/haproxy-spoe-auth-operator/lib/charms/haproxy/v0/spoe_auth.py @@ -0,0 +1,559 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +# pylint: disable=duplicate-code +"""SPOE-auth interface library. + +## Getting Started + +To get started using the library, you need to first declare the library in + the charm-libs section of your `charmcraft.yaml` file: +```yaml +charm-libs: +- lib: haproxy.spoe_auth + version: "0" +``` + +Then, fetch the library using `charmcraft`: + +```shell +cd some-charm +charmcraft fetch-libs +``` + +## Using the library as the Provider + +The provider charm should expose the interface as shown below: + +```yaml +provides: + spoe-auth: + interface: spoe-auth + limit: 1 +``` + +Then, to initialise the library: + +```python +from charms.haproxy.v0.spoe_auth import SpoeAuthProvider, HaproxyEvent + +class SpoeAuthCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.spoe_auth = SpoeAuthProvider(self, relation_name="spoe-auth") + + self.framework.observe( + self.on.config_changed, self._on_config_changed + ) + + def _on_config_changed(self, event): + # Publish the SPOE auth configuration + self.spoe_auth.provide_spoe_auth_requirements( + spop_port=8081, + oidc_callback_port=5000, + event=HaproxyEvent.ON_HTTP_REQUEST, + var_authenticated="var.sess.is_authenticated", + var_redirect_url="var.sess.redirect_url", + cookie_name="auth_session", + hostname="auth.example.com", + oidc_callback_path="/oauth2/callback", + ) +``` +""" + +import json +import logging +import re +from collections.abc import MutableMapping +from enum import StrEnum +from typing import Annotated, cast + +from ops import CharmBase, RelationBrokenEvent +from ops.charm import CharmEvents +from ops.framework import EventBase, EventSource, Object +from ops.model import Relation +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, IPvAnyAddress, ValidationError + +# The unique Charmhub library identifier, never change it +LIBID = "3f644e37fffc483aa97bea91d4fc0bce" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 2 + +logger = logging.getLogger(__name__) +SPOE_AUTH_DEFAULT_RELATION_NAME = "spoe-auth" +HAPROXY_CONFIG_INVALID_CHARACTERS = "\n\t#\\'\"\r$ " +# RFC-1034 and RFC-2181 compliance REGEX for validating FQDNs +HOSTNAME_REGEX = ( + r"^(?=.{1,253})(?!.*--.*)(?:(?!-)(?![0-9])[a-zA-Z0-9-]" + r"{1,63}(? str: + """Validate if value contains invalid haproxy config characters. + + Args: + value: The value to validate. + + Raises: + ValueError: When value contains invalid characters. + + Returns: + The validated value. + """ + if [char for char in value if char in HAPROXY_CONFIG_INVALID_CHARACTERS]: + raise ValueError(f"Relation data contains invalid character(s) {value}") + return value + + +def validate_hostname(value: str) -> str: + """Validate if value is a valid hostname per RFC 1123. + + Args: + value: The value to validate. + + Raises: + ValueError: When value is not a valid hostname. + + Returns: + The validated value. + """ + if not re.match(HOSTNAME_REGEX, value): + raise ValueError(f"Invalid hostname: {value}") + return value + + +VALIDSTR = Annotated[str, BeforeValidator(value_contains_invalid_characters)] + + +class DataValidationError(Exception): + """Raised when data validation fails.""" + + +class SpoeAuthInvalidRelationDataError(Exception): + """Raised when data validation of the spoe-auth relation fails.""" + + +class _DatabagModel(BaseModel): + """Base databag model. + + Attrs: + model_config: pydantic model configuration. + """ + + model_config = ConfigDict( + # tolerate additional keys in databag + extra="ignore", + # Allow instantiating this class by field name (instead of forcing alias). + populate_by_name=True, + ) # type: ignore + """Pydantic config.""" + + @classmethod + def load(cls, databag: MutableMapping[str, str]) -> "_DatabagModel": + """Load this model from a Juju json databag. + + Args: + databag: Databag content. + + Raises: + DataValidationError: When model validation failed. + + Returns: + _DatabagModel: The validated model. + """ + try: + data = { + k: json.loads(v) + for k, v in databag.items() + # Don't attempt to parse model-external values + if k in {(f.alias or n) for n, f in cls.model_fields.items()} + } + except json.JSONDecodeError as e: + msg = f"invalid databag contents: expecting json. {databag}" + logger.error(msg) + raise DataValidationError(msg) from e + + try: + return cls.model_validate_json(json.dumps(data)) + except ValidationError as e: + msg = f"failed to validate databag: {databag}" + logger.error(msg) + raise DataValidationError(msg) from e + + def dump( + self, databag: MutableMapping[str, str] | None = None, clear: bool = True + ) -> MutableMapping[str, str] | None: + """Write the contents of this model to Juju databag. + + Args: + databag: The databag to write to. + clear: Whether to clear the databag before writing. + + Returns: + MutableMapping: The databag. + """ + if clear and databag: + databag.clear() + + if databag is None: + databag = {} + + dct = self.model_dump(mode="json", by_alias=True) + databag.update({k: json.dumps(v) for k, v in dct.items()}) + return databag + + +class HaproxyEvent(StrEnum): + """Enumeration of HAProxy SPOE events. + + Attributes: + ON_FRONTEND_HTTP_REQUEST: Event triggered on frontend HTTP request. + """ + + ON_FRONTEND_HTTP_REQUEST = "on-frontend-http-request" + + +class SpoeAuthProviderAppData(_DatabagModel): + """Configuration model for SPOE authentication provider. + + Attributes: + spop_port: The port on the agent listening for SPOP. + oidc_callback_port: The port on the agent handling OIDC callbacks. + event: The event that triggers SPOE messages (e.g., on-http-request). + var_authenticated: Name of the variable set by the SPOE agent for auth status. + var_redirect_url: Name of the variable set by the SPOE agent for IDP redirect URL. + cookie_name: Name of the authentication cookie used by the SPOE agent. + oidc_callback_path: Path for OIDC callback. + oidc_callback_hostname: The hostname HAProxy should route OIDC callbacks to. + """ + + spop_port: int = Field( + description="The port on the agent listening for SPOP.", + gt=0, + le=65525, + ) + oidc_callback_port: int = Field( + description="The port on the agent handling OIDC callbacks.", + gt=0, + le=65525, + ) + event: HaproxyEvent = Field( + description="The event that triggers SPOE messages (e.g., on-http-request).", + ) + message_name: str = Field( + description="The name of the SPOE message that the provider expects." + ) + var_authenticated: VALIDSTR = Field( + description="Name of the variable set by the SPOE agent for auth status.", + ) + var_redirect_url: VALIDSTR = Field( + description="Name of the variable set by the SPOE agent for IDP redirect URL.", + ) + cookie_name: VALIDSTR = Field( + description="Name of the authentication cookie used by the SPOE agent.", + ) + oidc_callback_path: VALIDSTR = Field( + description="Path for OIDC callback.", default="/oauth2/callback" + ) + hostname: Annotated[str, BeforeValidator(validate_hostname)] = Field( + description="The hostname HAProxy should route OIDC callbacks to.", + ) + + +class SpoeAuthProviderUnitData(_DatabagModel): + """spoe-auth provider unit data. + + Attributes: + address: IP address of the unit. + """ + + address: IPvAnyAddress = Field(description="IP address of the unit.") + + +class SpoeAuthProvider(Object): + """SPOE auth interface provider implementation. + + Attributes: + relations: Related applications. + """ + + def __init__( + self, charm: CharmBase, relation_name: str = SPOE_AUTH_DEFAULT_RELATION_NAME + ) -> None: + """Initialize the SpoeAuthProvider. + + Args: + charm: The charm that is instantiating the library. + relation_name: The name of the relation to bind to. + """ + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + + @property + def relations(self) -> list[Relation]: + """The list of Relation instances associated with this relation_name. + + Returns: + list[Relation]: The list of relations. + """ + return list(self.charm.model.relations[self.relation_name]) + + # pylint: disable=too-many-arguments,too-many-positional-arguments + def provide_spoe_auth_requirements( + self, + relation: Relation, + spop_port: int, + oidc_callback_port: int, + event: HaproxyEvent, + message_name: str, + var_authenticated: str, + var_redirect_url: str, + cookie_name: str, + hostname: str, + oidc_callback_path: str = "/oauth2/callback", + unit_address: str | None = None, + ) -> None: + """Set the SPOE auth configuration in the application databag. + + Args: + relation: The relation instance to set data on. + spop_port: The port on the agent listening for SPOP. + oidc_callback_port: The port on the agent handling OIDC callbacks. + event: The event that triggers SPOE messages. + message_name: The name of the SPOE message that the provider expects. + var_authenticated: Name of the variable for auth status. + var_redirect_url: Name of the variable for IDP redirect URL. + cookie_name: Name of the authentication cookie. + hostname: The hostname HAProxy should route OIDC callbacks to. + oidc_callback_path: Path for OIDC callback. + unit_address: The address of the unit. + + Raises: + DataValidationError: When validation of application data fails. + """ + if not self.charm.unit.is_leader(): + logger.warning("Only the leader unit can set the SPOE auth configuration.") + return + + try: + application_data = SpoeAuthProviderAppData( + spop_port=spop_port, + oidc_callback_port=oidc_callback_port, + event=event, + message_name=message_name, + var_authenticated=var_authenticated, + var_redirect_url=var_redirect_url, + cookie_name=cookie_name, + hostname=hostname, + oidc_callback_path=oidc_callback_path, + ) + unit_data = self._prepare_unit_data(unit_address) + except ValidationError as exc: + logger.error("Validation error when preparing provider relation data.") + raise DataValidationError( + "Validation error when preparing provider relation data." + ) from exc + + if self.charm.unit.is_leader(): + application_data.dump(relation.data[self.charm.app], clear=True) + unit_data.dump(relation.data[self.charm.unit], clear=True) + + def _prepare_unit_data(self, unit_address: str | None) -> SpoeAuthProviderUnitData: + """Prepare and validate unit data. + + Raises: + DataValidationError: When no address or unit IP is available. + + Returns: + RequirerUnitData: The validated unit data model. + """ + if not unit_address: + network_binding = self.charm.model.get_binding(self.relation_name) + if ( + network_binding is not None + and (bind_address := network_binding.network.bind_address) is not None + ): + unit_address = str(bind_address) + else: + logger.error("No unit IP available.") + raise DataValidationError("No unit IP available.") + return SpoeAuthProviderUnitData(address=cast("IPvAnyAddress", unit_address)) + + +class SpoeAuthAvailableEvent(EventBase): + """SpoeAuthAvailableEvent custom event.""" + + +class SpoeAuthRemovedEvent(EventBase): + """SpoeAuthRemovedEvent custom event.""" + + +class SpoeAuthRequirerEvents(CharmEvents): + """List of events that the SPOE auth requirer charm can leverage. + + Attributes: + available: Emitted when provider configuration is available. + removed: Emitted when the provider relation is broken. + """ + + available = EventSource(SpoeAuthAvailableEvent) + removed = EventSource(SpoeAuthRemovedEvent) + + +class SpoeAuthRequirer(Object): + """SPOE auth interface requirer implementation. + + Attributes: + on: Custom events of the requirer. + relation: The related application. + """ + + # Ignore this for pylance + on = SpoeAuthRequirerEvents() # type: ignore + + def __init__( + self, charm: CharmBase, relation_name: str = SPOE_AUTH_DEFAULT_RELATION_NAME + ) -> None: + """Initialize the SpoeAuthRequirer. + + Args: + charm: The charm that is instantiating the library. + relation_name: The name of the relation to bind to. + """ + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + + self.framework.observe(self.charm.on[self.relation_name].relation_created, self._configure) + self.framework.observe(self.charm.on[self.relation_name].relation_changed, self._configure) + self.framework.observe( + self.charm.on[self.relation_name].relation_broken, self._on_relation_broken + ) + + @property + def relation(self) -> Relation | None: + """The relation instance associated with this relation_name. + + Returns: + Optional[Relation]: The relation instance, or None if not available. + """ + relations = self.charm.model.relations[self.relation_name] + return relations[0] if relations else None + + def _configure(self, _: EventBase) -> None: + """Handle relation changed events.""" + if self.is_available(): + self.on.available.emit() + + def _on_relation_broken(self, _: RelationBrokenEvent) -> None: + """Handle relation broken events.""" + self.on.removed.emit() + + def is_available(self) -> bool: + """Check if the SPOE auth configuration is available and valid. + + Returns: + bool: True if configuration is available and valid, False otherwise. + """ + if not self.relation: + return False + + if not self.relation.app: + return False + + try: + databag = self.relation.data[self.relation.app] + if not databag: + return False + SpoeAuthProviderAppData.load(databag) + return True + except (DataValidationError, KeyError): + return False + + def get_data(self) -> SpoeAuthProviderAppData | None: + """Get the SPOE auth configuration from the provider. + + Returns: + Optional[SpoeAuthProviderAppData]: The SPOE auth configuration, + or None if not available. + + Raises: + SpoeAuthInvalidRelationDataError: When configuration data is invalid. + """ + if not self.relation: + return None + + if not self.relation.app: + return None + + try: + databag = self.relation.data[self.relation.app] + if not databag: + return None + return SpoeAuthProviderAppData.load(databag) # type: ignore + except DataValidationError as exc: + logger.error( + "spoe-auth data validation failed for relation %s: %s", + self.relation, + str(exc), + ) + raise SpoeAuthInvalidRelationDataError( + f"spoe-auth data validation failed for relation: {self.relation}" + ) from exc + + def get_provider_unit_data(self, relation: Relation) -> list[SpoeAuthProviderUnitData]: + """Fetch and validate the requirer's units data. + + Args: + relation: The relation to fetch unit data from. + + Raises: + DataValidationError: When unit data validation fails. + + Returns: + list[SpoeAuthProviderUnitData]: List of validated unit data from the provider. + """ + requirer_units_data: list[SpoeAuthProviderUnitData] = [] + + for unit in relation.units: + databag = relation.data.get(unit) + if not databag: + logger.error( + "Requirer unit data does not exist even though the unit is still present." + ) + continue + try: + data = cast("SpoeAuthProviderUnitData", SpoeAuthProviderUnitData.load(databag)) + requirer_units_data.append(data) + except DataValidationError: + logger.error("Invalid requirer application data for %s", unit) + raise + return requirer_units_data + + def get_provider_application_data(self, relation: Relation) -> SpoeAuthProviderAppData: + """Fetch and validate the requirer's application databag. + + Args: + relation: The relation to fetch application data from. + + Raises: + DataValidationError: When requirer application data validation fails. + + Returns: + RequirerApplicationData: Validated application data from the requirer. + """ + try: + return cast( + "SpoeAuthProviderAppData", + SpoeAuthProviderAppData.load(relation.data[relation.app]), + ) + except DataValidationError: + logger.error("Invalid requirer application data for %s", relation.app.name) + raise diff --git a/haproxy-spoe-auth-operator/pyproject.toml b/haproxy-spoe-auth-operator/pyproject.toml index 56f27378d..d2066b6bd 100644 --- a/haproxy-spoe-auth-operator/pyproject.toml +++ b/haproxy-spoe-auth-operator/pyproject.toml @@ -14,7 +14,6 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "charmlibs-interfaces-haproxy-spoe-auth", "charmlibs-snap==1.0.0", "jinja2==3.1.6", "jsonschema==4.25.1", @@ -33,9 +32,6 @@ integration = ["jubilant==1.1.1", "juju==3.6.1.3", "pytest", "pytest-operator"] [tool.uv] package = false -[tool.uv.sources] -charmlibs-interfaces-haproxy-spoe-auth = { git = "https://github.com/Thanhphan1147/charmlibs", subdirectory = "interfaces/haproxy_spoe_auth", rev = "d0b2b27fa454394cc54d438ed18d25d88afa3ed5" } - [tool.ruff] target-version = "py310" line-length = 99 diff --git a/haproxy-spoe-auth-operator/src/charm.py b/haproxy-spoe-auth-operator/src/charm.py index 8de0e03a6..934c2b3a7 100644 --- a/haproxy-spoe-auth-operator/src/charm.py +++ b/haproxy-spoe-auth-operator/src/charm.py @@ -9,7 +9,7 @@ import typing import ops -from charmlibs.interfaces.haproxy_spoe_auth import HaproxyEvent, SpoeAuthProvider +from charms.haproxy.v0.spoe_auth import HaproxyEvent, SpoeAuthProvider from charms.hydra.v0.oauth import ClientConfig, OAuthRequirer from haproxy_spoe_auth_service import ( diff --git a/haproxy-spoe-auth-operator/uv.lock b/haproxy-spoe-auth-operator/uv.lock index f8b655533..c83dd3cc1 100644 --- a/haproxy-spoe-auth-operator/uv.lock +++ b/haproxy-spoe-auth-operator/uv.lock @@ -201,15 +201,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] -[[package]] -name = "charmlibs-interfaces-haproxy-spoe-auth" -version = "0.0.1" -source = { git = "https://github.com/Thanhphan1147/charmlibs?subdirectory=interfaces%2Fhaproxy_spoe_auth&rev=d0b2b27fa454394cc54d438ed18d25d88afa3ed5#d0b2b27fa454394cc54d438ed18d25d88afa3ed5" } -dependencies = [ - { name = "ops" }, - { name = "pydantic" }, -] - [[package]] name = "charmlibs-snap" version = "1.0.0" @@ -464,7 +455,6 @@ name = "haproxy-spoe-auth-operator" version = "0.0.1" source = { virtual = "." } dependencies = [ - { name = "charmlibs-interfaces-haproxy-spoe-auth" }, { name = "charmlibs-snap" }, { name = "jinja2" }, { name = "jsonschema" }, @@ -505,7 +495,6 @@ unit = [ [package.metadata] requires-dist = [ - { name = "charmlibs-interfaces-haproxy-spoe-auth", git = "https://github.com/Thanhphan1147/charmlibs?subdirectory=interfaces%2Fhaproxy_spoe_auth&rev=d0b2b27fa454394cc54d438ed18d25d88afa3ed5" }, { name = "charmlibs-snap", specifier = "==1.0.0" }, { name = "jinja2", specifier = "==3.1.6" }, { name = "jsonschema", specifier = "==4.25.1" }, From b635913941998f8fe376161859f6230aa84b90e1 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 27 Nov 2025 16:38:31 +0100 Subject: [PATCH 36/48] update template, sort alphabetically, move some constants around --- haproxy-spoe-auth-operator/src/charm.py | 12 ++++++------ .../src/haproxy_spoe_auth_service.py | 16 ++++++++++++---- .../templates/config.yaml.j2 | 8 ++++---- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/haproxy-spoe-auth-operator/src/charm.py b/haproxy-spoe-auth-operator/src/charm.py index 934c2b3a7..a3764c9df 100644 --- a/haproxy-spoe-auth-operator/src/charm.py +++ b/haproxy-spoe-auth-operator/src/charm.py @@ -13,6 +13,10 @@ from charms.hydra.v0.oauth import ClientConfig, OAuthRequirer from haproxy_spoe_auth_service import ( + COOKIE_NAME, + OIDC_CALLBACK_PATH, + OIDC_CALLBACK_PORT, + SPOP_PORT, SpoeAuthService, SpoeAuthServiceConfigError, SpoeAuthServiceInstallError, @@ -22,15 +26,11 @@ logger = logging.getLogger(__name__) OAUTH_RELATION = "oauth" -OIDC_CALLBACK_PATH = "/oauth2/callback" -SPOP_PORT = 8081 -OIDC_CALLBACK_PORT = 5000 OIDC_SCOPE = "openid email profile" +SPOE_AUTH_MESSAGE_NAME = "try-auth-oidc" +SPOE_AUTH_RELATION = "spoe-auth" VAR_AUTHENTICATED = "sess.auth.is_authenticated" VAR_REDIRECT_URL = "sess.auth.redirect_url" -COOKIE_NAME = "authsession" -SPOE_AUTH_RELATION = "spoe-auth" -SPOE_AUTH_MESSAGE_NAME = "try-auth-oidc" class HaproxySpoeAuthCharm(ops.CharmBase): diff --git a/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py b/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py index 494bee603..d1fcc549a 100644 --- a/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py +++ b/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py @@ -11,9 +11,13 @@ from state import CharmState, OauthInformation -SNAP_NAME = "haproxy-spoe-auth" CONFIG_PATH = Path("/var/snap/haproxy-spoe-auth/current/config.yaml") CONFIG_TEMPLATE = "config.yaml.j2" +COOKIE_NAME = "authsession" +OIDC_CALLBACK_PATH = "/oauth2/callback" +OIDC_CALLBACK_PORT = 5000 +SNAP_NAME = "haproxy-spoe-auth" +SPOP_PORT = 8081 logger = logging.getLogger(__name__) @@ -80,12 +84,16 @@ def _render_config(self, charm_state: CharmState, oauth_information: OauthInform ) template = env.get_template(CONFIG_TEMPLATE) config_content = template.render( - hostname=charm_state.hostname, - issuer_url=oauth_information.issuer_url, client_id=oauth_information.client_id, client_secret=oauth_information.client_secret, - signature_secret=charm_state.signature_secret, + cookie_name=COOKIE_NAME, encryption_secret=charm_state.encryption_secret, + hostname=charm_state.hostname, + issuer_url=oauth_information.issuer_url, + oidc_callback_path=OIDC_CALLBACK_PATH, + oidc_callback_port=OIDC_CALLBACK_PORT, + signature_secret=charm_state.signature_secret, + spop_port=SPOP_PORT, ) # Ensure parent directory exists diff --git a/haproxy-spoe-auth-operator/templates/config.yaml.j2 b/haproxy-spoe-auth-operator/templates/config.yaml.j2 index 0533d3d9c..7e81d2b91 100644 --- a/haproxy-spoe-auth-operator/templates/config.yaml.j2 +++ b/haproxy-spoe-auth-operator/templates/config.yaml.j2 @@ -1,6 +1,6 @@ # HAProxy SPOE Auth Configuration spoe: - address: 8081 + address: 0.0.0.0:{{ spop_port }} oidc: # The URL to the OpenID Connect provider. This is the URL hosting the discovery endpoint @@ -16,10 +16,10 @@ oidc: oauth2_healthcheck_path: /health # The SPOE agent will open a dedicated port for the HTTP server handling the callback. This is the address the server listens on - callback_addr: ":5000" + callback_addr: 0.0.0.0:{{ oidc_callback_port }} # Various properties of the cookie holding the ID Token of the user - cookie_name: authsession + cookie_name: {{ cookie_name }} cookie_secure: true # If not set, then uses ID token expire timestamp cookie_ttl_seconds: 3600 @@ -33,4 +33,4 @@ oauth: {{ hostname }}: client_id: {{ client_id }} client_secret: {{ client_secret }} - redirect_url: https://{{ hostname }}/oauth2/callback + redirect_url: https://{{ hostname }}{{ oidc_callback_path }} From 8206704babb696346f0268ccde2551115d0d3254 Mon Sep 17 00:00:00 2001 From: Phan Trung Thanh Date: Thu, 27 Nov 2025 16:38:55 +0100 Subject: [PATCH 37/48] Update .github/workflows/test.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sébastien Georget --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index db9592077..38429c00b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -21,4 +21,4 @@ jobs: self-hosted-runner-image: "noble" python-version: 3.12 working-directory: ./haproxy-spoe-auth-operator - with-uv: True + with-uv: true From 056537ed36d91d8f49175f69d99e23f978f156af Mon Sep 17 00:00:00 2001 From: Phan Trung Thanh Date: Thu, 27 Nov 2025 16:39:23 +0100 Subject: [PATCH 38/48] Update haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sébastien Georget --- haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py b/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py index d1fcc549a..971461728 100644 --- a/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py +++ b/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py @@ -47,7 +47,7 @@ def install(self) -> None: """ try: if not self.haproxy_spoe_auth_snap.present: - self.haproxy_spoe_auth_snap.ensure(snap.SnapState.Latest, channel="edge") + self.haproxy_spoe_auth_snap.ensure(snap.SnapState.Latest, channel=SNAP_CHANNEL) self.haproxy_spoe_auth_snap.restart(reload=True) except snap.SnapError as e: logger.error("An exception occurred when installing charmcraft. Reason: %s", e.message) From 2240a0c7e3c9e392047c37a66e5e35dfe1b721b5 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 27 Nov 2025 16:40:01 +0100 Subject: [PATCH 39/48] add snap channel --- haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py b/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py index 971461728..d0d26a4aa 100644 --- a/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py +++ b/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py @@ -16,6 +16,7 @@ COOKIE_NAME = "authsession" OIDC_CALLBACK_PATH = "/oauth2/callback" OIDC_CALLBACK_PORT = 5000 +SNAP_CHANNEL = "latest/edge" SNAP_NAME = "haproxy-spoe-auth" SPOP_PORT = 8081 From 5d068d409449219706f4bff10a4dcf6b67f97f68 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 27 Nov 2025 16:42:20 +0100 Subject: [PATCH 40/48] update readme with correct instruction --- haproxy-spoe-auth-operator/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haproxy-spoe-auth-operator/README.md b/haproxy-spoe-auth-operator/README.md index 89b9f5b28..5c5c8c64e 100644 --- a/haproxy-spoe-auth-operator/README.md +++ b/haproxy-spoe-auth-operator/README.md @@ -12,7 +12,7 @@ HAProxy SPOE Auth is a Stream Processing Offload Engine (SPOE) agent for HAProxy Deploy the charm: ```bash -juju deploy haproxy_spoe_auth_operator --channel=latest/edge +juju deploy haproxy_spoe_auth_operator --channel=latest/edge --config hostname=auth.example.com ``` Integrate with an OAuth provider: From 1741ee5bc03eae18c21ba29749a67dc04bbc10eb Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 27 Nov 2025 16:44:22 +0100 Subject: [PATCH 41/48] update docs and links --- haproxy-spoe-auth-operator/charmcraft.yaml | 2 +- .../src/haproxy_spoe_auth_service.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/haproxy-spoe-auth-operator/charmcraft.yaml b/haproxy-spoe-auth-operator/charmcraft.yaml index a782ab1ff..8d62d277b 100644 --- a/haproxy-spoe-auth-operator/charmcraft.yaml +++ b/haproxy-spoe-auth-operator/charmcraft.yaml @@ -25,7 +25,7 @@ name: haproxy-spoe-auth title: HAProxy SPOE Auth charm description: | A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) - deploying and managing [HAProxy SPOE Auth](https://github.com/Thanhphan1147/haproxy-spoe-auth) on machines. + deploying and managing [HAProxy SPOE Auth](https://github.com/criteo/haproxy-spoe-auth) on machines. HAProxy SPOE Auth is a Stream Processing Offload Engine agent for HAProxy that provides authentication capabilities including OAuth support. diff --git a/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py b/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py index d0d26a4aa..e4fcc9073 100644 --- a/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py +++ b/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py @@ -50,8 +50,12 @@ def install(self) -> None: if not self.haproxy_spoe_auth_snap.present: self.haproxy_spoe_auth_snap.ensure(snap.SnapState.Latest, channel=SNAP_CHANNEL) self.haproxy_spoe_auth_snap.restart(reload=True) - except snap.SnapError as e: - logger.error("An exception occurred when installing charmcraft. Reason: %s", e.message) + except snap.SnapError as exc: + logger.error( + "An exception occurred when installing the haproxy-spoe-auth snap: %s", + str(exc), + ) + raise SpoeAuthServiceInstallError("Failed to install haproxy-spoe-auth snap") from exc def reconcile(self, charm_state: CharmState, oauth_information: OauthInformation) -> None: """Reconcile the service configuration. From e609c6ce682547b1b2b53fe591470982a22e9f0f Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 27 Nov 2025 16:44:51 +0100 Subject: [PATCH 42/48] don't catch service install error --- haproxy-spoe-auth-operator/src/charm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haproxy-spoe-auth-operator/src/charm.py b/haproxy-spoe-auth-operator/src/charm.py index a3764c9df..3b80837e1 100644 --- a/haproxy-spoe-auth-operator/src/charm.py +++ b/haproxy-spoe-auth-operator/src/charm.py @@ -87,7 +87,7 @@ def _reconcile(self, _: ops.EventBase) -> None: except InvalidCharmConfigError as exc: logger.exception("Charm state validation failed") self.unit.status = ops.BlockedStatus(f"Configuration error: {exc}") - except (SpoeAuthServiceInstallError, SpoeAuthServiceConfigError) as exc: + except SpoeAuthServiceConfigError as exc: logger.exception("Error configuring the haproxy-spoe-auth service.") self.unit.status = ops.BlockedStatus(f"Service configuration failed: {exc}") From 4d7f46156308ca823375318797ba05f6941b0049 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 27 Nov 2025 16:49:06 +0100 Subject: [PATCH 43/48] update readme to fix broken command --- haproxy-operator/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haproxy-operator/README.md b/haproxy-operator/README.md index 3dbed3212..0e1aa73c4 100644 --- a/haproxy-operator/README.md +++ b/haproxy-operator/README.md @@ -12,7 +12,7 @@ juju deploy haproxy --channel=2.8/edge juju config haproxy external-hostname="fqdn.example" juju deploy self-signed-certificates -juju integrate haproxy self-signed-certificates +juju integrate haproxy self-signed-certificates:certificates ``` # HAProxy project information From 6cb06c0d6817cf443c1602a48426ae0b1a445c5a Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 27 Nov 2025 16:50:01 +0100 Subject: [PATCH 44/48] remove extra information from change artifact --- docs/release-notes/artifacts/pr0232.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/release-notes/artifacts/pr0232.yaml b/docs/release-notes/artifacts/pr0232.yaml index e3c598af6..c2176f125 100644 --- a/docs/release-notes/artifacts/pr0232.yaml +++ b/docs/release-notes/artifacts/pr0232.yaml @@ -7,8 +7,6 @@ changes: type: major description: | Added a new charm for HAProxy SPOE authentication. - Refactor the haproxy-operator charm code to a folder in the monorepo. - Update the relevant workflows to support the new directory structure. urls: pr: "https://github.com/canonical/haproxy-operator/pull/232" related_doc: From 2aec9e2abe3d8b657d1c4021464a9ded6c2e6c6a Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 27 Nov 2025 16:50:25 +0100 Subject: [PATCH 45/48] remove quotes --- docs/release-notes/artifacts/pr0232.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/artifacts/pr0232.yaml b/docs/release-notes/artifacts/pr0232.yaml index c2176f125..3e7bf8d77 100644 --- a/docs/release-notes/artifacts/pr0232.yaml +++ b/docs/release-notes/artifacts/pr0232.yaml @@ -8,7 +8,7 @@ changes: description: | Added a new charm for HAProxy SPOE authentication. urls: - pr: "https://github.com/canonical/haproxy-operator/pull/232" + pr: https://github.com/canonical/haproxy-operator/pull/232 related_doc: related_issue: visibility: public From 21334101b36dcdb4544c42fc70dfa1b964f9904a Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 27 Nov 2025 17:04:45 +0100 Subject: [PATCH 46/48] remove unused import --- haproxy-spoe-auth-operator/src/charm.py | 1 - 1 file changed, 1 deletion(-) diff --git a/haproxy-spoe-auth-operator/src/charm.py b/haproxy-spoe-auth-operator/src/charm.py index 3b80837e1..0211dd873 100644 --- a/haproxy-spoe-auth-operator/src/charm.py +++ b/haproxy-spoe-auth-operator/src/charm.py @@ -19,7 +19,6 @@ SPOP_PORT, SpoeAuthService, SpoeAuthServiceConfigError, - SpoeAuthServiceInstallError, ) from state import CharmState, InvalidCharmConfigError, OauthInformation From ecbbb76035b6b9235cc865f87172c7091f3e1c84 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 27 Nov 2025 17:06:57 +0100 Subject: [PATCH 47/48] remove global catch and update it to catching only SnapError --- haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py b/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py index e4fcc9073..c2d480c02 100644 --- a/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py +++ b/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py @@ -70,7 +70,7 @@ def reconcile(self, charm_state: CharmState, oauth_information: OauthInformation try: self._render_config(charm_state, oauth_information) self.haproxy_spoe_auth_snap.restart(reload=True) - except Exception as exc: + except snap.SnapError as exc: raise SpoeAuthServiceConfigError(f"Failed to reconcile service: {exc}") from exc def _render_config(self, charm_state: CharmState, oauth_information: OauthInformation) -> None: From d4f1a57796775c47c4f46017c2555b839a16e807 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 27 Nov 2025 17:31:49 +0100 Subject: [PATCH 48/48] update template --- haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py | 4 ++++ haproxy-spoe-auth-operator/templates/config.yaml.j2 | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py b/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py index c2d480c02..2377f8d64 100644 --- a/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py +++ b/haproxy-spoe-auth-operator/src/haproxy_spoe_auth_service.py @@ -16,6 +16,8 @@ COOKIE_NAME = "authsession" OIDC_CALLBACK_PATH = "/oauth2/callback" OIDC_CALLBACK_PORT = 5000 +OIDC_HEALTHCHECK_PATH = "/health" +OIDC_LOGOUT_PATH = "/oauth2/logout" SNAP_CHANNEL = "latest/edge" SNAP_NAME = "haproxy-spoe-auth" SPOP_PORT = 8081 @@ -97,6 +99,8 @@ def _render_config(self, charm_state: CharmState, oauth_information: OauthInform issuer_url=oauth_information.issuer_url, oidc_callback_path=OIDC_CALLBACK_PATH, oidc_callback_port=OIDC_CALLBACK_PORT, + oidc_logout_path=OIDC_LOGOUT_PATH, + oidc_healthcheck_path=OIDC_HEALTHCHECK_PATH, signature_secret=charm_state.signature_secret, spop_port=SPOP_PORT, ) diff --git a/haproxy-spoe-auth-operator/templates/config.yaml.j2 b/haproxy-spoe-auth-operator/templates/config.yaml.j2 index 7e81d2b91..27a2737c5 100644 --- a/haproxy-spoe-auth-operator/templates/config.yaml.j2 +++ b/haproxy-spoe-auth-operator/templates/config.yaml.j2 @@ -7,13 +7,13 @@ oidc: provider_url: {{ issuer_url }} # The callback the OIDC server will redirect the user to once authentication is done - oauth2_callback_path: /oauth2/callback + oauth2_callback_path: {{ oidc_callback_path }} # The path to the logout endpoint to redirect the user to. - oauth2_logout_path: /oauth2/logout + oauth2_logout_path: {{ oidc_logout_path }} # The path the oidc client uses for a healthcheck - oauth2_healthcheck_path: /health + oauth2_healthcheck_path: {{ oidc_healthcheck_path }} # The SPOE agent will open a dedicated port for the HTTP server handling the callback. This is the address the server listens on callback_addr: 0.0.0.0:{{ oidc_callback_port }}