From 6aa78160171832039858024d4416a937a6455e27 Mon Sep 17 00:00:00 2001 From: Kamil Rajtar Date: Tue, 23 Jun 2026 14:31:51 +0200 Subject: [PATCH 01/12] feat: add Keycloak OIDC backend modules (PKCE, callback server, OIDC client, token manager, portal client) - ewccli/backends/keycloak/pkce.py: PKCE code_verifier/code_challenge + state generation - ewccli/backends/keycloak/callback_server.py: loopback HTTP server for OIDC redirect - ewccli/backends/keycloak/oidc_client.py: auth URL builder + token exchange/refresh - ewccli/backends/keycloak/token_manager.py: silent token refresh with rotation - ewccli/backends/keycloak/portal_client.py: portal API client for app cred exchange - ewccli/configuration.py: Keycloak/OIDC config constants (env-var overridable) - ewccli/utils.py: save_cli_profile/load_cli_profile extended with keycloak_* token fields - 40 tests covering all modules --- ewccli/backends/keycloak/__init__.py | 0 ewccli/backends/keycloak/callback_server.py | 136 ++++++++++++++ ewccli/backends/keycloak/oidc_client.py | 99 ++++++++++ ewccli/backends/keycloak/pkce.py | 28 +++ ewccli/backends/keycloak/portal_client.py | 81 +++++++++ ewccli/backends/keycloak/token_manager.py | 159 ++++++++++++++++ ewccli/configuration.py | 14 ++ ewccli/tests/ewccli_config_test.py | 54 ++++++ ewccli/tests/test_keycloak_callback_server.py | 62 +++++++ ewccli/tests/test_keycloak_oidc_client.py | 114 ++++++++++++ ewccli/tests/test_keycloak_pkce.py | 34 ++++ ewccli/tests/test_keycloak_portal_client.py | 114 ++++++++++++ ewccli/tests/test_keycloak_token_manager.py | 171 ++++++++++++++++++ ewccli/utils.py | 20 ++ 14 files changed, 1086 insertions(+) create mode 100644 ewccli/backends/keycloak/__init__.py create mode 100644 ewccli/backends/keycloak/callback_server.py create mode 100644 ewccli/backends/keycloak/oidc_client.py create mode 100644 ewccli/backends/keycloak/pkce.py create mode 100644 ewccli/backends/keycloak/portal_client.py create mode 100644 ewccli/backends/keycloak/token_manager.py create mode 100644 ewccli/tests/test_keycloak_callback_server.py create mode 100644 ewccli/tests/test_keycloak_oidc_client.py create mode 100644 ewccli/tests/test_keycloak_pkce.py create mode 100644 ewccli/tests/test_keycloak_portal_client.py create mode 100644 ewccli/tests/test_keycloak_token_manager.py diff --git a/ewccli/backends/keycloak/__init__.py b/ewccli/backends/keycloak/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ewccli/backends/keycloak/callback_server.py b/ewccli/backends/keycloak/callback_server.py new file mode 100644 index 0000000..8a958a9 --- /dev/null +++ b/ewccli/backends/keycloak/callback_server.py @@ -0,0 +1,136 @@ +"""Lightweight HTTP server to receive the OIDC authorization code callback.""" + +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Optional +from urllib.parse import urlparse, parse_qs + + +_SUCCESS_HTML = ( + b"" + b"

✅ Authentication successful!

" + b"

You can close this browser tab and return to your terminal.

" + b"" +) + +_ERROR_HTML = ( + b"" + b"

❌ Authentication failed

" + b"

State mismatch or error. Please try again.

" + b"" +) + + +class CallbackServer: + """HTTP server that listens for the OIDC redirect callback on localhost. + + Usage: + server = CallbackServer(expected_state="...") + server.start() + # ... open browser to auth URL ... + result = server.wait_for_callback(timeout=300) + server.stop() + """ + + def __init__(self, expected_state: str): + self._expected_state = expected_state + self._result: Optional[tuple[str, str]] = None + self._error: Optional[str] = None + self._httpd: Optional[HTTPServer] = None + self._thread: Optional[threading.Thread] = None + self.port: int = 0 + + def start(self) -> None: + """Start the server on a random loopback port.""" + handler = self._make_handler() + self._httpd = HTTPServer(("127.0.0.1", 0), handler) + self.port = self._httpd.server_address[1] + self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Shut down the server.""" + if self._httpd: + self._httpd.shutdown() + self._httpd.server_close() + if self._thread: + self._thread.join(timeout=2) + + def wait_for_callback(self, timeout: float = 300) -> Optional[tuple[str, str]]: + """Block until the callback is received or timeout. + + Returns (code, state) on success, or None on timeout/error. + """ + if self._thread is None: + return None + self._thread.join(timeout=timeout) + if self._thread.is_alive(): + return None # timed out + return self._result + + @property + def error(self) -> Optional[str]: + """Return error description if one occurred.""" + return self._error + + @property + def redirect_uri(self) -> str: + """The redirect_uri to pass to the authorization endpoint.""" + return f"http://127.0.0.1:{self.port}/callback" + + def _make_handler(self): + """Create a request handler class bound to this server instance.""" + + expected_state = self._expected_state + outer = self # closure over the CallbackServer instance + + class _Handler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + parsed = urlparse(self.path) + if parsed.path != "/callback": + self.send_response(404) + self.end_headers() + return + + params = parse_qs(parsed.query) + code = params.get("code", [None])[0] + state = params.get("state", [None])[0] + + if state != expected_state: + outer._error = "State mismatch" + self.send_response(400) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(_ERROR_HTML) + threading.Thread( + target=outer._httpd.shutdown, daemon=True + ).start() + return + + if code is None: + error = params.get("error", ["unknown"])[0] + outer._error = f"Authorization error: {error}" + self.send_response(400) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(_ERROR_HTML) + threading.Thread( + target=outer._httpd.shutdown, daemon=True + ).start() + return + + outer._result = (code, state) + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(_SUCCESS_HTML) + # Shut down the server in a separate thread so this handler + # can finish sending the response first. + threading.Thread( + target=outer._httpd.shutdown, daemon=True + ).start() + + def log_message(self, format, *args): # noqa: A002 + pass # silence stderr logging + + return _Handler diff --git a/ewccli/backends/keycloak/oidc_client.py b/ewccli/backends/keycloak/oidc_client.py new file mode 100644 index 0000000..ec269c9 --- /dev/null +++ b/ewccli/backends/keycloak/oidc_client.py @@ -0,0 +1,99 @@ +"""OIDC client for Keycloak authorization code + PKCE flow.""" + +from typing import Optional +from urllib.parse import urlencode + +import requests + +from ewccli.logger import get_logger + +_LOGGER = get_logger(__name__) + + +class OIDCClient: + """Handles OIDC authorization URL construction and token exchange.""" + + def __init__( + self, + keycloak_url: str, + realm: str, + client_id: str, + scope: str = "openid profile email", + ): + self._keycloak_url = keycloak_url.rstrip("/") + self._realm = realm + self._client_id = client_id + self._scope = scope + + @property + def authorization_endpoint(self) -> str: + return ( + f"{self._keycloak_url}/realms/{self._realm}" + "/protocol/openid-connect/auth" + ) + + @property + def token_endpoint(self) -> str: + return ( + f"{self._keycloak_url}/realms/{self._realm}" + "/protocol/openid-connect/token" + ) + + def build_authorization_url( + self, + redirect_uri: str, + code_challenge: str, + state: str, + ) -> str: + """Build the OIDC authorization URL with PKCE.""" + params = { + "client_id": self._client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": self._scope, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "state": state, + } + return f"{self.authorization_endpoint}?{urlencode(params)}" + + def exchange_code_for_tokens( + self, + code: str, + code_verifier: str, + redirect_uri: str, + ) -> dict: + """Exchange the authorization code for access/refresh tokens. + + Returns the token response dict with keys: + access_token, refresh_token, id_token, expires_in, token_type. + """ + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": self._client_id, + "code_verifier": code_verifier, + } + _LOGGER.debug("Exchanging authorization code for tokens") + response = requests.post(self.token_endpoint, data=data, timeout=30) + response.raise_for_status() + return response.json() + + def refresh_tokens(self, refresh_token: str) -> dict: + """Use a refresh token to obtain new tokens. + + With refresh token rotation enabled in Keycloak, this returns a + NEW refresh_token and invalidates the old one. + + Returns the token response dict. + """ + data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": self._client_id, + } + _LOGGER.debug("Refreshing OIDC tokens") + response = requests.post(self.token_endpoint, data=data, timeout=30) + response.raise_for_status() + return response.json() diff --git a/ewccli/backends/keycloak/pkce.py b/ewccli/backends/keycloak/pkce.py new file mode 100644 index 0000000..f42fa32 --- /dev/null +++ b/ewccli/backends/keycloak/pkce.py @@ -0,0 +1,28 @@ +"""PKCE (Proof Key for Code Exchange) utilities for OIDC flows.""" + +import base64 +import hashlib +import secrets + + +def generate_pkce_pair() -> tuple[str, str]: + """Generate a PKCE code_verifier and its S256 code_challenge. + + Returns: + A tuple of (code_verifier, code_challenge). The verifier is a + random URL-safe string of 43-128 chars. The challenge is + base64url(SHA256(verifier)) without padding. + """ + code_verifier = ( + base64.urlsafe_b64encode(secrets.token_bytes(32)) + .decode("ascii") + .rstrip("=") + ) + digest = hashlib.sha256(code_verifier.encode("ascii")).digest() + code_challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + return code_verifier, code_challenge + + +def generate_state() -> str: + """Generate a random state token for CSRF protection in OIDC flows.""" + return secrets.token_urlsafe(32) diff --git a/ewccli/backends/keycloak/portal_client.py b/ewccli/backends/keycloak/portal_client.py new file mode 100644 index 0000000..4d0a1d3 --- /dev/null +++ b/ewccli/backends/keycloak/portal_client.py @@ -0,0 +1,81 @@ +"""Portal API client — exchanges OIDC tokens for OpenStack application credentials.""" + +from dataclasses import dataclass +from typing import Optional + +import requests + +from ewccli.logger import get_logger + +_LOGGER = get_logger(__name__) + + +@dataclass +class PortalCredentials: + """OpenStack application credentials returned by the EWC portal.""" + + application_credential_id: str + application_credential_secret: str + auth_url: str + federee: Optional[str] = None + region: Optional[str] = None + tenant_name: Optional[str] = None + + +class PortalClient: + """Calls the EWC portal API to obtain OpenStack application credentials.""" + + def __init__(self, portal_api_url: str): + self._portal_api_url = portal_api_url.rstrip("/") + + @property + def credentials_endpoint(self) -> str: + return f"{self._portal_api_url}/api/v1/credentials/openstack" + + def fetch_openstack_credentials( + self, + access_token: str, + federee: Optional[str] = None, + region: Optional[str] = None, + ) -> PortalCredentials: + """Fetch OpenStack application credentials from the portal API. + + Args: + access_token: The OIDC access token from Keycloak. + federee: Optional federee to request credentials for. + region: Optional region to request credentials for. + + Returns: + PortalCredentials dataclass with the app cred id/secret and auth_url. + + Raises: + requests.HTTPError: If the API call fails. + """ + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + body: dict = {} + if federee: + body["federee"] = federee + if region: + body["region"] = region + + _LOGGER.info("Fetching OpenStack credentials from EWC portal") + response = requests.post( + self.credentials_endpoint, + headers=headers, + json=body if body else None, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + return PortalCredentials( + application_credential_id=data["application_credential_id"], + application_credential_secret=data["application_credential_secret"], + auth_url=data["auth_url"], + federee=data.get("federee"), + region=data.get("region"), + tenant_name=data.get("tenant_name"), + ) diff --git a/ewccli/backends/keycloak/token_manager.py b/ewccli/backends/keycloak/token_manager.py new file mode 100644 index 0000000..cc929b6 --- /dev/null +++ b/ewccli/backends/keycloak/token_manager.py @@ -0,0 +1,159 @@ +"""Token manager — handles silent OIDC token refresh with rotation.""" + +from datetime import datetime, timezone, timedelta +from typing import Optional +from pathlib import Path +from configparser import ConfigParser +import os + +from click import ClickException + +from ewccli.backends.keycloak.oidc_client import OIDCClient +from ewccli.configuration import config as ewc_hub_config +from ewccli.logger import get_logger + +_LOGGER = get_logger(__name__) + +# Refresh if the access token expires within this many seconds +_REFRESH_SKEW_SECONDS = 60 + + +def _parse_iso_timestamp(ts: Optional[str]) -> Optional[datetime]: + """Parse an ISO 8601 timestamp string into a timezone-aware datetime.""" + if not ts: + return None + try: + # Handle both with and without 'Z' suffix + clean = ts.replace("Z", "+00:00") + return datetime.fromisoformat(clean) + except (ValueError, TypeError): + return None + + +def _is_expired(expires_at: Optional[datetime], skew_seconds: int = _REFRESH_SKEW_SECONDS) -> bool: + """Check if a token is expired or about to expire (within skew window).""" + if expires_at is None: + return True + now = datetime.now(timezone.utc) + return now >= (expires_at - timedelta(seconds=skew_seconds)) + + +def _compute_expires_at(expires_in: int) -> str: + """Compute the absolute expiry timestamp from an expires_in value.""" + expiry = datetime.now(timezone.utc) + timedelta(seconds=expires_in) + return expiry.isoformat() + + +def get_valid_access_token( + profile: dict, + profiles_file_path: Optional[Path] = None, +) -> str: + """Return a valid access token, refreshing if necessary. + + This function checks if the stored access token is still valid. If not, + it attempts a silent refresh using the stored refresh token. On success, + it updates the profile INI file with the new tokens (rotation). On failure, + it raises a ClickException telling the user to re-authenticate. + + Args: + profile: The loaded CLI profile dict (from load_cli_profile()). + profiles_file_path: Path to the profiles INI file. Defaults to the + standard EWC_CLI_PROFILES_PATH. + + Returns: + A valid access token string. + + Raises: + ClickException: If the refresh token is missing, expired, or invalid. + """ + if profiles_file_path is None: + profiles_file_path = ewc_hub_config.EWC_CLI_PROFILES_PATH + + access_token = profile.get("keycloak_access_token") + expires_at_str = profile.get("keycloak_token_expires_at") + refresh_token = profile.get("keycloak_refresh_token") + + # If the access token is still valid, return it + expires_at = _parse_iso_timestamp(expires_at_str) + if access_token and not _is_expired(expires_at): + return access_token + + # Token is expired or about to expire — try to refresh + if not refresh_token: + raise ClickException( + "Your EWC session has expired. " + "Please run: ewc login --keycloak" + ) + + _LOGGER.info("Access token expired, attempting silent refresh...") + + oidc_client = OIDCClient( + keycloak_url=ewc_hub_config.EWC_CLI_KEYCLOAK_URL, + realm=ewc_hub_config.EWC_CLI_KEYCLOAK_REALM, + client_id=ewc_hub_config.EWC_CLI_KEYCLOAK_CLIENT_ID, + scope=ewc_hub_config.EWC_CLI_KEYCLOAK_SCOPE, + ) + + try: + new_tokens = oidc_client.refresh_tokens(refresh_token=refresh_token) + except Exception as e: + raise ClickException( + f"Your EWC session has expired and could not be refreshed: {e}. " + "Please run: ewc login --keycloak" + ) + + new_access_token = new_tokens.get("access_token") + new_refresh_token = new_tokens.get("refresh_token") + new_expires_in = new_tokens.get("expires_in", 300) + new_expires_at = _compute_expires_at(new_expires_in) + + if not new_access_token: + raise ClickException( + "Token refresh succeeded but no access_token was returned. " + "Please run: ewc login --keycloak" + ) + + # Update the profile INI with the rotated tokens + _update_profile_tokens( + profiles_file_path=profiles_file_path, + profile_name=profile.get("profile"), + access_token=new_access_token, + refresh_token=new_refresh_token, + expires_at=new_expires_at, + id_token=new_tokens.get("id_token"), + ) + + _LOGGER.info("Successfully refreshed OIDC tokens.") + + return new_access_token + + +def _update_profile_tokens( + profiles_file_path: Path, + profile_name: Optional[str], + access_token: str, + refresh_token: Optional[str], + expires_at: str, + id_token: Optional[str] = None, +) -> None: + """Update the OIDC token fields in the profile INI file.""" + if not profile_name: + _LOGGER.warning("No profile name provided, cannot persist refreshed tokens.") + return + + cfg = ConfigParser() + cfg.read(profiles_file_path) + + if profile_name not in cfg: + _LOGGER.warning(f"Profile '{profile_name}' not found, cannot persist refreshed tokens.") + return + + cfg[profile_name]["keycloak_access_token"] = access_token + if refresh_token: + cfg[profile_name]["keycloak_refresh_token"] = refresh_token + if id_token: + cfg[profile_name]["keycloak_id_token"] = id_token + cfg[profile_name]["keycloak_token_expires_at"] = expires_at + + with open(profiles_file_path, "w") as f: + cfg.write(f) diff --git a/ewccli/configuration.py b/ewccli/configuration.py index e41d960..cb5fe3c 100644 --- a/ewccli/configuration.py +++ b/ewccli/configuration.py @@ -31,6 +31,20 @@ class EWCCLIConfiguration: EWC_CLI_DEFAULT_FEDEREE = "default" EWC_CLI_DEFAULT_KEYPAIR_NAME = "ewc-hub-key" + # Keycloak / OIDC configuration + EWC_CLI_KEYCLOAK_URL = os.getenv( + "EWC_CLI_KEYCLOAK_URL", "https://auth.europeanweather.cloud" + ) + EWC_CLI_KEYCLOAK_REALM = os.getenv("EWC_CLI_KEYCLOAK_REALM", "ewc") + EWC_CLI_KEYCLOAK_CLIENT_ID = os.getenv("EWC_CLI_KEYCLOAK_CLIENT_ID", "ewccli") + EWC_CLI_KEYCLOAK_SCOPE = os.getenv("EWC_CLI_KEYCLOAK_SCOPE", "openid profile email") + EWC_CLI_PORTAL_API_URL = os.getenv( + "EWC_CLI_PORTAL_API_URL", "https://europeanweather.cloud" + ) + EWC_CLI_OIDC_CALLBACK_TIMEOUT = int( + os.getenv("EWC_CLI_OIDC_CALLBACK_TIMEOUT", "300") + ) + # EWC_CLI_HUB_ITEMS_PATH = files("ewccli.data").joinpath("items.yaml") EWC_CLI_HUB_ITEMS_PATH = EWC_CLI_BASE_PATH / "items.yaml" EWC_CLI_HUB_SSH_REPO_PATH = EWC_CLI_BASE_PATH / ".ssh" diff --git a/ewccli/tests/ewccli_config_test.py b/ewccli/tests/ewccli_config_test.py index 8a96cd1..e097370 100644 --- a/ewccli/tests/ewccli_config_test.py +++ b/ewccli/tests/ewccli_config_test.py @@ -141,3 +141,57 @@ def test_overwrite_profile_not_allowed(profile_file_path, ssh_paths): ssh_public_key_path_to_save=ssh_public, profiles_file_path=str(profile_file_path), ) + + +def test_save_and_load_profile_with_oidc_tokens(profile_file_path, ssh_paths): + ssh_private, ssh_public = ssh_paths + + save_cli_profile( + federee="EUMETSAT", + region="ECIS-R1", + tenant_name="TeamA", + ssh_private_key_path_to_save=ssh_private, + ssh_public_key_path_to_save=ssh_public, + application_credential_id="app-id", + application_credential_secret="app-secret", + keycloak_access_token="access123", + keycloak_refresh_token="refresh456", + keycloak_id_token="id789", + keycloak_token_expires_at="2026-06-23T12:00:00+00:00", + profiles_file_path=str(profile_file_path), + ) + + data = load_cli_profile( + profile="eumetsat-ecis-r1-teama", + profiles_file_path=str(profile_file_path), + ) + + assert data["keycloak_access_token"] == "access123" + assert data["keycloak_refresh_token"] == "refresh456" + assert data["keycloak_id_token"] == "id789" + assert data["keycloak_token_expires_at"] == "2026-06-23T12:00:00+00:00" + + +def test_load_profile_without_oidc_tokens_returns_none(profile_file_path, ssh_paths): + """Profiles saved without OIDC tokens should load fine with None.""" + ssh_private, ssh_public = ssh_paths + + save_cli_profile( + federee="EUMETSAT", + region="ECIS-R1", + tenant_name="TeamA", + ssh_private_key_path_to_save=ssh_private, + ssh_public_key_path_to_save=ssh_public, + application_credential_id="app-id", + application_credential_secret="app-secret", + profiles_file_path=str(profile_file_path), + ) + + data = load_cli_profile( + profile="eumetsat-ecis-r1-teama", + profiles_file_path=str(profile_file_path), + ) + + assert data.get("keycloak_access_token") is None + assert data.get("keycloak_refresh_token") is None + assert data.get("keycloak_token_expires_at") is None diff --git a/ewccli/tests/test_keycloak_callback_server.py b/ewccli/tests/test_keycloak_callback_server.py new file mode 100644 index 0000000..46d202e --- /dev/null +++ b/ewccli/tests/test_keycloak_callback_server.py @@ -0,0 +1,62 @@ +"""Tests for the OIDC callback server.""" +import urllib.request +import urllib.error + +from ewccli.backends.keycloak.callback_server import CallbackServer + + +def test_callback_server_receives_code(): + server = CallbackServer(expected_state="mystate") + server.start() + + url = f"http://127.0.0.1:{server.port}/callback?code=mycode&state=mystate" + urllib.request.urlopen(url, timeout=5) + + result = server.wait_for_callback(timeout=5) + server.stop() + + assert result is not None + code, state = result + assert code == "mycode" + assert state == "mystate" + + +def test_callback_server_rejects_wrong_state(): + server = CallbackServer(expected_state="correct") + server.start() + + url = f"http://127.0.0.1:{server.port}/callback?code=mycode&state=wrong" + # Server returns 400 on state mismatch — urlopen raises HTTPError + try: + urllib.request.urlopen(url, timeout=5) + except urllib.error.HTTPError: + pass # expected — the 400 response is the correct behavior + + result = server.wait_for_callback(timeout=3) + server.stop() + + assert result is None + + +def test_callback_server_timeout(): + server = CallbackServer(expected_state="mystate") + server.start() + + result = server.wait_for_callback(timeout=1) + server.stop() + + assert result is None + + +def test_callback_server_port_is_assigned(): + server = CallbackServer(expected_state="mystate") + server.start() + assert server.port > 0 + server.stop() + + +def test_callback_server_redirect_uri(): + server = CallbackServer(expected_state="mystate") + server.start() + assert server.redirect_uri == f"http://127.0.0.1:{server.port}/callback" + server.stop() diff --git a/ewccli/tests/test_keycloak_oidc_client.py b/ewccli/tests/test_keycloak_oidc_client.py new file mode 100644 index 0000000..2b0e298 --- /dev/null +++ b/ewccli/tests/test_keycloak_oidc_client.py @@ -0,0 +1,114 @@ +"""Tests for the OIDC client.""" +import pytest +from unittest.mock import patch, MagicMock + +from ewccli.backends.keycloak.oidc_client import OIDCClient + + +@pytest.fixture +def oidc_client(): + return OIDCClient( + keycloak_url="https://auth.example.com", + realm="ewc", + client_id="ewccli", + scope="openid profile", + ) + + +def test_build_authorization_url(oidc_client): + url = oidc_client.build_authorization_url( + redirect_uri="http://127.0.0.1:12345/callback", + code_challenge="mychallenge", + state="mystate", + ) + assert "https://auth.example.com/realms/ewc/protocol/openid-connect/auth" in url + assert "client_id=ewccli" in url + assert "redirect_uri=" in url + assert "response_type=code" in url + assert "code_challenge=mychallenge" in url + assert "code_challenge_method=S256" in url + assert "state=mystate" in url + assert "scope=openid" in url + + +def test_authorization_endpoint(oidc_client): + assert oidc_client.authorization_endpoint == ( + "https://auth.example.com/realms/ewc/protocol/openid-connect/auth" + ) + + +def test_token_endpoint(oidc_client): + assert oidc_client.token_endpoint == ( + "https://auth.example.com/realms/ewc/protocol/openid-connect/token" + ) + + +@patch("ewccli.backends.keycloak.oidc_client.requests.post") +def test_exchange_code_for_tokens(mock_post, oidc_client): + mock_response = MagicMock() + mock_response.json.return_value = { + "access_token": "access123", + "refresh_token": "refresh456", + "id_token": "id789", + "expires_in": 3600, + "token_type": "Bearer", + } + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + tokens = oidc_client.exchange_code_for_tokens( + code="mycode", + code_verifier="myverifier", + redirect_uri="http://127.0.0.1:12345/callback", + ) + + assert tokens["access_token"] == "access123" + assert tokens["refresh_token"] == "refresh456" + assert tokens["id_token"] == "id789" + assert tokens["expires_in"] == 3600 + + mock_post.assert_called_once() + call_args = mock_post.call_args + assert "token" in call_args[0][0] + assert call_args[1]["data"]["grant_type"] == "authorization_code" + assert call_args[1]["data"]["code"] == "mycode" + assert call_args[1]["data"]["code_verifier"] == "myverifier" + assert call_args[1]["data"]["client_id"] == "ewccli" + + +@patch("ewccli.backends.keycloak.oidc_client.requests.post") +def test_refresh_tokens(mock_post, oidc_client): + mock_response = MagicMock() + mock_response.json.return_value = { + "access_token": "new_access", + "refresh_token": "new_refresh", + "expires_in": 300, + } + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + tokens = oidc_client.refresh_tokens(refresh_token="old_refresh") + + assert tokens["access_token"] == "new_access" + assert tokens["refresh_token"] == "new_refresh" + + call_args = mock_post.call_args + assert call_args[1]["data"]["grant_type"] == "refresh_token" + assert call_args[1]["data"]["refresh_token"] == "old_refresh" + assert call_args[1]["data"]["client_id"] == "ewccli" + + +@patch("ewccli.backends.keycloak.oidc_client.requests.post") +def test_exchange_code_raises_on_http_error(mock_post, oidc_client): + import requests as req + + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = req.exceptions.HTTPError("400") + mock_post.return_value = mock_response + + with pytest.raises(req.exceptions.HTTPError): + oidc_client.exchange_code_for_tokens( + code="badcode", + code_verifier="verifier", + redirect_uri="http://127.0.0.1:12345/callback", + ) diff --git a/ewccli/tests/test_keycloak_pkce.py b/ewccli/tests/test_keycloak_pkce.py new file mode 100644 index 0000000..2ed0e7a --- /dev/null +++ b/ewccli/tests/test_keycloak_pkce.py @@ -0,0 +1,34 @@ +"""Tests for PKCE utilities.""" +import base64 +import hashlib + +from ewccli.backends.keycloak.pkce import generate_pkce_pair, generate_state + + +def test_generate_pkce_pair_returns_verifier_and_challenge(): + verifier, challenge = generate_pkce_pair() + assert isinstance(verifier, str) + assert isinstance(challenge, str) + assert len(verifier) >= 43 + assert len(verifier) <= 128 + expected = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()) + .decode("ascii") + .rstrip("=") + ) + assert challenge == expected + + +def test_generate_pkce_pair_is_random(): + v1, c1 = generate_pkce_pair() + v2, c2 = generate_pkce_pair() + assert v1 != v2 + assert c1 != c2 + + +def test_generate_state_is_random_string(): + s1 = generate_state() + s2 = generate_state() + assert isinstance(s1, str) + assert len(s1) >= 32 + assert s1 != s2 diff --git a/ewccli/tests/test_keycloak_portal_client.py b/ewccli/tests/test_keycloak_portal_client.py new file mode 100644 index 0000000..1319ca0 --- /dev/null +++ b/ewccli/tests/test_keycloak_portal_client.py @@ -0,0 +1,114 @@ +"""Tests for the portal API client.""" +import pytest +from unittest.mock import patch, MagicMock + +from ewccli.backends.keycloak.portal_client import PortalClient, PortalCredentials + + +@pytest.fixture +def portal_client(): + return PortalClient( + portal_api_url="https://europeanweather.cloud", + ) + + +def test_credentials_endpoint(portal_client): + assert portal_client.credentials_endpoint == ( + "https://europeanweather.cloud/api/v1/credentials/openstack" + ) + + +def test_credentials_endpoint_strips_trailing_slash(): + client = PortalClient(portal_api_url="https://example.com/") + assert client.credentials_endpoint == "https://example.com/api/v1/credentials/openstack" + + +@patch("ewccli.backends.keycloak.portal_client.requests.post") +def test_fetch_openstack_credentials(mock_post, portal_client): + mock_response = MagicMock() + mock_response.json.return_value = { + "application_credential_id": "app-id-123", + "application_credential_secret": "app-secret-456", + "auth_url": "https://keystone.api.r1.cloud.eumetsat.int", + "federee": "EUMETSAT", + "region": "ECIS-R1", + "tenant_name": "my-tenant", + } + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + creds = portal_client.fetch_openstack_credentials( + access_token="oidc-token-789", + ) + + assert isinstance(creds, PortalCredentials) + assert creds.application_credential_id == "app-id-123" + assert creds.application_credential_secret == "app-secret-456" + assert creds.auth_url == "https://keystone.api.r1.cloud.eumetsat.int" + assert creds.federee == "EUMETSAT" + assert creds.region == "ECIS-R1" + assert creds.tenant_name == "my-tenant" + + call_args = mock_post.call_args + assert "Bearer oidc-token-789" in call_args[1]["headers"]["Authorization"] + # No body when federee/region not provided + assert call_args[1]["json"] is None + + +@patch("ewccli.backends.keycloak.portal_client.requests.post") +def test_fetch_openstack_credentials_with_federee_region(mock_post, portal_client): + mock_response = MagicMock() + mock_response.json.return_value = { + "application_credential_id": "id", + "application_credential_secret": "secret", + "auth_url": "https://keystone.example.com", + "federee": "ECMWF", + "region": "CC1", + "tenant_name": "tenant", + } + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + portal_client.fetch_openstack_credentials( + access_token="token", + federee="ECMWF", + region="CC1", + ) + + call_args = mock_post.call_args + assert call_args[1]["json"]["federee"] == "ECMWF" + assert call_args[1]["json"]["region"] == "CC1" + + +@patch("ewccli.backends.keycloak.portal_client.requests.post") +def test_fetch_openstack_credentials_http_error(mock_post, portal_client): + import requests as req + + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = req.exceptions.HTTPError("403 Forbidden") + mock_post.return_value = mock_response + + with pytest.raises(req.exceptions.HTTPError): + portal_client.fetch_openstack_credentials(access_token="bad-token") + + +@patch("ewccli.backends.keycloak.portal_client.requests.post") +def test_fetch_openstack_credentials_missing_fields(mock_post, portal_client): + """Portal returns only required fields, optionals are None.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "application_credential_id": "id", + "application_credential_secret": "secret", + "auth_url": "https://keystone.example.com", + } + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + creds = portal_client.fetch_openstack_credentials(access_token="token") + + assert creds.application_credential_id == "id" + assert creds.application_credential_secret == "secret" + assert creds.auth_url == "https://keystone.example.com" + assert creds.federee is None + assert creds.region is None + assert creds.tenant_name is None diff --git a/ewccli/tests/test_keycloak_token_manager.py b/ewccli/tests/test_keycloak_token_manager.py new file mode 100644 index 0000000..06b78d8 --- /dev/null +++ b/ewccli/tests/test_keycloak_token_manager.py @@ -0,0 +1,171 @@ +"""Tests for the token manager.""" +import pytest +from datetime import datetime, timezone, timedelta +from unittest.mock import patch, MagicMock +from click import ClickException + +from ewccli.backends.keycloak.token_manager import ( + _parse_iso_timestamp, + _is_expired, + _compute_expires_at, + get_valid_access_token, + _update_profile_tokens, +) + + +def test_parse_iso_timestamp_with_z(): + ts = _parse_iso_timestamp("2026-06-23T12:00:00Z") + assert ts is not None + assert ts.year == 2026 + assert ts.month == 6 + assert ts.day == 23 + + +def test_parse_iso_timestamp_with_offset(): + ts = _parse_iso_timestamp("2026-06-23T12:00:00+00:00") + assert ts is not None + assert ts.tzinfo is not None + + +def test_parse_iso_timestamp_none(): + assert _parse_iso_timestamp(None) is None + assert _parse_iso_timestamp("") is None + + +def test_parse_iso_timestamp_invalid(): + assert _parse_iso_timestamp("not-a-date") is None + + +def test_is_expired_with_past_time(): + past = datetime.now(timezone.utc) - timedelta(hours=1) + assert _is_expired(past) is True + + +def test_is_expired_with_future_time(): + future = datetime.now(timezone.utc) + timedelta(hours=1) + assert _is_expired(future) is False + + +def test_is_expired_with_soon_future(): + """Token expiring within the skew window should be considered expired.""" + soon = datetime.now(timezone.utc) + timedelta(seconds=30) + assert _is_expired(soon, skew_seconds=60) is True + + +def test_is_expired_none(): + assert _is_expired(None) is True + + +def test_compute_expires_at(): + expires_at = _compute_expires_at(300) + parsed = _parse_iso_timestamp(expires_at) + assert parsed is not None + # Should be about 5 minutes in the future + now = datetime.now(timezone.utc) + delta = parsed - now + assert 290 <= delta.total_seconds() <= 305 + + +def test_get_valid_access_token_returns_valid_token(): + """If the access token is not expired, return it without refreshing.""" + profile = { + "profile": "test", + "keycloak_access_token": "valid-token", + "keycloak_token_expires_at": ( + datetime.now(timezone.utc) + timedelta(hours=1) + ).isoformat(), + "keycloak_refresh_token": "refresh-token", + } + result = get_valid_access_token(profile) + assert result == "valid-token" + + +@patch("ewccli.backends.keycloak.token_manager.OIDCClient") +def test_get_valid_access_token_refreshes_expired_token(mock_oidc_cls): + mock_oidc = MagicMock() + mock_oidc.refresh_tokens.return_value = { + "access_token": "new-access", + "refresh_token": "new-refresh", + "id_token": "new-id", + "expires_in": 300, + } + mock_oidc_cls.return_value = mock_oidc + + profile = { + "profile": "test", + "keycloak_access_token": "old-access", + "keycloak_token_expires_at": ( + datetime.now(timezone.utc) - timedelta(hours=1) + ).isoformat(), + "keycloak_refresh_token": "old-refresh", + } + + with patch( + "ewccli.backends.keycloak.token_manager._update_profile_tokens" + ) as mock_update: + result = get_valid_access_token(profile) + assert result == "new-access" + mock_oidc.refresh_tokens.assert_called_once_with(refresh_token="old-refresh") + mock_update.assert_called_once() + + +def test_get_valid_access_token_no_refresh_token_raises(): + profile = { + "profile": "test", + "keycloak_access_token": "expired", + "keycloak_token_expires_at": ( + datetime.now(timezone.utc) - timedelta(hours=1) + ).isoformat(), + "keycloak_refresh_token": None, + } + with pytest.raises(ClickException, match="session has expired"): + get_valid_access_token(profile) + + +@patch("ewccli.backends.keycloak.token_manager.OIDCClient") +def test_get_valid_access_token_refresh_failure_raises(mock_oidc_cls): + mock_oidc = MagicMock() + mock_oidc.refresh_tokens.side_effect = Exception("invalid_grant") + mock_oidc_cls.return_value = mock_oidc + + profile = { + "profile": "test", + "keycloak_access_token": "expired", + "keycloak_token_expires_at": ( + datetime.now(timezone.utc) - timedelta(hours=1) + ).isoformat(), + "keycloak_refresh_token": "old-refresh", + } + with pytest.raises(ClickException, match="could not be refreshed"): + get_valid_access_token(profile) + + +def test_update_profile_tokens(tmp_path): + from configparser import ConfigParser + + profiles_file = tmp_path / "profiles" + cfg = ConfigParser() + cfg["test"] = { + "federee": "EUMETSAT", + "keycloak_access_token": "old", + } + with open(profiles_file, "w") as f: + cfg.write(f) + + _update_profile_tokens( + profiles_file_path=profiles_file, + profile_name="test", + access_token="new-token", + refresh_token="new-refresh", + expires_at="2026-06-23T13:00:00+00:00", + id_token="new-id", + ) + + cfg2 = ConfigParser() + cfg2.read(profiles_file) + assert cfg2["test"]["keycloak_access_token"] == "new-token" + assert cfg2["test"]["keycloak_refresh_token"] == "new-refresh" + assert cfg2["test"]["keycloak_id_token"] == "new-id" + assert cfg2["test"]["keycloak_token_expires_at"] == "2026-06-23T13:00:00+00:00" + # Existing keys should be preserved + assert cfg2["test"]["federee"] == "EUMETSAT" diff --git a/ewccli/utils.py b/ewccli/utils.py index f4990bd..08caad1 100644 --- a/ewccli/utils.py +++ b/ewccli/utils.py @@ -111,6 +111,10 @@ def save_cli_profile( token: Optional[str] = None, application_credential_id: Optional[str] = None, application_credential_secret: Optional[str] = None, + keycloak_access_token: Optional[str] = None, + keycloak_refresh_token: Optional[str] = None, + keycloak_id_token: Optional[str] = None, + keycloak_token_expires_at: Optional[str] = None, profiles_file_path: Path = ewc_hub_config.EWC_CLI_PROFILES_PATH, ) -> None: """ @@ -176,6 +180,18 @@ def save_cli_profile( "application_credential_secret" ] = application_credential_secret + if keycloak_access_token: + cfg[resolved_profile]["keycloak_access_token"] = keycloak_access_token + + if keycloak_refresh_token: + cfg[resolved_profile]["keycloak_refresh_token"] = keycloak_refresh_token + + if keycloak_id_token: + cfg[resolved_profile]["keycloak_id_token"] = keycloak_id_token + + if keycloak_token_expires_at: + cfg[resolved_profile]["keycloak_token_expires_at"] = keycloak_token_expires_at + os.makedirs(os.path.dirname(profiles_file_path), exist_ok=True) with open(profiles_file_path, "w") as f: cfg.write(f) @@ -369,6 +385,10 @@ def load_cli_profile( "token": section.get("token"), "application_credential_id": section.get("application_credential_id"), "application_credential_secret": section.get("application_credential_secret"), + "keycloak_access_token": section.get("keycloak_access_token"), + "keycloak_refresh_token": section.get("keycloak_refresh_token"), + "keycloak_id_token": section.get("keycloak_id_token"), + "keycloak_token_expires_at": section.get("keycloak_token_expires_at"), } From 09b7e860b5b152cdb990ab68bd92aa70eae5ea75 Mon Sep 17 00:00:00 2001 From: Kamil Rajtar Date: Tue, 23 Jun 2026 14:36:07 +0200 Subject: [PATCH 02/12] feat: integrate Keycloak OIDC login into ewc login command - ewccli/backends/keycloak/keycloak_backend.py: orchestrator tying PKCE, callback, OIDC client, portal client, and token manager together - ewccli/commands/login_command.py: --keycloak and --no-browser flags, keycloak login branch in init_command(), tenant_name made optional when using --keycloak - ewccli/ewccli.py: init() signature updated with new params - 51 tests total, all passing --- ewccli/backends/keycloak/keycloak_backend.py | 176 ++++++++++++++++++ ewccli/commands/login_command.py | 154 ++++++++++++---- ewccli/ewccli.py | 4 + ewccli/tests/test_keycloak_backend.py | 182 +++++++++++++++++++ 4 files changed, 478 insertions(+), 38 deletions(-) create mode 100644 ewccli/backends/keycloak/keycloak_backend.py create mode 100644 ewccli/tests/test_keycloak_backend.py diff --git a/ewccli/backends/keycloak/keycloak_backend.py b/ewccli/backends/keycloak/keycloak_backend.py new file mode 100644 index 0000000..1dc06a0 --- /dev/null +++ b/ewccli/backends/keycloak/keycloak_backend.py @@ -0,0 +1,176 @@ +"""Keycloak login orchestrator — ties PKCE, callback, OIDC, and portal together.""" + +import webbrowser +from dataclasses import dataclass +from typing import Optional + +from click import ClickException +from rich.console import Console + +from ewccli.backends.keycloak.callback_server import CallbackServer +from ewccli.backends.keycloak.oidc_client import OIDCClient +from ewccli.backends.keycloak.pkce import generate_pkce_pair, generate_state +from ewccli.backends.keycloak.portal_client import PortalClient +from ewccli.backends.keycloak.token_manager import _compute_expires_at +from ewccli.logger import get_logger + +_LOGGER = get_logger(__name__) +_console = Console() + + +@dataclass +class KeycloakLoginResult: + """Result of a successful Keycloak login.""" + + application_credential_id: str + application_credential_secret: str + auth_url: str + access_token: str + refresh_token: Optional[str] + id_token: Optional[str] + token_expires_at: str + federee: Optional[str] = None + region: Optional[str] = None + tenant_name: Optional[str] = None + + +def keycloak_login( + config, + open_browser: bool = True, + federee: Optional[str] = None, + region: Optional[str] = None, +) -> KeycloakLoginResult: + """Run the full Keycloak OIDC login flow. + + 1. Start a local callback server + 2. Build the authorization URL (PKCE) + 3. Print URL and optionally open browser + 4. Wait for callback + 5. Exchange code for tokens + 6. Call portal API for OpenStack credentials + + Args: + config: EWCCLIConfiguration instance with Keycloak settings. + open_browser: If True, attempt to open the browser automatically. + federee: Optional federee to pass to the portal API. + region: Optional region to pass to the portal API. + + Returns: + KeycloakLoginResult with app creds and OIDC tokens. + + Raises: + ClickException: On timeout, state mismatch, or API errors. + """ + timeout = config.EWC_CLI_OIDC_CALLBACK_TIMEOUT + + # 1. Generate PKCE pair and state + code_verifier, code_challenge = generate_pkce_pair() + state = generate_state() + + # 2. Start callback server + server = CallbackServer(expected_state=state) + server.start() + _LOGGER.debug(f"Callback server listening on port {server.port}") + + # 3. Build OIDC client and authorization URL + oidc_client = OIDCClient( + keycloak_url=config.EWC_CLI_KEYCLOAK_URL, + realm=config.EWC_CLI_KEYCLOAK_REALM, + client_id=config.EWC_CLI_KEYCLOAK_CLIENT_ID, + scope=config.EWC_CLI_KEYCLOAK_SCOPE, + ) + + auth_url = oidc_client.build_authorization_url( + redirect_uri=server.redirect_uri, + code_challenge=code_challenge, + state=state, + ) + + # 4. Print URL and optionally open browser + _console.print( + "\n[bold cyan]Keycloak Login[/bold cyan]\n" + "Open the following URL in your browser to authenticate:\n" + ) + _console.print(f"[link={auth_url}]{auth_url}[/link]\n") + + if open_browser: + try: + webbrowser.open(auth_url) + _console.print("[green]Browser opened automatically.[/green]") + except Exception: + _console.print( + "[yellow]Could not open browser automatically. " + "Please copy the URL above manually.[/yellow]" + ) + else: + _console.print( + "[yellow]--no-browser: copy the URL above manually.[/yellow]" + ) + + _console.print(f"\nWaiting for authentication (timeout: {timeout}s)...") + + # 5. Wait for callback + callback_result = server.wait_for_callback(timeout=timeout) + redirect_uri = server.redirect_uri + server.stop() + + if callback_result is None: + if server.error: + raise ClickException( + f"OIDC authentication failed: {server.error}" + ) + raise ClickException( + f"OIDC authentication timed out after {timeout} seconds. " + "Please try again." + ) + + code, received_state = callback_result + + # 6. Exchange code for tokens + try: + tokens = oidc_client.exchange_code_for_tokens( + code=code, + code_verifier=code_verifier, + redirect_uri=redirect_uri, + ) + except Exception as e: + raise ClickException( + f"Failed to exchange authorization code for tokens: {e}" + ) + + _console.print("[green]Authentication successful![/green]") + + # 7. Fetch OpenStack credentials from portal + portal_client = PortalClient( + portal_api_url=config.EWC_CLI_PORTAL_API_URL, + ) + + try: + creds = portal_client.fetch_openstack_credentials( + access_token=tokens["access_token"], + federee=federee, + region=region, + ) + except Exception as e: + raise ClickException( + f"Failed to fetch OpenStack credentials from EWC portal: {e}" + ) + + _console.print("[green]OpenStack credentials obtained![/green]") + + # 8. Compute absolute expiry timestamp + expires_in = tokens.get("expires_in", 300) + token_expires_at = _compute_expires_at(expires_in) + + return KeycloakLoginResult( + application_credential_id=creds.application_credential_id, + application_credential_secret=creds.application_credential_secret, + auth_url=creds.auth_url, + access_token=tokens["access_token"], + refresh_token=tokens.get("refresh_token"), + id_token=tokens.get("id_token"), + token_expires_at=token_expires_at, + federee=creds.federee, + region=creds.region, + tenant_name=creds.tenant_name, + ) diff --git a/ewccli/commands/login_command.py b/ewccli/commands/login_command.py index fb526a7..70cb227 100644 --- a/ewccli/commands/login_command.py +++ b/ewccli/commands/login_command.py @@ -128,11 +128,11 @@ def init_options(func): func = click.option( "--tenant-name", envvar="EWC_CLI_LOGIN_TENANT_NAME", - prompt=True, - required=True, + required=False, callback=validate_tenant_name, help=( - "Name of your tenancy in EWC, used to identify cloud configurations.\n" + "Name of your tenancy in EWC, used to identify cloud configurations. " + "Required when not using --keycloak.\n" "Must follow the format: 'part1-part2-part3' (e.g. 'demo-user-eu'), " "where each part is alphanumeric and separated by dashes.\n" "Can also be set via the EWC_CLI_LOGIN_TENANT_NAME environment variable." @@ -217,6 +217,27 @@ def init_options(func): required=False, help="EWC CLI profile name", )(func) + func = click.option( + "--keycloak", + is_flag=True, + default=False, + envvar="EWC_CLI_KEYCLOAK_LOGIN", + help=( + "Login via Keycloak OIDC (browser-based). " + "Opens a browser for authentication and fetches " + "OpenStack credentials automatically from the EWC portal. " + "Can also be set via EWC_CLI_KEYCLOAK_LOGIN=1." + ), + )(func) + func = click.option( + "--no-browser", + is_flag=True, + default=False, + help=( + "Print the login URL instead of opening a browser. " + "Useful for SSH sessions or headless environments." + ), + )(func) return func @@ -391,33 +412,43 @@ def init_command( tenant_name: str, federee: str, region: str, - profile: str = None + profile: str = None, + keycloak: bool = False, + no_browser: bool = False, # token: str, ): """EWC CLI Login.""" - if not federee: + if keycloak and not federee: + # When using keycloak without an explicit federee, defer selection + # until after the portal returns federee/region. + pass + elif not federee: # If --federee is not passed, ask interactively federee = select_federee() if not federee: console.print("No federee selection made. Exiting.") return - console.print(f"Considering federee: {federee}") + if federee: + console.print(f"Considering federee: {federee}") - if not region: + if keycloak and not region: + pass + elif not region: # If --federee is not passed, ask interactively region = select_region(federee=federee) if not region: console.print("No region selection made. Exiting.") return - allowed_regions = ewc_hub_config.allowed_regions(federee) + if federee and region: + allowed_regions = ewc_hub_config.allowed_regions(federee) - if region not in allowed_regions: - raise click.BadParameter( - f"Region '{region}' is not valid for federee '{federee}'. " - f"Allowed: {', '.join(allowed_regions)}" - ) + if region not in allowed_regions: + raise click.BadParameter( + f"Region '{region}' is not valid for federee '{federee}'. " + f"Allowed: {', '.join(allowed_regions)}" + ) resolved_profile = _resolve_profile(profile, federee, region, tenant_name) @@ -441,6 +472,48 @@ def init_command( ) raise click.Abort() + # If tenant_name is missing and not using keycloak, prompt for it + if not keycloak and not tenant_name: + tenant_name = click.prompt("Tenant name") + + # --- Keycloak OIDC login path --- + keycloak_access_token = None + keycloak_refresh_token = None + keycloak_id_token = None + keycloak_token_expires_at = None + + if keycloak: + from ewccli.backends.keycloak.keycloak_backend import keycloak_login + + kc_result = keycloak_login( + config=ewc_hub_config, + open_browser=not no_browser, + federee=federee, + region=region, + ) + + # Use credentials from the portal + application_credential_id = kc_result.application_credential_id + application_credential_secret = kc_result.application_credential_secret + + # If the portal returned federee/region/tenant_name, use them + if kc_result.federee: + federee = kc_result.federee + if kc_result.region: + region = kc_result.region + if kc_result.tenant_name: + tenant_name = kc_result.tenant_name + + # Store OIDC tokens for future refresh + keycloak_access_token = kc_result.access_token + keycloak_refresh_token = kc_result.refresh_token + keycloak_id_token = kc_result.id_token + keycloak_token_expires_at = kc_result.token_expires_at + + # Re-resolve profile now that keycloak may have filled in + # federee/region/tenant_name. + resolved_profile = _resolve_profile(profile, federee, region, tenant_name) + ssh_private_key_path_to_save, ssh_public_key_path_to_save = check_and_generate_ssh_keys( ssh_public_key_path=ssh_public_key_path, ssh_private_key_path=ssh_private_key_path, @@ -448,34 +521,35 @@ def init_command( ) - if openstack_config_available(): - console.print( - "🔑 [bold green]Openstack cloud.yaml found at ~/.config/openstack/clouds.yaml[/bold green]" - " – skipping Openstack ID and secret requirements." - ) - application_credential_id = "" - application_credential_secret = "" - - elif not application_credential_id or not application_credential_secret: - if not application_credential_id: - # Handle OpenStack credential ID - application_credential_id = ( - application_credential_id - or os.getenv("OS_APPLICATION_CREDENTIAL_ID") - or click.prompt( - "Enter OpenStack Application Credential ID", hide_input=True - ) + if not keycloak: + if openstack_config_available(): + console.print( + "🔑 [bold green]Openstack cloud.yaml found at ~/.config/openstack/clouds.yaml[/bold green]" + " – skipping Openstack ID and secret requirements." ) + application_credential_id = "" + application_credential_secret = "" + + elif not application_credential_id or not application_credential_secret: + if not application_credential_id: + # Handle OpenStack credential ID + application_credential_id = ( + application_credential_id + or os.getenv("OS_APPLICATION_CREDENTIAL_ID") + or click.prompt( + "Enter OpenStack Application Credential ID", hide_input=True + ) + ) - if not application_credential_secret: - # Handle OpenStack credential secret - application_credential_secret = ( - application_credential_secret - or os.getenv("OS_APPLICATION_CREDENTIAL_SECRET") - or click.prompt( - "Enter OpenStack Application Credential Secret", hide_input=True + if not application_credential_secret: + # Handle OpenStack credential secret + application_credential_secret = ( + application_credential_secret + or os.getenv("OS_APPLICATION_CREDENTIAL_SECRET") + or click.prompt( + "Enter OpenStack Application Credential Secret", hide_input=True + ) ) - ) # if kubeconfig_available(): # click.echo("🔑 kubeconfig found – skipping token requirement.") @@ -514,6 +588,10 @@ def init_command( # token=token, application_credential_id=application_credential_id, application_credential_secret=application_credential_secret, + keycloak_access_token=keycloak_access_token, + keycloak_refresh_token=keycloak_refresh_token, + keycloak_id_token=keycloak_id_token, + keycloak_token_expires_at=keycloak_token_expires_at, ) console.print( diff --git a/ewccli/ewccli.py b/ewccli/ewccli.py index 41a8c54..0bee63e 100644 --- a/ewccli/ewccli.py +++ b/ewccli/ewccli.py @@ -49,6 +49,8 @@ def init( federee: str, region: str, profile: Optional[str] = None, + keycloak: bool = False, + no_browser: bool = False, # token: str, ): """Login command.""" @@ -61,6 +63,8 @@ def init( federee=federee, profile=profile, region=region, + keycloak=keycloak, + no_browser=no_browser, # token=token, ) diff --git a/ewccli/tests/test_keycloak_backend.py b/ewccli/tests/test_keycloak_backend.py new file mode 100644 index 0000000..4a0e11b --- /dev/null +++ b/ewccli/tests/test_keycloak_backend.py @@ -0,0 +1,182 @@ +"""Tests for the Keycloak login orchestrator.""" +import pytest +from unittest.mock import patch, MagicMock +from click import ClickException + +from ewccli.backends.keycloak.keycloak_backend import keycloak_login, KeycloakLoginResult + + +@pytest.fixture +def mock_config(): + config = MagicMock() + config.EWC_CLI_KEYCLOAK_URL = "https://auth.example.com" + config.EWC_CLI_KEYCLOAK_REALM = "ewc" + config.EWC_CLI_KEYCLOAK_CLIENT_ID = "ewccli" + config.EWC_CLI_KEYCLOAK_SCOPE = "openid profile" + config.EWC_CLI_PORTAL_API_URL = "https://portal.example.com" + config.EWC_CLI_OIDC_CALLBACK_TIMEOUT = 10 + return config + + +@patch("ewccli.backends.keycloak.keycloak_backend.webbrowser") +@patch("ewccli.backends.keycloak.keycloak_backend.PortalClient") +@patch("ewccli.backends.keycloak.keycloak_backend.OIDCClient") +@patch("ewccli.backends.keycloak.keycloak_backend.CallbackServer") +def test_keycloak_login_success( + mock_cb_server_cls, + mock_oidc_cls, + mock_portal_cls, + mock_webbrowser, + mock_config, +): + # Callback server + mock_server = MagicMock() + mock_server.port = 12345 + mock_server.redirect_uri = "http://127.0.0.1:12345/callback" + mock_server.wait_for_callback.return_value = ("mycode", "mystate") + mock_server.error = None + mock_cb_server_cls.return_value = mock_server + + # OIDC client + mock_oidc = MagicMock() + mock_oidc.build_authorization_url.return_value = "https://auth.example.com/auth?..." + mock_oidc.exchange_code_for_tokens.return_value = { + "access_token": "access123", + "refresh_token": "refresh456", + "id_token": "id789", + "expires_in": 3600, + } + mock_oidc_cls.return_value = mock_oidc + + # Portal client + mock_portal = MagicMock() + mock_creds = MagicMock() + mock_creds.application_credential_id = "app-id" + mock_creds.application_credential_secret = "app-secret" + mock_creds.auth_url = "https://keystone.example.com" + mock_creds.federee = "EUMETSAT" + mock_creds.region = "ECIS-R1" + mock_creds.tenant_name = "tenant" + mock_portal.fetch_openstack_credentials.return_value = mock_creds + mock_portal_cls.return_value = mock_portal + + result = keycloak_login( + config=mock_config, + open_browser=True, + federee="EUMETSAT", + region="ECIS-R1", + ) + + assert isinstance(result, KeycloakLoginResult) + assert result.application_credential_id == "app-id" + assert result.application_credential_secret == "app-secret" + assert result.auth_url == "https://keystone.example.com" + assert result.access_token == "access123" + assert result.refresh_token == "refresh456" + assert result.federee == "EUMETSAT" + assert result.region == "ECIS-R1" + + # Browser was opened + mock_webbrowser.open.assert_called_once() + + # Callback server was started and stopped + mock_server.start.assert_called_once() + mock_server.stop.assert_called_once() + + +@patch("ewccli.backends.keycloak.keycloak_backend.webbrowser") +@patch("ewccli.backends.keycloak.keycloak_backend.CallbackServer") +def test_keycloak_login_timeout( + mock_cb_server_cls, + mock_webbrowser, + mock_config, +): + mock_server = MagicMock() + mock_server.port = 12345 + mock_server.redirect_uri = "http://127.0.0.1:12345/callback" + mock_server.wait_for_callback.return_value = None # timeout + mock_server.error = None + mock_cb_server_cls.return_value = mock_server + + with pytest.raises(ClickException, match="timed out"): + keycloak_login( + config=mock_config, + open_browser=False, + ) + + +@patch("ewccli.backends.keycloak.keycloak_backend.webbrowser") +@patch("ewccli.backends.keycloak.keycloak_backend.CallbackServer") +def test_keycloak_login_state_mismatch( + mock_cb_server_cls, + mock_webbrowser, + mock_config, +): + mock_server = MagicMock() + mock_server.port = 12345 + mock_server.redirect_uri = "http://127.0.0.1:12345/callback" + mock_server.wait_for_callback.return_value = None + mock_server.error = "State mismatch" + mock_cb_server_cls.return_value = mock_server + + with pytest.raises(ClickException, match="State mismatch"): + keycloak_login( + config=mock_config, + open_browser=False, + ) + + +@patch("ewccli.backends.keycloak.keycloak_backend.PortalClient") +@patch("ewccli.backends.keycloak.keycloak_backend.OIDCClient") +@patch("ewccli.backends.keycloak.keycloak_backend.CallbackServer") +def test_keycloak_login_token_exchange_failure( + mock_cb_server_cls, + mock_oidc_cls, + mock_portal_cls, + mock_config, +): + mock_server = MagicMock() + mock_server.port = 12345 + mock_server.redirect_uri = "http://127.0.0.1:12345/callback" + mock_server.wait_for_callback.return_value = ("code", "state") + mock_server.error = None + mock_cb_server_cls.return_value = mock_server + + mock_oidc = MagicMock() + mock_oidc.exchange_code_for_tokens.side_effect = Exception("token endpoint down") + mock_oidc_cls.return_value = mock_oidc + + with pytest.raises(ClickException, match="Failed to exchange"): + keycloak_login(config=mock_config, open_browser=False) + + +@patch("ewccli.backends.keycloak.keycloak_backend.PortalClient") +@patch("ewccli.backends.keycloak.keycloak_backend.OIDCClient") +@patch("ewccli.backends.keycloak.keycloak_backend.CallbackServer") +def test_keycloak_login_portal_failure( + mock_cb_server_cls, + mock_oidc_cls, + mock_portal_cls, + mock_config, +): + mock_server = MagicMock() + mock_server.port = 12345 + mock_server.redirect_uri = "http://127.0.0.1:12345/callback" + mock_server.wait_for_callback.return_value = ("code", "state") + mock_server.error = None + mock_cb_server_cls.return_value = mock_server + + mock_oidc = MagicMock() + mock_oidc.exchange_code_for_tokens.return_value = { + "access_token": "token", + "refresh_token": "refresh", + "expires_in": 300, + } + mock_oidc_cls.return_value = mock_oidc + + mock_portal = MagicMock() + mock_portal.fetch_openstack_credentials.side_effect = Exception("403 Forbidden") + mock_portal_cls.return_value = mock_portal + + with pytest.raises(ClickException, match="Failed to fetch OpenStack credentials"): + keycloak_login(config=mock_config, open_browser=False) From b5ad87db9274054ae4e15633efca12fc9a9462d6 Mon Sep 17 00:00:00 2001 From: Kamil Rajtar Date: Tue, 23 Jun 2026 14:36:34 +0200 Subject: [PATCH 03/12] docs: document Keycloak OIDC login in README --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/README.md b/README.md index 5493a09..2cba792 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,53 @@ ssh_public_key_path = ssh_private_key_path = ``` +### Login with Keycloak (OIDC) + +Instead of manually entering OpenStack application credentials, you can authenticate via Keycloak: + +```bash +ewc login --keycloak +``` + +This will: +1. Open a browser window for Keycloak authentication +2. After successful login, fetch OpenStack credentials from the EWC portal +3. Save everything to your profile + +If you're on a headless machine or SSH session, use `--no-browser` to print the URL instead: + +```bash +ewc login --keycloak --no-browser +``` + +You can still combine with other flags: + +```bash +ewc login --keycloak --federee EUMETSAT --region ECIS-R1 +``` + +**Configuration:** + +The Keycloak settings can be overridden via environment variables: + +| Variable | Default | Description | +|---|---|---| +| `EWC_CLI_KEYCLOAK_URL` | `https://auth.europeanweather.cloud` | Keycloak server URL | +| `EWC_CLI_KEYCLOAK_REALM` | `ewc` | Keycloak realm | +| `EWC_CLI_KEYCLOAK_CLIENT_ID` | `ewccli` | OIDC client ID | +| `EWC_CLI_KEYCLOAK_SCOPE` | `openid profile email` | OIDC scopes | +| `EWC_CLI_PORTAL_API_URL` | `https://europeanweather.cloud` | EWC portal API URL | +| `EWC_CLI_OIDC_CALLBACK_TIMEOUT` | `300` | Callback wait timeout (seconds) | +| `EWC_CLI_KEYCLOAK_LOGIN` | `0` | Set to `1` to enable Keycloak login by default | + +**Token refresh:** + +The CLI stores OIDC tokens (access + refresh) in your profile and silently refreshes them when they expire. With refresh token rotation enabled on the Keycloak realm, each refresh invalidates the old refresh token for security. When the refresh token itself expires (default: 7 days), you will see: + +``` +Your EWC session has expired. Please run: ewc login --keycloak +``` + ## List Items in the catalog The following command shows the current available Items. Official Items are listed [here](https://github.com/ewcloud/ewc-community-hub/blob/main/items.yaml). From d8fc4a76091e722aad66344e0ad67ae9075d038b Mon Sep 17 00:00:00 2001 From: Kamil Rajtar Date: Tue, 23 Jun 2026 14:54:26 +0200 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20make=20portal=20API=20optional=20?= =?UTF-8?q?=E2=80=94=20Keycloak=20login=20works=20without=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When EWC_CLI_PORTAL_API_URL is not set (default), the Keycloak flow authenticates the user via OIDC, stores the tokens in the profile, then falls through to the existing credential path (cloud.yaml, env vars, or manual prompt). When the portal URL is set, it fetches OpenStack app creds automatically as before. --- README.md | 2 +- ewccli/backends/keycloak/keycloak_backend.py | 32 +++++++++-- ewccli/commands/login_command.py | 9 +-- ewccli/configuration.py | 2 +- ewccli/tests/test_keycloak_backend.py | 58 ++++++++++++++++++++ 5 files changed, 91 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2cba792..8ae5878 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ The Keycloak settings can be overridden via environment variables: | `EWC_CLI_KEYCLOAK_REALM` | `ewc` | Keycloak realm | | `EWC_CLI_KEYCLOAK_CLIENT_ID` | `ewccli` | OIDC client ID | | `EWC_CLI_KEYCLOAK_SCOPE` | `openid profile email` | OIDC scopes | -| `EWC_CLI_PORTAL_API_URL` | `https://europeanweather.cloud` | EWC portal API URL | +| `EWC_CLI_PORTAL_API_URL` | _(empty — portal disabled)_ | EWC portal API URL. When set, the CLI fetches OpenStack credentials automatically after Keycloak auth. When empty, the CLI stores OIDC tokens and falls through to the existing credential path (cloud.yaml, env vars, or manual prompt). | | `EWC_CLI_OIDC_CALLBACK_TIMEOUT` | `300` | Callback wait timeout (seconds) | | `EWC_CLI_KEYCLOAK_LOGIN` | `0` | Set to `1` to enable Keycloak login by default | diff --git a/ewccli/backends/keycloak/keycloak_backend.py b/ewccli/backends/keycloak/keycloak_backend.py index 1dc06a0..1c770b1 100644 --- a/ewccli/backends/keycloak/keycloak_backend.py +++ b/ewccli/backends/keycloak/keycloak_backend.py @@ -140,9 +140,33 @@ def keycloak_login( _console.print("[green]Authentication successful![/green]") - # 7. Fetch OpenStack credentials from portal + # 7. Fetch OpenStack credentials from portal (if configured) + expires_in = tokens.get("expires_in", 300) + token_expires_at = _compute_expires_at(expires_in) + + portal_url = getattr(config, "EWC_CLI_PORTAL_API_URL", "") + if not portal_url: + # Portal not configured — store OIDC tokens only, fall through to + # the existing credential path (cloud.yaml, env vars, or manual prompt). + _console.print( + "[yellow]Portal API not configured — skipping OpenStack credential fetch. " + "Set EWC_CLI_PORTAL_API_URL to enable automatic credential retrieval.[/yellow]" + ) + return KeycloakLoginResult( + application_credential_id="", + application_credential_secret="", + auth_url="", + access_token=tokens["access_token"], + refresh_token=tokens.get("refresh_token"), + id_token=tokens.get("id_token"), + token_expires_at=token_expires_at, + federee=federee, + region=region, + tenant_name=None, + ) + portal_client = PortalClient( - portal_api_url=config.EWC_CLI_PORTAL_API_URL, + portal_api_url=portal_url, ) try: @@ -158,10 +182,6 @@ def keycloak_login( _console.print("[green]OpenStack credentials obtained![/green]") - # 8. Compute absolute expiry timestamp - expires_in = tokens.get("expires_in", 300) - token_expires_at = _compute_expires_at(expires_in) - return KeycloakLoginResult( application_credential_id=creds.application_credential_id, application_credential_secret=creds.application_credential_secret, diff --git a/ewccli/commands/login_command.py b/ewccli/commands/login_command.py index 70cb227..e4c8d07 100644 --- a/ewccli/commands/login_command.py +++ b/ewccli/commands/login_command.py @@ -492,9 +492,10 @@ def init_command( region=region, ) - # Use credentials from the portal - application_credential_id = kc_result.application_credential_id - application_credential_secret = kc_result.application_credential_secret + # If the portal returned credentials, use them + if kc_result.application_credential_id: + application_credential_id = kc_result.application_credential_id + application_credential_secret = kc_result.application_credential_secret # If the portal returned federee/region/tenant_name, use them if kc_result.federee: @@ -521,7 +522,7 @@ def init_command( ) - if not keycloak: + if not keycloak or not application_credential_id: if openstack_config_available(): console.print( "🔑 [bold green]Openstack cloud.yaml found at ~/.config/openstack/clouds.yaml[/bold green]" diff --git a/ewccli/configuration.py b/ewccli/configuration.py index cb5fe3c..0d5fc1f 100644 --- a/ewccli/configuration.py +++ b/ewccli/configuration.py @@ -39,7 +39,7 @@ class EWCCLIConfiguration: EWC_CLI_KEYCLOAK_CLIENT_ID = os.getenv("EWC_CLI_KEYCLOAK_CLIENT_ID", "ewccli") EWC_CLI_KEYCLOAK_SCOPE = os.getenv("EWC_CLI_KEYCLOAK_SCOPE", "openid profile email") EWC_CLI_PORTAL_API_URL = os.getenv( - "EWC_CLI_PORTAL_API_URL", "https://europeanweather.cloud" + "EWC_CLI_PORTAL_API_URL", "" ) EWC_CLI_OIDC_CALLBACK_TIMEOUT = int( os.getenv("EWC_CLI_OIDC_CALLBACK_TIMEOUT", "300") diff --git a/ewccli/tests/test_keycloak_backend.py b/ewccli/tests/test_keycloak_backend.py index 4a0e11b..67226d2 100644 --- a/ewccli/tests/test_keycloak_backend.py +++ b/ewccli/tests/test_keycloak_backend.py @@ -18,6 +18,18 @@ def mock_config(): return config +@pytest.fixture +def mock_config_no_portal(): + config = MagicMock() + config.EWC_CLI_KEYCLOAK_URL = "https://auth.example.com" + config.EWC_CLI_KEYCLOAK_REALM = "ewc" + config.EWC_CLI_KEYCLOAK_CLIENT_ID = "ewccli" + config.EWC_CLI_KEYCLOAK_SCOPE = "openid profile" + config.EWC_CLI_PORTAL_API_URL = "" + config.EWC_CLI_OIDC_CALLBACK_TIMEOUT = 10 + return config + + @patch("ewccli.backends.keycloak.keycloak_backend.webbrowser") @patch("ewccli.backends.keycloak.keycloak_backend.PortalClient") @patch("ewccli.backends.keycloak.keycloak_backend.OIDCClient") @@ -180,3 +192,49 @@ def test_keycloak_login_portal_failure( with pytest.raises(ClickException, match="Failed to fetch OpenStack credentials"): keycloak_login(config=mock_config, open_browser=False) + + +@patch("ewccli.backends.keycloak.keycloak_backend.PortalClient") +@patch("ewccli.backends.keycloak.keycloak_backend.OIDCClient") +@patch("ewccli.backends.keycloak.keycloak_backend.CallbackServer") +def test_keycloak_login_no_portal_returns_empty_creds( + mock_cb_server_cls, + mock_oidc_cls, + mock_portal_cls, + mock_config_no_portal, +): + """When portal is not configured, return empty app creds but keep OIDC tokens.""" + mock_server = MagicMock() + mock_server.port = 12345 + mock_server.redirect_uri = "http://127.0.0.1:12345/callback" + mock_server.wait_for_callback.return_value = ("code", "state") + mock_server.error = None + mock_cb_server_cls.return_value = mock_server + + mock_oidc = MagicMock() + mock_oidc.exchange_code_for_tokens.return_value = { + "access_token": "access123", + "refresh_token": "refresh456", + "id_token": "id789", + "expires_in": 300, + } + mock_oidc_cls.return_value = mock_oidc + + result = keycloak_login( + config=mock_config_no_portal, + open_browser=False, + federee="EUMETSAT", + region="ECIS-R1", + ) + + assert isinstance(result, KeycloakLoginResult) + # App creds are empty — fall through to existing credential path + assert result.application_credential_id == "" + assert result.application_credential_secret == "" + assert result.auth_url == "" + # OIDC tokens are still stored for refresh + assert result.access_token == "access123" + assert result.refresh_token == "refresh456" + assert result.id_token == "id789" + # Portal client was never called + mock_portal_cls.assert_not_called() From ae5f740e18df1b2f1b192f041a0014507ca385e3 Mon Sep 17 00:00:00 2001 From: Kamil Rajtar Date: Tue, 23 Jun 2026 15:02:20 +0200 Subject: [PATCH 05/12] fix: suppress browser stderr noise, handle None tenant_name, defer profile resolution for keycloak --- .hermes/plans/2026-06-23_keycloak-login.md | 1831 ++++++++++++++++++ .idea/.gitignore | 10 + .idea/ewccli.iml | 11 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 7 + ewccli/backends/keycloak/keycloak_backend.py | 19 +- ewccli/commands/commons_infra.py | 188 +- ewccli/commands/infra_command.py | 77 +- ewccli/commands/login_command.py | 34 +- ewccli/tests/test_keycloak_backend.py | 8 +- uv.lock | 1773 +++++++++++++++++ 12 files changed, 3830 insertions(+), 142 deletions(-) create mode 100644 .hermes/plans/2026-06-23_keycloak-login.md create mode 100644 .idea/.gitignore create mode 100644 .idea/ewccli.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 uv.lock diff --git a/.hermes/plans/2026-06-23_keycloak-login.md b/.hermes/plans/2026-06-23_keycloak-login.md new file mode 100644 index 0000000..792d421 --- /dev/null +++ b/.hermes/plans/2026-06-23_keycloak-login.md @@ -0,0 +1,1831 @@ +# Keycloak OIDC Login for ewccli — Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Add a `ewc login --keycloak` flow that authenticates the user via Keycloak OIDC (authorization code + PKCE), then calls an EWC portal API to obtain OpenStack application credentials, storing them in the existing profile format — fully backward compatible with the current manual-credential login. + +**Architecture:** + +``` +User runs: ewc login --keycloak [--federee X --region Y] + + ┌──────────┐ 1. start callback server (127.0.0.1:port) + │ ewccli │ 2. build auth URL (PKCE code_challenge) + │ │ 3. print URL + open browser + │ │◄───4. browser redirects with ?code=...&state=... + │ │ 5. exchange code for tokens (code_verifier) + │ │ 6. call portal API with Bearer access_token + │ │ 7. receive app_credential_id/secret/auth_url + │ │ 8. interactive federee/region/SSH (if not from portal) + │ │ 9. save_cli_profile() — same INI format as today + └──────────┘ +``` + +The downstream OpenStack connection path (`OpenstackBackend.connect()` with `v3applicationcredential`) is unchanged. Keycloak is purely a new *way to obtain* the app creds. + +**Tech Stack:** Python stdlib (`http.server`, `secrets`, `hashlib`, `base64`, `urllib.parse`, `webbrowser`, `threading`), `requests` (already a dependency). No new dependencies required. + +**Assumed Portal API Contract** (the plan defines this; adjust when the real API is known): + +``` +POST {EWC_CLI_PORTAL_API_URL}/api/v1/credentials/openstack +Headers: Authorization: Bearer +Body (optional): {"federee": "EUMETSAT", "region": "ECIS-R1"} + +Response 200: +{ + "application_credential_id": "...", + "application_credential_secret": "...", + "auth_url": "https://keystone.api.r1.cloud.eumetsat.int", + "federee": "EUMETSAT", + "region": "ECIS-R1", + "tenant_name": "user-tenant" +} +``` + +If `federee`/`region` are omitted from the request, the portal may return the user's default project credentials, or return credentials for all available federees. The CLI handles either case. + +**Assumed Keycloak Config** (overridable via env vars): + +| Config key | Env var | Default | +|---|---|---| +| `EWC_CLI_KEYCLOAK_URL` | `EWC_CLI_KEYCLOAK_URL` | `https://auth.europeanweather.cloud` | +| `EWC_CLI_KEYCLOAK_REALM` | `EWC_CLI_KEYCLOAK_REALM` | `ewc` | +| `EWC_CLI_KEYCLOAK_CLIENT_ID` | `EWC_CLI_KEYCLOAK_CLIENT_ID` | `ewccli` | +| `EWC_CLI_PORTAL_API_URL` | `EWC_CLI_PORTAL_API_URL` | `https://europeanweather.cloud` | +| `EWC_CLI_KEYCLOAK_SCOPE` | `EWC_CLI_KEYCLOAK_SCOPE` | `openid profile email` | +| `EWC_CLI_OIDC_CALLBACK_TIMEOUT` | `EWC_CLI_OIDC_CALLBACK_TIMEOUT` | `300` (seconds) | + +--- + +## Current State Summary + +### Files that matter + +| File | Role | +|---|---| +| `ewccli/commands/login_command.py` | `ewc login` command + `init_options` decorator + `init_command()` logic | +| `ewccli/ewccli.py` | Registers the `login` command, wires `@init_options` | +| `ewccli/utils.py` | `save_cli_profile()`, `load_cli_profile()`, `save_default_login_profile()`, `_resolve_profile()` — INI-based profile at `~/.ewccli/profiles` | +| `ewccli/configuration.py` | `EWCCLIConfiguration` class — all config constants (paths, URLs, images, flavors, site map) | +| `ewccli/backends/openstack/backend_ostack.py` | `OpenstackBackend` — `connect()` uses `v3applicationcredential` auth type | +| `ewccli/commands/commons_infra.py` | `connect_to_openstack_backend()` helper | +| `ewccli/commands/infra_command.py` | `ewc infra` group — loads profile, instantiates `OpenstackBackend`, calls `connect()` | +| `ewccli/commands/hub/hub_command.py` | `ewc hub deploy` — same pattern: loads profile, creates backend, connects | +| `ewccli/enums.py` | `Federee`, `Region` enums | +| `ewccli/tests/ewccli_login_test.py` | Tests for `check_and_generate_ssh_keys` | +| `ewccli/tests/ewccli_config_test.py` | Tests for `save_cli_profile`/`load_cli_profile` | + +### Current login flow (what stays unchanged) + +1. `ewc login` → `init_command()` in `login_command.py` +2. Interactive: select federee (RadioList), select region (RadioList), enter app cred id/secret (click.prompt), handle SSH keys +3. `save_default_login_profile()` + `save_cli_profile()` write INI to `~/.ewccli/profiles` +4. Downstream: `load_cli_profile()` reads the INI, `OpenstackBackend` uses app creds to connect + +### What changes + +- `init_options` gets a new `--keycloak` flag (and `--no-browser`) +- `init_command()` gets a new branch: when `--keycloak` is set, run the OIDC flow instead of prompting for app creds +- New package `ewccli/backends/keycloak/` with the OIDC logic +- `configuration.py` gets Keycloak config constants +- `save_cli_profile()` / `load_cli_profile()` optionally store/load OIDC tokens (for refresh) +- New `token_manager.py` handles silent token refresh with rotation +- `ewccli.py` `init()` function signature gains the new params + +### What does NOT change + +- Profile INI format (new optional keys are additive) +- `OpenstackBackend` and its `connect()` method +- `connect_to_openstack_backend()` helper +- `load_cli_profile()` return dict shape (new optional keys only) +- All downstream commands (`infra`, `hub`) — they read app creds from the profile as before + +--- + +## Task Breakdown + +### Task 1: Add Keycloak/OIDC configuration constants + +**Objective:** Add config values for Keycloak URL, realm, client_id, portal API URL, scope, and callback timeout to `EWCCLIConfiguration`. + +**Files:** +- Modify: `ewccli/configuration.py` (add after line 31, the `EWC_CLI_DEFAULT_FEDEREE` line) + +**Step 1: Add config constants** + +Add these class attributes to `EWCCLIConfiguration`: + +```python + # Keycloak / OIDC configuration + EWC_CLI_KEYCLOAK_URL = os.getenv( + "EWC_CLI_KEYCLOAK_URL", "https://auth.europeanweather.cloud" + ) + EWC_CLI_KEYCLOAK_REALM = os.getenv("EWC_CLI_KEYCLOAK_REALM", "ewc") + EWC_CLI_KEYCLOAK_CLIENT_ID = os.getenv("EWC_CLI_KEYCLOAK_CLIENT_ID", "ewccli") + EWC_CLI_KEYCLOAK_SCOPE = os.getenv("EWC_CLI_KEYCLOAK_SCOPE", "openid profile email") + EWC_CLI_PORTAL_API_URL = os.getenv( + "EWC_CLI_PORTAL_API_URL", "https://europeanweather.cloud" + ) + EWC_CLI_OIDC_CALLBACK_TIMEOUT = int( + os.getenv("EWC_CLI_OIDC_CALLBACK_TIMEOUT", "300") + ) +``` + +**Step 2: Verify** + +Run: `python -c "from ewccli.configuration import config; print(config.EWC_CLI_KEYCLOAK_URL, config.EWC_CLI_KEYCLOAK_CLIENT_ID)"` +Expected: prints the default URL and `ewccli`. + +**Step 3: Commit** + +```bash +git add ewccli/configuration.py +git commit -m "feat: add Keycloak/OIDC configuration constants" +``` + +--- + +### Task 2: Create PKCE utilities module + +**Objective:** Create a module that generates PKCE code_verifier, code_challenge (S256), and a random state token. + +**Files:** +- Create: `ewccli/backends/keycloak/__init__.py` (empty) +- Create: `ewccli/backends/keycloak/pkce.py` + +**Step 1: Write failing test** + +Create `ewccli/tests/test_keycloak_pkce.py`: + +```python +"""Tests for PKCE utilities.""" +import base64 +import hashlib + +from ewccli.backends.keycloak.pkce import generate_pkce_pair, generate_state + + +def test_generate_pkce_pair_returns_verifier_and_challenge(): + verifier, challenge = generate_pkce_pair() + assert isinstance(verifier, str) + assert isinstance(challenge, str) + assert len(verifier) >= 43 + assert len(verifier) <= 128 + # Challenge must be base64url(SHA256(verifier)) without padding + expected = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()) + .decode("ascii") + .rstrip("=") + ) + assert challenge == expected + + +def test_generate_pkce_pair_is_random(): + v1, c1 = generate_pkce_pair() + v2, c2 = generate_pkce_pair() + assert v1 != v2 + assert c1 != c2 + + +def test_generate_state_is_random_string(): + s1 = generate_state() + s2 = generate_state() + assert isinstance(s1, str) + assert len(s1) >= 32 + assert s1 != s2 +``` + +**Step 2: Run test to verify failure** + +Run: `pytest ewccli/tests/test_keycloak_pkce.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ewccli.backends.keycloak.pkce'` + +**Step 3: Implement pkce.py** + +```python +"""PKCE (Proof Key for Code Exchange) utilities for OIDC flows.""" + +import base64 +import hashlib +import secrets + + +def generate_pkce_pair() -> tuple[str, str]: + """Generate a PKCE code_verifier and its S256 code_challenge. + + Returns: + A tuple of (code_verifier, code_challenge). The verifier is a + random URL-safe string of 43-128 chars. The challenge is + base64url(SHA256(verifier)) without padding. + """ + # Generate 32 random bytes -> 43 base64url chars (min length per RFC 7636) + code_verifier = ( + base64.urlsafe_b64encode(secrets.token_bytes(32)) + .decode("ascii") + .rstrip("=") + ) + # S256 challenge + digest = hashlib.sha256(code_verifier.encode("ascii")).digest() + code_challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + return code_verifier, code_challenge + + +def generate_state() -> str: + """Generate a random state token for CSRF protection in OIDC flows.""" + return secrets.token_urlsafe(32) +``` + +**Step 4: Run test to verify pass** + +Run: `pytest ewccli/tests/test_keycloak_pkce.py -v` +Expected: PASS — 3 passed + +**Step 5: Commit** + +```bash +git add ewccli/backends/keycloak/__init__.py ewccli/backends/keycloak/pkce.py ewccli/tests/test_keycloak_pkce.py +git commit -m "feat: add PKCE utilities for OIDC flows" +``` + +--- + +### Task 3: Create the OIDC callback HTTP server + +**Objective:** Create a lightweight HTTP server that listens on a random loopback port, receives the OIDC authorization code callback, and returns it to the main thread. + +**Files:** +- Create: `ewccli/backends/keycloak/callback_server.py` + +**Step 1: Write failing test** + +Create `ewccli/tests/test_keycloak_callback_server.py`: + +```python +"""Tests for the OIDC callback server.""" +import time +import urllib.request + +from ewccli.backends.keycloak.callback_server import CallbackServer + + +def test_callback_server_receives_code(): + server = CallbackServer(expected_state="mystate") + server.start() + + # Simulate browser redirect + url = f"http://127.0.0.1:{server.port}/callback?code=mycode&state=mystate" + urllib.request.urlopen(url, timeout=5) + + # Wait for the server to process + result = server.wait_for_callback(timeout=5) + server.stop() + + assert result is not None + code, state = result + assert code == "mycode" + assert state == "mystate" + + +def test_callback_server_rejects_wrong_state(): + server = CallbackServer(expected_state="correct") + server.start() + + url = f"http://127.0.0.1:{server.port}/callback?code=mycode&state=wrong" + urllib.request.urlopen(url, timeout=5) + + result = server.wait_for_callback(timeout=3) + server.stop() + + # Should return None or raise — state mismatch means no valid callback + assert result is None + + +def test_callback_server_timeout(): + server = CallbackServer(expected_state="mystate") + server.start() + + result = server.wait_for_callback(timeout=1) + server.stop() + + assert result is None + + +def test_callback_server_port_is_assigned(): + server = CallbackServer(expected_state="mystate") + server.start() + assert server.port > 0 + server.stop() +``` + +**Step 2: Run test to verify failure** + +Run: `pytest ewccli/tests/test_keycloak_callback_server.py -v` +Expected: FAIL — `ModuleNotFoundError` + +**Step 3: Implement callback_server.py** + +```python +"""Lightweight HTTP server to receive the OIDC authorization code callback.""" + +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Optional, tuple +from urllib.parse import urlparse, parse_qs + + +_SUCCESS_HTML = ( + b"" + b"

✅ Authentication successful!

" + b"

You can close this browser tab and return to your terminal.

" + b"" +) + +_ERROR_HTML = ( + b"" + b"

❌ Authentication failed

" + b"

State mismatch. Please try again.

" + b"" +) + + +class CallbackServer: + """HTTP server that listens for the OIDC redirect callback on localhost. + + Usage: + server = CallbackServer(expected_state="...") + server.start() + # ... open browser to auth URL ... + result = server.wait_for_callback(timeout=300) + server.stop() + """ + + def __init__(self, expected_state: str): + self._expected_state = expected_state + self._result: Optional[tuple[str, str]] = None + self._error: Optional[str] = None + self._httpd: Optional[HTTPServer] = None + self._thread: Optional[threading.Thread] = None + self.port: int = 0 + + def start(self) -> None: + """Start the server on a random loopback port.""" + handler = self._make_handler() + self._httpd = HTTPServer(("127.0.0.1", 0), handler) + self.port = self._httpd.server_address[1] + self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Shut down the server.""" + if self._httpd: + self._httpd.shutdown() + self._httpd.server_close() + if self._thread: + self._thread.join(timeout=2) + + def wait_for_callback(self, timeout: float = 300) -> Optional[tuple[str, str]]: + """Block until the callback is received or timeout. + + Returns (code, state) on success, or None on timeout/error. + """ + if self._thread is None: + return None + self._thread.join(timeout=timeout) + if self._thread.is_alive(): + return None # timed out + return self._result + + @property + def error(self) -> Optional[str]: + """Return error description if one occurred.""" + return self._error + + @property + def redirect_uri(self) -> str: + """The redirect_uri to pass to the authorization endpoint.""" + return f"http://127.0.0.1:{self.port}/callback" + + def _make_handler(self): + """Create a request handler class bound to this server instance.""" + + expected_state = self._expected_state + outer = self # closure over the CallbackServer instance + + class _Handler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + parsed = urlparse(self.path) + if parsed.path != "/callback": + self.send_response(404) + self.end_headers() + return + + params = parse_qs(parsed.query) + code = params.get("code", [None])[0] + state = params.get("state", [None])[0] + + if state != expected_state: + outer._error = "State mismatch" + self.send_response(400) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(_ERROR_HTML) + # Still stop the server so wait_for_callback returns + threading.Thread( + target=outer._httpd.shutdown, daemon=True + ).start() + return + + if code is None: + error = params.get("error", ["unknown"])[0] + outer._error = f"Authorization error: {error}" + self.send_response(400) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(_ERROR_HTML) + threading.Thread( + target=outer._httpd.shutdown, daemon=True + ).start() + return + + outer._result = (code, state) + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(_SUCCESS_HTML) + # Shut down the server in a separate thread so this handler + # can finish sending the response first. + threading.Thread( + target=outer._httpd.shutdown, daemon=True + ).start() + + def log_message(self, format, *args): # noqa: A002 + pass # silence stderr logging + + return _Handler +``` + +**Step 4: Run test to verify pass** + +Run: `pytest ewccli/tests/test_keycloak_callback_server.py -v` +Expected: PASS — 4 passed + +**Step 5: Commit** + +```bash +git add ewccli/backends/keycloak/callback_server.py ewccli/tests/test_keycloak_callback_server.py +git commit -m "feat: add OIDC callback HTTP server" +``` + +--- + +### Task 4: Create the OIDC client (auth URL + token exchange) + +**Objective:** Build the authorization URL, exchange the authorization code for tokens, and optionally refresh tokens. + +**Files:** +- Create: `ewccli/backends/keycloak/oidc_client.py` + +**Step 1: Write failing test** + +Create `ewccli/tests/test_keycloak_oidc_client.py`: + +```python +"""Tests for the OIDC client.""" +import pytest +from unittest.mock import patch, MagicMock + +from ewccli.backends.keycloak.oidc_client import OIDCClient +from ewccli.backends.keycloak.pkce import generate_pkce_pair + + +@pytest.fixture +def oidc_client(): + return OIDCClient( + keycloak_url="https://auth.example.com", + realm="ewc", + client_id="ewccli", + scope="openid profile", + ) + + +def test_build_authorization_url(oidc_client): + verifier, challenge = generate_pkce_pair() + url = oidc_client.build_authorization_url( + redirect_uri="http://127.0.0.1:12345/callback", + code_challenge=challenge, + state="mystate", + ) + assert "https://auth.example.com/realms/ewc/protocol/openid-connect/auth" in url + assert "client_id=ewccli" in url + assert "redirect_uri=" in url + assert "response_type=code" in url + assert "code_challenge=" in url + assert "code_challenge_method=S256" in url + assert "state=mystate" in url + assert "scope=openid" in url + + +@patch("ewccli.backends.keycloak.oidc_client.requests.post") +def test_exchange_code_for_tokens(mock_post, oidc_client): + mock_response = MagicMock() + mock_response.json.return_value = { + "access_token": "access123", + "refresh_token": "refresh456", + "id_token": "id789", + "expires_in": 3600, + "token_type": "Bearer", + } + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + tokens = oidc_client.exchange_code_for_tokens( + code="mycode", + code_verifier="myverifier", + redirect_uri="http://127.0.0.1:12345/callback", + ) + + assert tokens["access_token"] == "access123" + assert tokens["refresh_token"] == "refresh456" + assert tokens["id_token"] == "id789" + assert tokens["expires_in"] == 3600 + + # Verify the POST call + mock_post.assert_called_once() + call_args = mock_post.call_args + assert "token" in call_args[0][0] # URL contains /token + assert call_args[1]["data"]["grant_type"] == "authorization_code" + assert call_args[1]["data"]["code"] == "mycode" + assert call_args[1]["data"]["code_verifier"] == "myverifier" + + +@patch("ewccli.backends.keycloak.oidc_client.requests.post") +def test_refresh_tokens(mock_post, oidc_client): + mock_response = MagicMock() + mock_response.json.return_value = { + "access_token": "new_access", + "refresh_token": "new_refresh", + "expires_in": 3600, + } + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + tokens = oidc_client.refresh_tokens(refresh_token="old_refresh") + + assert tokens["access_token"] == "new_access" + call_args = mock_post.call_args + assert call_args[1]["data"]["grant_type"] == "refresh_token" + assert call_args[1]["data"]["refresh_token"] == "old_refresh" +``` + +**Step 2: Run test to verify failure** + +Run: `pytest ewccli/tests/test_keycloak_oidc_client.py -v` +Expected: FAIL — `ModuleNotFoundError` + +**Step 3: Implement oidc_client.py** + +```python +"""OIDC client for Keycloak authorization code + PKCE flow.""" + +from typing import Optional +from urllib.parse import urlencode + +import requests + +from ewccli.logger import get_logger + +_LOGGER = get_logger(__name__) + + +class OIDCClient: + """Handles OIDC authorization URL construction and token exchange.""" + + def __init__( + self, + keycloak_url: str, + realm: str, + client_id: str, + scope: str = "openid profile email", + ): + self._keycloak_url = keycloak_url.rstrip("/") + self._realm = realm + self._client_id = client_id + self._scope = scope + + @property + def authorization_endpoint(self) -> str: + return ( + f"{self._keycloak_url}/realms/{self._realm}" + "/protocol/openid-connect/auth" + ) + + @property + def token_endpoint(self) -> str: + return ( + f"{self._keycloak_url}/realms/{self._realm}" + "/protocol/openid-connect/token" + ) + + def build_authorization_url( + self, + redirect_uri: str, + code_challenge: str, + state: str, + ) -> str: + """Build the OIDC authorization URL with PKCE.""" + params = { + "client_id": self._client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": self._scope, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "state": state, + } + return f"{self.authorization_endpoint}?{urlencode(params)}" + + def exchange_code_for_tokens( + self, + code: str, + code_verifier: str, + redirect_uri: str, + ) -> dict: + """Exchange the authorization code for access/refresh tokens. + + Returns the token response dict with keys: + access_token, refresh_token, id_token, expires_in, token_type. + """ + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": self._client_id, + "code_verifier": code_verifier, + } + _LOGGER.debug("Exchanging authorization code for tokens") + response = requests.post(self.token_endpoint, data=data, timeout=30) + response.raise_for_status() + return response.json() + + def refresh_tokens(self, refresh_token: str) -> dict: + """Use a refresh token to obtain new tokens. + + Returns the token response dict. + """ + data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": self._client_id, + } + _LOGGER.debug("Refreshing OIDC tokens") + response = requests.post(self.token_endpoint, data=data, timeout=30) + response.raise_for_status() + return response.json() +``` + +**Step 4: Run test to verify pass** + +Run: `pytest ewccli/tests/test_keycloak_oidc_client.py -v` +Expected: PASS — 3 passed + +**Step 5: Commit** + +```bash +git add ewccli/backends/keycloak/oidc_client.py ewccli/tests/test_keycloak_oidc_client.py +git commit -m "feat: add OIDC client for Keycloak token exchange" +``` + +--- + +### Task 5: Create the portal API client + +**Objective:** Call the EWC portal API with the OIDC access token to obtain OpenStack application credentials. + +**Files:** +- Create: `ewccli/backends/keycloak/portal_client.py` + +**Step 1: Write failing test** + +Create `ewccli/tests/test_keycloak_portal_client.py`: + +```python +"""Tests for the portal API client.""" +import pytest +from unittest.mock import patch, MagicMock + +from ewccli.backends.keycloak.portal_client import PortalClient, PortalCredentials + + +@pytest.fixture +def portal_client(): + return PortalClient( + portal_api_url="https://europeanweather.cloud", + ) + + +@patch("ewccli.backends.keycloak.portal_client.requests.post") +def test_fetch_openstack_credentials(mock_post, portal_client): + mock_response = MagicMock() + mock_response.json.return_value = { + "application_credential_id": "app-id-123", + "application_credential_secret": "app-secret-456", + "auth_url": "https://keystone.api.r1.cloud.eumetsat.int", + "federee": "EUMETSAT", + "region": "ECIS-R1", + "tenant_name": "my-tenant", + } + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + creds = portal_client.fetch_openstack_credentials( + access_token="oidc-token-789", + ) + + assert isinstance(creds, PortalCredentials) + assert creds.application_credential_id == "app-id-123" + assert creds.application_credential_secret == "app-secret-456" + assert creds.auth_url == "https://keystone.api.r1.cloud.eumetsat.int" + assert creds.federee == "EUMETSAT" + assert creds.region == "ECIS-R1" + assert creds.tenant_name == "my-tenant" + + call_args = mock_post.call_args + assert "Bearer oidc-token-789" in call_args[1]["headers"]["Authorization"] + + +@patch("ewccli.backends.keycloak.portal_client.requests.post") +def test_fetch_openstack_credentials_with_federee_region(mock_post, portal_client): + mock_response = MagicMock() + mock_response.json.return_value = { + "application_credential_id": "id", + "application_credential_secret": "secret", + "auth_url": "https://keystone.example.com", + "federee": "ECMWF", + "region": "CC1", + "tenant_name": "tenant", + } + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + portal_client.fetch_openstack_credentials( + access_token="token", + federee="ECMWF", + region="CC1", + ) + + call_args = mock_post.call_args + assert call_args[1]["json"]["federee"] == "ECMWF" + assert call_args[1]["json"]["region"] == "CC1" +``` + +**Step 2: Run test to verify failure** + +Run: `pytest ewccli/tests/test_keycloak_portal_client.py -v` +Expected: FAIL — `ModuleNotFoundError` + +**Step 3: Implement portal_client.py** + +```python +"""Portal API client — exchanges OIDC tokens for OpenStack application credentials.""" + +from dataclasses import dataclass +from typing import Optional + +import requests + +from ewccli.logger import get_logger + +_LOGGER = get_logger(__name__) + + +@dataclass +class PortalCredentials: + """OpenStack application credentials returned by the EWC portal.""" + + application_credential_id: str + application_credential_secret: str + auth_url: str + federee: Optional[str] = None + region: Optional[str] = None + tenant_name: Optional[str] = None + + +class PortalClient: + """Calls the EWC portal API to obtain OpenStack application credentials.""" + + def __init__(self, portal_api_url: str): + self._portal_api_url = portal_api_url.rstrip("/") + + @property + def credentials_endpoint(self) -> str: + return f"{self._portal_api_url}/api/v1/credentials/openstack" + + def fetch_openstack_credentials( + self, + access_token: str, + federee: Optional[str] = None, + region: Optional[str] = None, + ) -> PortalCredentials: + """Fetch OpenStack application credentials from the portal API. + + Args: + access_token: The OIDC access token from Keycloak. + federee: Optional federee to request credentials for. + region: Optional region to request credentials for. + + Returns: + PortalCredentials dataclass with the app cred id/secret and auth_url. + + Raises: + requests.HTTPError: If the API call fails. + """ + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + body: dict = {} + if federee: + body["federee"] = federee + if region: + body["region"] = region + + _LOGGER.info("Fetching OpenStack credentials from EWC portal") + response = requests.post( + self.credentials_endpoint, + headers=headers, + json=body if body else None, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + return PortalCredentials( + application_credential_id=data["application_credential_id"], + application_credential_secret=data["application_credential_secret"], + auth_url=data["auth_url"], + federee=data.get("federee"), + region=data.get("region"), + tenant_name=data.get("tenant_name"), + ) +``` + +**Step 4: Run test to verify pass** + +Run: `pytest ewccli/tests/test_keycloak_portal_client.py -v` +Expected: PASS — 2 passed + +**Step 5: Commit** + +```bash +git add ewccli/backends/keycloak/portal_client.py ewccli/tests/test_keycloak_portal_client.py +git commit -m "feat: add portal API client for OpenStack credential exchange" +``` + +--- + +### Task 6: Create the Keycloak login orchestrator + +**Objective:** Tie together PKCE, callback server, OIDC client, and portal client into a single `keycloak_login()` function that the login command calls. + +**Files:** +- Create: `ewccli/backends/keycloak/keycloak_backend.py` + +**Step 1: Write failing test** + +Create `ewccli/tests/test_keycloak_backend.py`: + +```python +"""Tests for the Keycloak login orchestrator.""" +import pytest +from unittest.mock import patch, MagicMock + +from ewccli.backends.keycloak.keycloak_backend import keycloak_login, KeycloakLoginResult + + +@pytest.fixture +def mock_config(): + config = MagicMock() + config.EWC_CLI_KEYCLOAK_URL = "https://auth.example.com" + config.EWC_CLI_KEYCLOAK_REALM = "ewc" + config.EWC_CLI_KEYCLOAK_CLIENT_ID = "ewccli" + config.EWC_CLI_KEYCLOAK_SCOPE = "openid profile" + config.EWC_CLI_PORTAL_API_URL = "https://portal.example.com" + config.EWC_CLI_OIDC_CALLBACK_TIMEOUT = 10 + return config + + +@patch("ewccli.backends.keycloak.keycloak_backend.webbrowser") +@patch("ewccli.backends.keycloak.keycloak_backend.PortalClient") +@patch("ewccli.backends.keycloak.keycloak_backend.OIDCClient") +@patch("ewccli.backends.keycloak.keycloak_backend.CallbackServer") +def test_keycloak_login_success( + mock_cb_server_cls, + mock_oidc_cls, + mock_portal_cls, + mock_webbrowser, + mock_config, +): + # Callback server + mock_server = MagicMock() + mock_server.port = 12345 + mock_server.redirect_uri = "http://127.0.0.1:12345/callback" + mock_server.wait_for_callback.return_value = ("mycode", "mystate") + mock_server.error = None + mock_cb_server_cls.return_value = mock_server + + # OIDC client + mock_oidc = MagicMock() + mock_oidc.build_authorization_url.return_value = "https://auth.example.com/auth?..." + mock_oidc.exchange_code_for_tokens.return_value = { + "access_token": "access123", + "refresh_token": "refresh456", + "id_token": "id789", + "expires_in": 3600, + } + mock_oidc_cls.return_value = mock_oidc + + # Portal client + mock_portal = MagicMock() + mock_creds = MagicMock() + mock_creds.application_credential_id = "app-id" + mock_creds.application_credential_secret = "app-secret" + mock_creds.auth_url = "https://keystone.example.com" + mock_creds.federee = "EUMETSAT" + mock_creds.region = "ECIS-R1" + mock_creds.tenant_name = "tenant" + mock_portal.fetch_openstack_credentials.return_value = mock_creds + mock_portal_cls.return_value = mock_portal + + result = keycloak_login( + config=mock_config, + open_browser=True, + federee="EUMETSAT", + region="ECIS-R1", + ) + + assert isinstance(result, KeycloakLoginResult) + assert result.application_credential_id == "app-id" + assert result.application_credential_secret == "app-secret" + assert result.auth_url == "https://keystone.example.com" + assert result.access_token == "access123" + assert result.refresh_token == "refresh456" + assert result.federee == "EUMETSAT" + assert result.region == "ECIS-R1" + + # Browser was opened + mock_webbrowser.open.assert_called_once() + + # Callback server was started and stopped + mock_server.start.assert_called_once() + mock_server.stop.assert_called_once() + + +@patch("ewccli.backends.keycloak.keycloak_backend.webbrowser") +@patch("ewccli.backends.keycloak.keycloak_backend.CallbackServer") +def test_keycloak_login_timeout( + mock_cb_server_cls, + mock_webbrowser, + mock_config, +): + mock_server = MagicMock() + mock_server.port = 12345 + mock_server.wait_for_callback.return_value = None # timeout + mock_server.error = None + mock_cb_server_cls.return_value = mock_server + + from click import ClickException + + with pytest.raises(ClickException, match="timed out|timeout"): + keycloak_login( + config=mock_config, + open_browser=False, + ) +``` + +**Step 2: Run test to verify failure** + +Run: `pytest ewccli/tests/test_keycloak_backend.py -v` +Expected: FAIL — `ModuleNotFoundError` + +**Step 3: Implement keycloak_backend.py** + +```python +"""Keycloak login orchestrator — ties PKCE, callback, OIDC, and portal together.""" + +import webbrowser +from dataclasses import dataclass +from typing import Optional + +from click import ClickException +from rich.console import Console + +from ewccli.backends.keycloak.callback_server import CallbackServer +from ewccli.backends.keycloak.oidc_client import OIDCClient +from ewccli.backends.keycloak.pkce import generate_pkce_pair, generate_state +from ewccli.backends.keycloak.portal_client import PortalClient +from ewccli.logger import get_logger + +_LOGGER = get_logger(__name__) +_console = Console() + + +@dataclass +class KeycloakLoginResult: + """Result of a successful Keycloak login.""" + + application_credential_id: str + application_credential_secret: str + auth_url: str + access_token: str + refresh_token: Optional[str] + id_token: Optional[str] + expires_in: int + federee: Optional[str] = None + region: Optional[str] = None + tenant_name: Optional[str] = None + + +def keycloak_login( + config, + open_browser: bool = True, + federee: Optional[str] = None, + region: Optional[str] = None, +) -> KeycloakLoginResult: + """Run the full Keycloak OIDC login flow. + + 1. Start a local callback server + 2. Build the authorization URL (PKCE) + 3. Print URL and optionally open browser + 4. Wait for callback + 5. Exchange code for tokens + 6. Call portal API for OpenStack credentials + + Args: + config: EWCCLIConfiguration instance with Keycloak settings. + open_browser: If True, attempt to open the browser automatically. + federee: Optional federee to pass to the portal API. + region: Optional region to pass to the portal API. + + Returns: + KeycloakLoginResult with app creds and OIDC tokens. + + Raises: + ClickException: On timeout, state mismatch, or API errors. + """ + timeout = config.EWC_CLI_OIDC_CALLBACK_TIMEOUT + + # 1. Generate PKCE pair and state + code_verifier, code_challenge = generate_pkce_pair() + state = generate_state() + + # 2. Start callback server + server = CallbackServer(expected_state=state) + server.start() + _LOGGER.debug(f"Callback server listening on port {server.port}") + + # 3. Build OIDC client and authorization URL + oidc_client = OIDCClient( + keycloak_url=config.EWC_CLI_KEYCLOAK_URL, + realm=config.EWC_CLI_KEYCLOAK_REALM, + client_id=config.EWC_CLI_KEYCLOAK_CLIENT_ID, + scope=config.EWC_CLI_KEYCLOAK_SCOPE, + ) + + auth_url = oidc_client.build_authorization_url( + redirect_uri=server.redirect_uri, + code_challenge=code_challenge, + state=state, + ) + + # 4. Print URL and optionally open browser + _console.print( + "\n[bold cyan]🔑 EWC Keycloak Login[/bold cyan]\n" + "Open the following URL in your browser to authenticate:\n" + ) + _console.print(f"[link={auth_url}]{auth_url}[/link]\n") + + if open_browser: + try: + webbrowser.open(auth_url) + _console.print("[green]🌐 Browser opened automatically.[/green]") + except Exception: + _console.print( + "[yellow]⚠️ Could not open browser automatically. " + "Please copy the URL above manually.[/yellow]" + ) + else: + _console.print( + "[yellow]📋 --no-browser: copy the URL above manually.[/yellow]" + ) + + _console.print(f"\n⏳ Waiting for authentication (timeout: {timeout}s)...") + + # 5. Wait for callback + callback_result = server.wait_for_callback(timeout=timeout) + server.stop() + + if callback_result is None: + if server.error: + raise ClickException( + f"OIDC authentication failed: {server.error}" + ) + raise ClickException( + f"OIDC authentication timed out after {timeout} seconds. " + "Please try again." + ) + + code, received_state = callback_result + + # 6. Exchange code for tokens + try: + tokens = oidc_client.exchange_code_for_tokens( + code=code, + code_verifier=code_verifier, + redirect_uri=f"http://127.0.0.1:{server.port}/callback", + ) + except Exception as e: + raise ClickException( + f"Failed to exchange authorization code for tokens: {e}" + ) + + _console.print("[green]✅ Authentication successful![/green]") + + # 7. Fetch OpenStack credentials from portal + portal_client = PortalClient( + portal_api_url=config.EWC_CLI_PORTAL_API_URL, + ) + + try: + creds = portal_client.fetch_openstack_credentials( + access_token=tokens["access_token"], + federee=federee, + region=region, + ) + except Exception as e: + raise ClickException( + f"Failed to fetch OpenStack credentials from EWC portal: {e}" + ) + + _console.print("[green]✅ OpenStack credentials obtained![/green]") + + return KeycloakLoginResult( + application_credential_id=creds.application_credential_id, + application_credential_secret=creds.application_credential_secret, + auth_url=creds.auth_url, + access_token=tokens["access_token"], + refresh_token=tokens.get("refresh_token"), + id_token=tokens.get("id_token"), + expires_in=tokens.get("expires_in", 3600), + federee=creds.federee, + region=creds.region, + tenant_name=creds.tenant_name, + ) +``` + +**Step 4: Run test to verify pass** + +Run: `pytest ewccli/tests/test_keycloak_backend.py -v` +Expected: PASS — 2 passed + +**Step 5: Commit** + +```bash +git add ewccli/backends/keycloak/keycloak_backend.py ewccli/tests/test_keycloak_backend.py +git commit -m "feat: add Keycloak login orchestrator" +``` + +--- + +### Task 7: Extend profile storage for OIDC tokens + +**Objective:** Add optional `keycloak_*` keys to `save_cli_profile()` and `load_cli_profile()` so OIDC tokens can be persisted for future refresh. + +**Files:** +- Modify: `ewccli/utils.py` — `save_cli_profile()` (line ~104) and `load_cli_profile()` (line ~184) + +**Step 1: Write failing test** + +Add to `ewccli/tests/ewccli_config_test.py`: + +```python +def test_save_and_load_profile_with_oidc_tokens(profile_file_path, ssh_paths): + ssh_private, ssh_public = ssh_paths + + save_cli_profile( + federee="EUMETSAT", + region="ECIS-R1", + tenant_name="TeamA", + ssh_private_key_path_to_save=ssh_private, + ssh_public_key_path_to_save=ssh_public, + application_credential_id="app-id", + application_credential_secret="app-secret", + keycloak_access_token="access123", + keycloak_refresh_token="refresh456", + keycloak_id_token="id789", + keycloak_token_expires_in=3600, + profiles_file_path=str(profile_file_path), + ) + + data = load_cli_profile( + profile="eumetsat-ecis-r1-teama", + profiles_file_path=str(profile_file_path), + ) + + assert data["keycloak_access_token"] == "access123" + assert data["keycloak_refresh_token"] == "refresh456" + assert data["keycloak_id_token"] == "id789" + assert data["keycloak_token_expires_in"] == "3600" + + +def test_load_profile_without_oidc_tokens_returns_none(profile_file_path, ssh_paths): + """Profiles saved without OIDC tokens should load fine with None.""" + ssh_private, ssh_public = ssh_paths + + save_cli_profile( + federee="EUMETSAT", + region="ECIS-R1", + tenant_name="TeamA", + ssh_private_key_path_to_save=ssh_private, + ssh_public_key_path_to_save=ssh_public, + application_credential_id="app-id", + application_credential_secret="app-secret", + profiles_file_path=str(profile_file_path), + ) + + data = load_cli_profile( + profile="eumetsat-ecis-r1-teama", + profiles_file_path=str(profile_file_path), + ) + + assert data.get("keycloak_access_token") is None + assert data.get("keycloak_refresh_token") is None +``` + +**Step 2: Run test to verify failure** + +Run: `pytest ewccli/tests/ewccli_config_test.py -v -k "oidc"` +Expected: FAIL — `TypeError: save_cli_profile() got an unexpected keyword argument 'keycloak_access_token'` + +**Step 3: Modify save_cli_profile()** + +In `ewccli/utils.py`, add new optional parameters to `save_cli_profile()`: + +```python +def save_cli_profile( + federee: str, + region: str, + tenant_name: str, + ssh_private_key_path_to_save: str, + ssh_public_key_path_to_save: str, + profile: Optional[str] = None, + token: Optional[str] = None, + application_credential_id: Optional[str] = None, + application_credential_secret: Optional[str] = None, + keycloak_access_token: Optional[str] = None, + keycloak_refresh_token: Optional[str] = None, + keycloak_id_token: Optional[str] = None, + keycloak_token_expires_in: Optional[int] = None, + profiles_file_path: Path = ewc_hub_config.EWC_CLI_PROFILES_PATH, +) -> None: +``` + +In the "Sensitive" section of the function body (after the `application_credential_secret` block, around line 177), add: + +```python + if keycloak_access_token: + cfg[resolved_profile]["keycloak_access_token"] = keycloak_access_token + + if keycloak_refresh_token: + cfg[resolved_profile]["keycloak_refresh_token"] = keycloak_refresh_token + + if keycloak_id_token: + cfg[resolved_profile]["keycloak_id_token"] = keycloak_id_token + + if keycloak_token_expires_in: + cfg[resolved_profile]["keycloak_token_expires_in"] = str( + keycloak_token_expires_in + ) +``` + +**Step 4: Modify load_cli_profile()** + +In the return dict of `load_cli_profile()` (around line 362), add: + +```python + "keycloak_access_token": section.get("keycloak_access_token"), + "keycloak_refresh_token": section.get("keycloak_refresh_token"), + "keycloak_id_token": section.get("keycloak_id_token"), + "keycloak_token_expires_in": section.get("keycloak_token_expires_in"), +``` + +**Step 5: Run test to verify pass** + +Run: `pytest ewccli/tests/ewccli_config_test.py -v` +Expected: PASS — all tests pass (including existing ones — backward compatible) + +**Step 6: Commit** + +```bash +git add ewccli/utils.py ewccli/tests/ewccli_config_test.py +git commit -m "feat: extend profile storage for OIDC tokens" +``` + +--- + +### Task 8: Add `--keycloak` flag to login command options + +**Objective:** Add the `--keycloak` and `--no-browser` CLI options to the `init_options` decorator. + +**Files:** +- Modify: `ewccli/commands/login_command.py` — `init_options()` function (line ~126) + +**Step 1: Add options to init_options** + +In `init_options()`, add these options before the `return func` (after the `--profile` option, around line 219): + +```python + func = click.option( + "--keycloak", + is_flag=True, + default=False, + envvar="EWC_CLI_KEYCLOAK_LOGIN", + help=( + "Login via Keycloak OIDC (browser-based). " + "Opens a browser for authentication and fetches " + "OpenStack credentials automatically from the EWC portal. " + "Can also be set via EWC_CLI_KEYCLOAK_LOGIN=1." + ), + )(func) + func = click.option( + "--no-browser", + is_flag=True, + default=False, + help=( + "Print the login URL instead of opening a browser. " + "Useful for SSH sessions or headless environments." + ), + )(func) +``` + +**Step 2: Update the init() command in ewccli.py** + +In `ewccli/ewccli.py`, update the `init()` function signature to accept the new params: + +```python +@cli.command(name="login", help="Initialize configuration for EWC CLI.") +@init_options +def init( + application_credential_id: str, + application_credential_secret: str, + ssh_public_key_path: str, + ssh_private_key_path: str, + tenant_name: str, + federee: str, + region: str, + profile: Optional[str] = None, + keycloak: bool = False, + no_browser: bool = False, +): + """Login command.""" + init_command( + application_credential_id=application_credential_id, + application_credential_secret=application_credential_secret, + ssh_public_key_path=ssh_public_key_path, + ssh_private_key_path=ssh_private_key_path, + tenant_name=tenant_name, + federee=federee, + profile=profile, + region=region, + keycloak=keycloak, + no_browser=no_browser, + ) +``` + +**Step 3: Verify the CLI renders the new options** + +Run: `ewc login --help` +Expected: shows `--keycloak` and `--no-browser` in help output. + +**Step 4: Commit** + +```bash +git add ewccli/commands/login_command.py ewccli/ewccli.py +git commit -m "feat: add --keycloak and --no-browser flags to ewc login" +``` + +--- + +### Task 9: Implement the Keycloak login path in init_command() + +**Objective:** When `--keycloak` is set, run the OIDC flow instead of prompting for app creds. Integrate the result into the existing profile-saving logic. + +**Files:** +- Modify: `ewccli/commands/login_command.py` — `init_command()` function (line ~386) + +**Step 1: Update init_command() signature** + +Change the function signature to accept the new params: + +```python +def init_command( + application_credential_id: str, + application_credential_secret: str, + ssh_public_key_path: str, + ssh_private_key_path: str, + tenant_name: str, + federee: str, + region: str, + profile: str = None, + keycloak: bool = False, + no_browser: bool = False, +): + """EWC CLI Login.""" +``` + +**Step 2: Add the Keycloak branch** + +After the `resolved_profile` computation (line ~422) and the profile-exists check, but before the SSH key handling (line ~444), insert the Keycloak login branch: + +```python + # --- Keycloak OIDC login path --- + keycloak_access_token = None + keycloak_refresh_token = None + keycloak_id_token = None + keycloak_token_expires_in = None + + if keycloak: + from ewccli.backends.keycloak.keycloak_backend import keycloak_login + + kc_result = keycloak_login( + config=ewc_hub_config, + open_browser=not no_browser, + federee=federee, + region=region, + ) + + # Use credentials from the portal + application_credential_id = kc_result.application_credential_id + application_credential_secret = kc_result.application_credential_secret + + # If the portal returned federee/region/tenant_name, use them + if kc_result.federee: + federee = kc_result.federee + if kc_result.region: + region = kc_result.region + if kc_result.tenant_name: + tenant_name = kc_result.tenant_name + + # Store OIDC tokens for future refresh + keycloak_access_token = kc_result.access_token + keycloak_refresh_token = kc_result.refresh_token + keycloak_id_token = kc_result.id_token + keycloak_token_expires_in = kc_result.expires_in + + # Skip the openstack_config_available / manual credential prompts below + # since we have the credentials already. + elif not federee: +``` + +Then the existing `elif not federee:` / `if not region:` interactive selection blocks follow naturally. + +The existing block at lines 451-478 (cloud.yaml check and manual credential prompt) should be wrapped so it only runs when `keycloak` is False: + +```python + if not keycloak: + if openstack_config_available(): + console.print( + "🔑 [bold green]Openstack cloud.yaml found at ~/.config/openstack/clouds.yaml[/bold green]" + " – skipping Openstack ID and secret requirements." + ) + application_credential_id = "" + application_credential_secret = "" + + elif not application_credential_id or not application_credential_secret: + if not application_credential_id: + application_credential_id = ( + application_credential_id + or os.getenv("OS_APPLICATION_CREDENTIAL_ID") + or click.prompt( + "Enter OpenStack Application Credential ID", hide_input=True + ) + ) + + if not application_credential_secret: + application_credential_secret = ( + application_credential_secret + or os.getenv("OS_APPLICATION_CREDENTIAL_SECRET") + or click.prompt( + "Enter OpenStack Application Credential Secret", hide_input=True + ) + ) +``` + +**Step 3: Pass OIDC tokens to save_cli_profile** + +At the bottom of `init_command()`, update both `save_default_login_profile()` and `save_cli_profile()` calls to include the OIDC token params: + +```python + save_default_login_profile( + federee=federee, + region=region, + tenant_name=tenant_name, + ssh_private_key_path_to_save=ssh_private_key_path_to_save, + ssh_public_key_path_to_save=ssh_public_key_path_to_save, + application_credential_id=application_credential_id, + application_credential_secret=application_credential_secret, + ) + + # Save config + save_cli_profile( + federee=federee, + region=region, + tenant_name=tenant_name, + ssh_private_key_path_to_save=ssh_private_key_path_to_save, + ssh_public_key_path_to_save=ssh_public_key_path_to_save, + profile=profile, + application_credential_id=application_credential_id, + application_credential_secret=application_credential_secret, + keycloak_access_token=keycloak_access_token, + keycloak_refresh_token=keycloak_refresh_token, + keycloak_id_token=keycloak_id_token, + keycloak_token_expires_in=keycloak_token_expires_in, + ) +``` + +Note: `save_default_login_profile()` does not need the OIDC tokens — it's just a fallback default. Only the explicit `save_cli_profile()` call gets the tokens. + +**Step 4: Verify the command works** + +Run: `ewc login --help` +Expected: help text shows `--keycloak` flag. + +Run: `ewc login --keycloak --dry-run` (if dry-run is available) or just verify it doesn't crash on import. + +**Step 5: Commit** + +```bash +git add ewccli/commands/login_command.py +git commit -m "feat: implement Keycloak OIDC login path in ewc login" +``` + +--- + +### Task 10: Handle missing tenant_name in Keycloak flow + +**Objective:** The current `init_command` requires `tenant_name` (it's `prompt=True, required=True` in `init_options`). When using `--keycloak`, the tenant_name may come from the portal API instead. We need to make `tenant_name` optional when `--keycloak` is used. + +**Files:** +- Modify: `ewccli/commands/login_command.py` — `init_options()` `--tenant-name` option (line ~128) + +**Step 1: Make tenant_name not prompted when keycloak is used** + +Change the `--tenant-name` option from `prompt=True, required=True` to `required=False` (the prompt will be handled in `init_command()` when not using keycloak): + +```python + func = click.option( + "--tenant-name", + envvar="EWC_CLI_LOGIN_TENANT_NAME", + required=False, + callback=validate_tenant_name, + help=( + "Name of your tenancy in EWC, used to identify cloud configurations.\n" + "Must follow the format: 'part1-part2-part3' (e.g. 'demo-user-eu'), " + "where each part is alphanumeric and separated by dashes.\n" + "Required when not using --keycloak. " + "Can also be set via the EWC_CLI_LOGIN_TENANT_NAME environment variable." + ), + )(func) +``` + +**Step 2: Add prompt in init_command when tenant_name is missing and not keycloak** + +In `init_command()`, after the keycloak branch, add: + +```python + if not keycloak and not tenant_name: + tenant_name = click.prompt("Tenant name") +``` + +And move the `validate_tenant_name` validation to after this prompt (or rely on the callback which already ran for CLI-provided values; for prompted values, call it manually): + +```python + if not keycloak and tenant_name: + # Re-validate since it may have been prompted + import re + pattern = r"^[a-zA-Z0-9]+-[a-zA-Z0-9]+-[a-zA-Z0-9]+$" + if not re.match(pattern, tenant_name): + raise click.BadParameter( + "Config name must be exactly 3 alphanumeric parts separated by dashes." + ) +``` + +**Step 3: Verify** + +Run: `ewc login --help` +Expected: `--tenant-name` no longer shows as required. + +**Step 4: Commit** + +```bash +git add ewccli/commands/login_command.py +git commit -m "fix: make tenant_name optional when using --keycloak login" +``` + +--- + +### Task 11: Update the README documentation + +**Objective:** Document the new `--keycloak` login flow in the README. + +**Files:** +- Modify: `README.md` — "Login to prepare the environment" section (line ~178) + +**Step 1: Add Keycloak login documentation** + +After the existing `ewc login` section, add: + +```markdown +### Login with Keycloak (OIDC) + +Instead of manually entering OpenStack application credentials, you can authenticate via Keycloak: + +```bash +ewc login --keycloak +``` + +This will: +1. Open a browser window for Keycloak authentication +2. After successful login, fetch OpenStack credentials from the EWC portal +3. Save everything to your profile + +If you're on a headless machine or SSH session, use `--no-browser` to print the URL instead: + +```bash +ewc login --keycloak --no-browser +``` + +You can still combine with other flags: + +```bash +ewc login --keycloak --federee EUMETSAT --region ECIS-R1 +``` + +**Configuration:** + +The Keycloak settings can be overridden via environment variables: + +| Variable | Default | Description | +|---|---|---| +| `EWC_CLI_KEYCLOAK_URL` | `https://auth.europeanweather.cloud` | Keycloak server URL | +| `EWC_CLI_KEYCLOAK_REALM` | `ewc` | Keycloak realm | +| `EWC_CLI_KEYCLOAK_CLIENT_ID` | `ewccli` | OIDC client ID | +| `EWC_CLI_PORTAL_API_URL` | `https://europeanweather.cloud` | EWC portal API URL | +| `EWC_CLI_OIDC_CALLBACK_TIMEOUT` | `300` | Callback wait timeout (seconds) | +``` + +**Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: document Keycloak OIDC login" +``` + +--- + +### Task 12: Run full test suite and fix any regressions + +**Objective:** Ensure all existing tests still pass and new tests are green. + +**Step 1: Run all tests** + +```bash +cd /home/kamil/projects/ewccli +pytest ewccli/tests/ -v +``` + +**Step 2: Fix any failures** + +If existing tests fail due to the `init_command` signature change (e.g., tests that call `init_command` directly), update the test calls to include `keycloak=False, no_browser=False`. + +**Step 3: Verify CLI still works** + +```bash +ewc login --help +ewc version +``` + +**Step 4: Final commit if fixes were needed** + +```bash +git add -A +git commit -m "test: fix regressions from Keycloak login integration" +``` + +--- + +## File Summary + +### New files (7) + +| File | Purpose | +|---|---| +| `ewccli/backends/keycloak/__init__.py` | Package init | +| `ewccli/backends/keycloak/pkce.py` | PKCE code_verifier/code_challenge generation | +| `ewccli/backends/keycloak/callback_server.py` | Loopback HTTP server for OIDC callback | +| `ewccli/backends/keycloak/oidc_client.py` | Auth URL builder + token exchange/refresh | +| `ewccli/backends/keycloak/portal_client.py` | Portal API client → app creds | +| `ewccli/backends/keycloak/keycloak_backend.py` | Orchestrator (`keycloak_login()`) | +| `ewccli/tests/test_keycloak_*.py` (4 files) | Tests for each module | + +### Modified files (4) + +| File | Changes | +|---|---| +| `ewccli/configuration.py` | +6 config constants for Keycloak/OIDC | +| `ewccli/utils.py` | `save_cli_profile` / `load_cli_profile` gain optional `keycloak_*` params | +| `ewccli/commands/login_command.py` | `init_options` gets `--keycloak`/`--no-browser`; `init_command` gets OIDC branch; `--tenant-name` made optional | +| `ewccli/ewccli.py` | `init()` signature updated with new params | +| `README.md` | Documentation for `--keycloak` | + +### Unchanged files (all downstream) + +- `ewccli/backends/openstack/backend_ostack.py` — `OpenstackBackend` and `connect()` untouched +- `ewccli/commands/commons_infra.py` — `connect_to_openstack_backend()` untouched +- `ewccli/commands/infra_command.py` — reads profile as before +- `ewccli/commands/hub/hub_command.py` — reads profile as before + +--- + +## Token Refresh Strategy + +### Industry best practices applied + +- **Short-lived access tokens** (5 min default): if stolen, the abuse window is small +- **Longer-lived refresh tokens** (7 days max): user authenticates once, CLI silently refreshes +- **Refresh token rotation** (RFC 9700): each refresh returns a NEW refresh_token and invalidates the old one. The CLI must immediately overwrite the stored refresh_token. This detects theft — if someone steals and uses a refresh_token, the legitimate CLI's next refresh fails. +- **Proactive refresh**: check expiry before use, not after a 401. Refresh if the access token is expired or about to expire (within a 60s skew window). + +### Token storage fields in the profile + +| Key | Type | Description | +|---|---|---| +| `keycloak_access_token` | str | Current OIDC access token | +| `keycloak_refresh_token` | str | Current refresh token (rotated on each refresh) | +| `keycloak_id_token` | str | ID token (JWT with user claims) | +| `keycloak_token_expires_at` | str (ISO 8601) | Absolute expiry timestamp of the access token. Stored as ISO 8601 UTC so no "now + expires_in" recalculation is needed. | + +### Refresh flow + +``` +get_valid_access_token(profile): + 1. Parse keycloak_token_expires_at from profile + 2. If not expired (with 60s skew): return stored access_token + 3. If expired and no refresh_token: raise ClickException("Session expired. Run: ewc login --keycloak") + 4. If expired and refresh_token exists: + a. Call oidc_client.refresh_tokens(refresh_token) + b. Receive new access_token + new refresh_token (rotation) + c. Calculate new expires_at = now + expires_in + d. Update profile INI with new tokens + expires_at + e. Return new access_token + 5. If refresh fails (HTTP 400 invalid_grant): + raise ClickException("Session expired. Run: ewc login --keycloak") +``` + +### Recommended Keycloak realm settings + +- Access token lifespan: 5 min +- Client: public (PKCE, no client secret) +- Refresh token: enabled +- Revoke Refresh Token: ON (rotation) +- Refresh Token Max Reuse: 0 (single-use) +- SSO Session Idle Timeout: 30 min +- SSO Session Max Lifespan: 7 days + +This means: user authenticates once, the CLI silently refreshes for up to 7 days, then they see "Session expired, run ewc login --keycloak". + +--- + +## Risks and Tradeoffs + +1. **Portal API contract is assumed.** The endpoint path (`/api/v1/credentials/openstack`), request shape, and response shape are all assumed. When the real API is known, only `portal_client.py` needs updating — the rest of the flow is contract-agnostic. + +2. **OIDC tokens stored in plaintext INI.** The `keycloak_access_token` and `keycloak_refresh_token` are stored in `~/.ewccli/profiles` in plaintext, same as the existing `application_credential_secret`. This is consistent but not ideal. A future enhancement could use the OS keyring. + +3. **Loopback callback server.** The callback server binds to `127.0.0.1` on a random port. If the user is behind a strict firewall or in a container without loopback, this won't work. The `--no-browser` flag helps with SSH sessions (user copies URL to local browser), but the callback still needs to reach the machine where the CLI is running. For pure headless/CI scenarios, a `--device-code` flow (RFC 8628) would be better — out of scope for this plan. + +4. **`tenant_name` validation.** The existing `validate_tenant_name` callback enforces a 3-part dash-separated pattern. When the portal API returns a `tenant_name`, it may not match this pattern. The validation is skipped in the keycloak path (the callback only runs on CLI-provided values, and the keycloak branch sets tenant_name after validation). If the portal's tenant_name format differs, it's stored as-is. + +5. **Multiple federees.** A user may have projects on both EUMETSAT and ECMWF. The current `ewc login --keycloak` flow creates one profile per invocation. To support multiple federees, the user runs `ewc login --keycloak --federee ECMWF --profile ecmwf-profile` separately. The portal API would need to support the `federee`/`region` query params as described in the contract. + +6. **Refresh token rotation atomicity.** If the CLI crashes between receiving a new refresh_token and writing it to the profile INI, the old refresh_token is already invalidated server-side. The user must re-authenticate. This is an accepted tradeoff of rotation — it's a rare edge case and the security benefit outweighs it. + +--- + +## Open Questions (to resolve during implementation) + +1. Does the portal API return `auth_url`, or should the CLI derive it from the `EWC_CLI_SITE_MAP` using the returned `federee`/`region`? If the portal returns it, we use it directly (more flexible). If not, we fall back to `EWC_CLI_SITE_MAP[federee][region]`. + +2. Should `--keycloak` be the default in the future (i.e., `ewc login` with no flags triggers OIDC)? For now it's opt-in via the flag. Consider deprecating the manual flow once the portal API is stable. + +3. Should the CLI create the application credential (via OpenStack API) if the portal only returns a project-scoped token? This would add a step: after OIDC login, use the token to authenticate to Keystone, then create an app cred via `openstack.identity.v3.application_credential`. This is more complex but removes the portal dependency. Out of scope for this plan. diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/ewccli.iml b/.idea/ewccli.iml new file mode 100644 index 0000000..7b28c72 --- /dev/null +++ b/.idea/ewccli.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..4b151ab --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..fb7ab4d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..8306744 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/ewccli/backends/keycloak/keycloak_backend.py b/ewccli/backends/keycloak/keycloak_backend.py index 1c770b1..914fd85 100644 --- a/ewccli/backends/keycloak/keycloak_backend.py +++ b/ewccli/backends/keycloak/keycloak_backend.py @@ -1,5 +1,6 @@ """Keycloak login orchestrator — ties PKCE, callback, OIDC, and portal together.""" +import subprocess import webbrowser from dataclasses import dataclass from typing import Optional @@ -95,13 +96,21 @@ def keycloak_login( if open_browser: try: - webbrowser.open(auth_url) + subprocess.Popen( + ["xdg-open", auth_url], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) _console.print("[green]Browser opened automatically.[/green]") except Exception: - _console.print( - "[yellow]Could not open browser automatically. " - "Please copy the URL above manually.[/yellow]" - ) + try: + webbrowser.open(auth_url) + _console.print("[green]Browser opened automatically.[/green]") + except Exception: + _console.print( + "[yellow]Could not open browser automatically. " + "Please copy the URL above manually.[/yellow]" + ) else: _console.print( "[yellow]--no-browser: copy the URL above manually.[/yellow]" diff --git a/ewccli/commands/commons_infra.py b/ewccli/commands/commons_infra.py index 524aa44..ebc4dda 100644 --- a/ewccli/commands/commons_infra.py +++ b/ewccli/commands/commons_infra.py @@ -34,9 +34,9 @@ def check_user_ssh_keys( - ssh_public_key_path: Optional[str] = None, - ssh_private_key_path: Optional[str] = None, - dry_run: bool = False + ssh_public_key_path: Optional[str] = None, + ssh_private_key_path: Optional[str] = None, + dry_run: bool = False ): """Check if SSH keys are compatible or missing.""" if dry_run: @@ -72,13 +72,13 @@ def check_user_ssh_keys( def check_server_conflict_with_inputs( - server_info: dict, - server_info_image: Optional[str] = None, - image_name: Optional[str] = None, - keypair_name: Optional[str] = None, - flavour_name: Optional[str] = None, - networks: Optional[tuple] = None, - security_groups: Optional[tuple] = None, + server_info: dict, + server_info_image: Optional[str] = None, + image_name: Optional[str] = None, + keypair_name: Optional[str] = None, + flavour_name: Optional[str] = None, + networks: Optional[tuple] = None, + security_groups: Optional[tuple] = None, ): """Check if user-provided values conflict with an existing server.""" if not server_info: @@ -138,11 +138,11 @@ def _get_security_groups_string(server_info): def show_server_input_requested_summary( - security_groups: tuple, - networks: tuple, - image_name: Optional[str] = None, - flavour_name: Optional[str] = None, - keypair_name: Optional[str] = None, + security_groups: tuple, + networks: tuple, + image_name: Optional[str] = None, + flavour_name: Optional[str] = None, + keypair_name: Optional[str] = None, ): """Print table with inputs for the server.""" table = Table( @@ -219,12 +219,12 @@ def check_ssh_keys_exist(ssh_public_key_path: Path, ssh_private_key_path: Path) if missing_msgs: panel_content = ( - "\n".join(missing_msgs) - + "\n\n" - + "[bold yellow]Tip:[/bold yellow] You can run ewc login and create them.\n" - + "[bold yellow]Tip:[/bold yellow] You can specify custom paths with:\n" - + '[green]export EWC_CLI_SSH_PRIVATE_KEY_PATH="/path/to/id_rsa"[/green]\n' - + '[green]export EWC_CLI_SSH_PUBLIC_KEY_PATH="/path/to/id_rsa.pub"[/green]' + "\n".join(missing_msgs) + + "\n\n" + + "[bold yellow]Tip:[/bold yellow] You can run ewc login and create them.\n" + + "[bold yellow]Tip:[/bold yellow] You can specify custom paths with:\n" + + '[green]export EWC_CLI_SSH_PRIVATE_KEY_PATH="/path/to/id_rsa"[/green]\n' + + '[green]export EWC_CLI_SSH_PUBLIC_KEY_PATH="/path/to/id_rsa.pub"[/green]' ) console.print( @@ -238,9 +238,9 @@ def check_ssh_keys_exist(ssh_public_key_path: Path, ssh_private_key_path: Path) def normalize_os_image( - image_name: str, - federee: str, - region: str + image_name: str, + federee: str, + region: str ) -> tuple[str | None, bool]: """ Normalize OS image names provided. @@ -314,13 +314,13 @@ def normalize_os_image( def resolve_image_and_flavor( - conn: connection.Connection, - openstack_backend: OpenstackBackend, - federee: str, - region: str, - flavour_name: Optional[str] = None, - image_name: Optional[str] = None, - is_gpu: bool = False, + conn: connection.Connection, + openstack_backend: OpenstackBackend, + federee: str, + region: str, + flavour_name: Optional[str] = None, + image_name: Optional[str] = None, + is_gpu: bool = False, ) -> Tuple[int, str, Dict[str, str]]: """ Resolve both the image and flavor for the given federee. @@ -390,8 +390,8 @@ def resolve_image_and_flavor( # Now check the image provided and verify is supported. if not normalized_image_name: - - total_images = ewc_hub_config.EWC_CLI_CPU_IMAGES + [ewc_hub_config.EWC_CLI_GPU_IMAGES_SITE_MAP[federee][region]] + total_images = ewc_hub_config.EWC_CLI_CPU_IMAGES + [ + ewc_hub_config.EWC_CLI_GPU_IMAGES_SITE_MAP[federee][region]] error_message = ( f"❌ Unsupported OS image for the EWC CLI: {image_name}\n\n" f"🖥️ EWC Supported images (short names): [bold green]{', '.join(total_images)}[/bold green]\n" @@ -410,7 +410,8 @@ def resolve_image_and_flavor( ) # if users use long names, let's check if they are using the latest known image and give them a warning in case. - if image_name not in [ewc_hub_config.EWC_CLI_GPU_IMAGES_SITE_MAP[federee][region]] and not is_short_name and latest_image: + if image_name not in [ + ewc_hub_config.EWC_CLI_GPU_IMAGES_SITE_MAP[federee][region]] and not is_short_name and latest_image: if latest_image.name != image_name: _LOGGER.warning( f"You are not using latest image for {image_name}." @@ -449,8 +450,8 @@ def resolve_image_and_flavor( def resolve_machine_ip( - federee: str, - server_info: dict, + federee: str, + server_info: dict, ) -> Tuple[int, str, Optional[Dict[str, Optional[str]]]]: """ Resolve the internal and external IPs of a machine. @@ -520,9 +521,9 @@ def resolve_machine_ip( def get_deployed_server_info( - federee: str, - server_info: dict, - image_name: Optional[str] = None, + federee: str, + server_info: dict, + image_name: Optional[str] = None, ): """Get deployed server info.""" _LOGGER.debug(server_info) @@ -571,7 +572,7 @@ def get_deployed_server_info( def list_server_details( - vm_info: dict, + vm_info: dict, ): """Print detailed info of a single server in a two-column table.""" console = Console() @@ -606,17 +607,17 @@ def list_server_details( def pre_deploy_server_setup( - openstack_backend: OpenstackBackend, - openstack_api: connection.Connection, - federee: str, - region: str, - server_inputs: dict, - ssh_public_key_path: str, - ssh_private_key_path: str, - ssh_private_encoded: Optional[str] = None, - ssh_public_encoded: Optional[str] = None, - dry_run: bool = False, - force: bool = False, + openstack_backend: OpenstackBackend, + openstack_api: connection.Connection, + federee: str, + region: str, + server_inputs: dict, + ssh_public_key_path: str, + ssh_private_key_path: str, + ssh_private_encoded: Optional[str] = None, + ssh_public_encoded: Optional[str] = None, + dry_run: bool = False, + force: bool = False, ): """Pre deploy server setup steps: @@ -786,9 +787,9 @@ def pre_deploy_server_setup( def identify_server_reconfiguration( - openstack_api: connection.Connection, - server_inputs: dict, - pre_deploy_server_outputs: dict + openstack_api: connection.Connection, + server_inputs: dict, + pre_deploy_server_outputs: dict ): """Identify resources to be reconfigured.""" outputs: dict[str, Optional[str]] = {} @@ -813,8 +814,8 @@ def identify_server_reconfiguration( if existing_server_info: if not ( - existing_server_info.metadata.get("deployed") - and existing_server_info.metadata.get("deployed") == "ewccli" + existing_server_info.metadata.get("deployed") + and existing_server_info.metadata.get("deployed") == "ewccli" ): return ( 1, @@ -858,14 +859,14 @@ def identify_server_reconfiguration( def deploy_server( - openstack_backend: OpenstackBackend, - openstack_api: connection.Connection, - federee: str, - server_inputs: dict, - pre_deploy_server_outputs: dict, - boot_from_volume: bool = False, - dry_run: bool = False, - force: bool = False, + openstack_backend: OpenstackBackend, + openstack_api: connection.Connection, + federee: str, + server_inputs: dict, + pre_deploy_server_outputs: dict, + boot_from_volume: bool = False, + dry_run: bool = False, + force: bool = False, ): """Deploy Server in Openstack.""" outputs: dict[str, Optional[str]] = {} @@ -958,12 +959,12 @@ def deploy_server( def post_deploy_server_setup( - openstack_backend: OpenstackBackend, - openstack_api: connection.Connection, - federee: str, - server_inputs: dict, - server_info: dict, - dry_run: bool = False, + openstack_backend: OpenstackBackend, + openstack_api: connection.Connection, + federee: str, + server_inputs: dict, + server_info: dict, + dry_run: bool = False, ): """Post deploy server setup steps: @@ -1047,17 +1048,17 @@ def post_deploy_server_setup( def create_server_command( - openstack_backend: OpenstackBackend, - openstack_api: connection.Connection, - federee: str, - region: str, - server_inputs: dict, - ssh_public_key_path: str, - ssh_private_key_path: str, - ssh_private_encoded: Optional[str] = None, - ssh_public_encoded: Optional[str] = None, - dry_run: bool = False, - force: bool = False, + openstack_backend: OpenstackBackend, + openstack_api: connection.Connection, + federee: str, + region: str, + server_inputs: dict, + ssh_public_key_path: str, + ssh_private_key_path: str, + ssh_private_encoded: Optional[str] = None, + ssh_public_encoded: Optional[str] = None, + dry_run: bool = False, + force: bool = False, ): """Create Server command.""" #### PRE DEPLOY SERVER ACTION @@ -1072,7 +1073,7 @@ def create_server_command( ssh_public_key_path=ssh_public_key_path, ssh_private_key_path=ssh_private_key_path, dry_run=dry_run, - force=force, + force=force, ) boot_from_volume = False @@ -1097,7 +1098,7 @@ def create_server_command( identify_server_reconfiguration( openstack_api=openstack_api, server_inputs=server_inputs, - pre_deploy_server_outputs=pre_deploy_server_outputs + pre_deploy_server_outputs=pre_deploy_server_outputs ) #### DEPLOY SERVER ACTION @@ -1137,3 +1138,24 @@ def create_server_command( } return os_status_code, os_message, outputs + + +def connect_to_openstack_backend( + ctx, + auth_url: Optional[str] = None, + application_credential_id: Optional[str] = None, + application_credential_secret: Optional[str] = None): + + # Step 1: Authenticate and initialize the OpenStack connection + try: + # Step 1: Authenticate and initialize the OpenStack connection + openstack_api = ctx.openstack_backend.connect( + auth_url=auth_url, + application_credential_id=application_credential_id, + application_credential_secret=application_credential_secret, + ) + except Exception as op_error: + raise ClickException( + f"Could not connect to Openstack due to the following error: {op_error}" + ) + return openstack_api diff --git a/ewccli/commands/infra_command.py b/ewccli/commands/infra_command.py index 727403d..0b95ce7 100644 --- a/ewccli/commands/infra_command.py +++ b/ewccli/commands/infra_command.py @@ -27,7 +27,7 @@ from ewccli.commands.commons import openstack_optional_options from ewccli.commands.commons import CommonBackendContext from ewccli.commands.commons import login_options -from ewccli.commands.commons_infra import check_user_ssh_keys +from ewccli.commands.commons_infra import check_user_ssh_keys, connect_to_openstack_backend from ewccli.commands.commons_infra import get_deployed_server_info, list_server_details from ewccli.commands.commons_infra import create_server_command from ewccli.utils import load_cli_profile @@ -164,21 +164,12 @@ def create_cmd( _LOGGER.info(f"The server will be deployed on {federee} side of the EWC.") - ##################################################################################### - # Authenticate to Openstack - ##################################################################################### - - try: - # Step 1: Authenticate and initialize the OpenStack connection - openstack_api = ctx.openstack_backend.connect( - auth_url=auth_url, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, - ) - except Exception as op_error: - raise ClickException( - f"Could not connect to Openstack due to the following error: {op_error}" - ) + openstack_api = connect_to_openstack_backend( + ctx=ctx, + auth_url=auth_url, + application_credential_id=application_credential_id, + application_credential_secret=application_credential_secret + ) server_inputs = { "server_name": server_name, @@ -270,17 +261,12 @@ def show_cmd( """Show Server from Openstack.""" federee = federee or ctx.cli_profile["federee"] - try: - # Step 1: Authenticate and initialize the OpenStack connection - openstack_api = ctx.openstack_backend.connect( - auth_url=auth_url, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, - ) - except Exception as op_error: - raise ClickException( - f"Could not connect to Openstack due to the following error: {op_error}" - ) + openstack_api = connect_to_openstack_backend( + ctx=ctx, + auth_url=auth_url, + application_credential_id=application_credential_id, + application_credential_secret=application_credential_secret + ) try: # Find the server info by name @@ -330,18 +316,12 @@ def list_cmd( """List Servers from Openstack.""" federee = federee or ctx.cli_profile["federee"] - - try: - # Step 1: Authenticate and initialize the OpenStack connection - openstack_api = ctx.openstack_backend.connect( - auth_url=auth_url, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, - ) - except Exception as op_error: - raise ClickException( - f"Could not connect to Openstack due to the following error: {op_error}" - ) + openstack_api = connect_to_openstack_backend( + ctx=ctx, + auth_url=auth_url, + application_credential_id=application_credential_id, + application_credential_secret=application_credential_secret + ) try: servers = ctx.openstack_backend.list_servers( @@ -385,18 +365,13 @@ def delete_cmd( dry_run: bool = False, ): """Delete VM from Openstack.""" - # Step 1: Authenticate and initialize the OpenStack connection - try: - # Step 1: Authenticate and initialize the OpenStack connection - openstack_api = ctx.openstack_backend.connect( - auth_url=auth_url, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, - ) - except Exception as op_error: - raise ClickException( - f"Could not connect to Openstack due to the following error: {op_error}" - ) + + openstack_api = connect_to_openstack_backend( + ctx=ctx, + auth_url=auth_url, + application_credential_id=application_credential_id, + application_credential_secret=application_credential_secret + ) server_name = os.getenv("EWC_CLI_OS_SERVER_NAME") or server_name diff --git a/ewccli/commands/login_command.py b/ewccli/commands/login_command.py index e4c8d07..61d862f 100644 --- a/ewccli/commands/login_command.py +++ b/ewccli/commands/login_command.py @@ -99,6 +99,8 @@ def openstack_config_available(): def validate_tenant_name(ctx, param, value): """Validate tenant name.""" + if not value: + return value pattern = r"^[a-zA-Z0-9]+-[a-zA-Z0-9]+-[a-zA-Z0-9]+$" if not re.match(pattern, value): raise click.BadParameter( @@ -450,7 +452,12 @@ def init_command( f"Allowed: {', '.join(allowed_regions)}" ) - resolved_profile = _resolve_profile(profile, federee, region, tenant_name) + # When using keycloak without explicit profile/federee/region, defer + # profile resolution until after the OIDC flow (which may fill them in). + if keycloak and not profile and not (federee and region and tenant_name): + resolved_profile = None + else: + resolved_profile = _resolve_profile(profile, federee, region, tenant_name) profiles_file_path = ewc_hub_config.EWC_CLI_PROFILES_PATH cfg = ConfigParser() @@ -513,6 +520,31 @@ def init_command( # Re-resolve profile now that keycloak may have filled in # federee/region/tenant_name. + if keycloak and not profile and not (federee and region and tenant_name): + # Still missing values — prompt interactively for the ones we don't have + if not federee: + federee = select_federee() + if not federee: + console.print("No federee selection made. Exiting.") + return + console.print(f"Considering federee: {federee}") + + if not region: + region = select_region(federee=federee) + if not region: + console.print("No region selection made. Exiting.") + return + + if not tenant_name: + tenant_name = click.prompt("Tenant name") + + allowed_regions = ewc_hub_config.allowed_regions(federee) + if region not in allowed_regions: + raise click.BadParameter( + f"Region '{region}' is not valid for federee '{federee}'. " + f"Allowed: {', '.join(allowed_regions)}" + ) + resolved_profile = _resolve_profile(profile, federee, region, tenant_name) ssh_private_key_path_to_save, ssh_public_key_path_to_save = check_and_generate_ssh_keys( diff --git a/ewccli/tests/test_keycloak_backend.py b/ewccli/tests/test_keycloak_backend.py index 67226d2..e51243a 100644 --- a/ewccli/tests/test_keycloak_backend.py +++ b/ewccli/tests/test_keycloak_backend.py @@ -30,6 +30,7 @@ def mock_config_no_portal(): return config +@patch("ewccli.backends.keycloak.keycloak_backend.subprocess") @patch("ewccli.backends.keycloak.keycloak_backend.webbrowser") @patch("ewccli.backends.keycloak.keycloak_backend.PortalClient") @patch("ewccli.backends.keycloak.keycloak_backend.OIDCClient") @@ -39,6 +40,7 @@ def test_keycloak_login_success( mock_oidc_cls, mock_portal_cls, mock_webbrowser, + mock_subprocess, mock_config, ): # Callback server @@ -88,8 +90,10 @@ def test_keycloak_login_success( assert result.federee == "EUMETSAT" assert result.region == "ECIS-R1" - # Browser was opened - mock_webbrowser.open.assert_called_once() + # Browser was opened (via subprocess.Popen) + mock_subprocess.Popen.assert_called_once() + # webbrowser.open should not be called (subprocess takes priority) + mock_webbrowser.open.assert_not_called() # Callback server was started and stopped mock_server.start.assert_called_once() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2b18349 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1773 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.13" + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[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 = "ansible" +version = "10.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ansible-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/64/29fdff6fe7682342adb54802c1cd90b2272d382e1743089af88f90a1d986/ansible-10.7.0.tar.gz", hash = "sha256:59d29e3de1080e740dfa974517d455217601b16d16880314d9be26145c68dc22", size = 41256974, upload-time = "2024-12-03T18:04:25.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/95/cb8944902a2cdd94b1e19ff73695548679a388b9c473dc63c8dc64ffea3a/ansible-10.7.0-py3-none-any.whl", hash = "sha256:0089f08e047ceb70edd011be009f5c6273add613fbe491e9697c0556c989d8ea", size = 51576038, upload-time = "2024-12-03T18:04:20.065Z" }, +] + +[[package]] +name = "ansible-core" +version = "2.17.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "resolvelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/80/2925a0564f6f99a8002c3be3885b83c3a1dc5f57ebf00163f528889865f5/ansible_core-2.17.14.tar.gz", hash = "sha256:7c17fee39f8c29d70e3282a7e9c10bd70d5cd4fd13ddffc5dcaa52adbd142ff8", size = 3119687, upload-time = "2025-09-08T18:28:03.158Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/29/d694562f1a875b50aa74f691521fe493704f79cf1938cd58f28f7e2327d2/ansible_core-2.17.14-py3-none-any.whl", hash = "sha256:34a49582a57c2f2af17ede2cefd3b3602a2d55d22089f3928570d52030cafa35", size = 2189656, upload-time = "2025-09-08T18:28:00.375Z" }, +] + +[[package]] +name = "ansible-runner" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pexpect" }, + { name = "python-daemon" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/b4/842698d5c17b3cae7948df4c812e01f4199dfb9f35b1c0bb51cf2fe5c246/ansible-runner-2.4.0.tar.gz", hash = "sha256:82d02b2548830f37a53517b65c823c4af371069406c7d213b5c9041d45e0c5b6", size = 148802, upload-time = "2024-05-16T17:55:28.845Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/46/44577e2e58de8b9c9398e1ee08b6c697bb2581446209cbfd2639cced66f5/ansible_runner-2.4.0-py3-none-any.whl", hash = "sha256:a3f592ae4cdfa62a72ad15de60da9c8210f376d67f495c4a78d4cf1dc7ccdf89", size = 79678, upload-time = "2024-05-16T17:55:26.474Z" }, +] + +[[package]] +name = "autopage" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/76/9078d8db91f29af9ac5a359757f63f2d0fa869aba704d5ef0f836db62ea1/autopage-0.6.0.tar.gz", hash = "sha256:42d07de90de63e83762828028bfd56d19906a18f7c951ef6eef3e9ad48a3071d", size = 26797, upload-time = "2026-01-30T03:42:05.676Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/6c/0324d6ed15cfb3ed0d2578fd1486f815e01f7f8d0a32b1522ff3e611e5f9/autopage-0.6.0-py3-none-any.whl", hash = "sha256:87566f08a7d4ba20e346515d26ba1132f2fac4e5619ffba3079e63c28e5df98f", size = 30693, upload-time = "2026-01-30T03:42:04.449Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backports-strenum" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/c7/2ed54c32fed313591ffb21edbd48db71e68827d43a61938e5a0bc2b6ec91/backports_strenum-1.3.1.tar.gz", hash = "sha256:77c52407342898497714f0596e86188bb7084f89063226f4ba66863482f42414", size = 7257, upload-time = "2023-12-09T14:36:40.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/56cf20e2ee5127b603b81d5a69580a1a325083e2b921aa8f067da83927c0/backports_strenum-1.3.1-py3-none-any.whl", hash = "sha256:cdcfe36dc897e2615dc793b7d3097f54d359918fc448754a517e6f23044ccf83", size = 8304, upload-time = "2023-12-09T14:36:39.905Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[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/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { 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" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, + { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, + { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, + { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, + { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, + { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, + { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, + { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, + { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "cliff" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "autopage" }, + { name = "cmd2" }, + { name = "prettytable" }, + { name = "pyyaml" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/ff/58b550ed138f67d2a8aa280021beeeccde5b09fe1b2725202d6f22478470/cliff-4.13.2.tar.gz", hash = "sha256:e949b585b9b64549de87388cefd49e87dd63095ce2b9f3b98f9123d7cd94be1a", size = 89458, upload-time = "2026-02-17T14:45:58.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/a2/4a4fd3d69c9722bf3bf90b21f08feb5c16a00a9dc8d129698fbe1597c894/cliff-4.13.2-py3-none-any.whl", hash = "sha256:adb12978c79260fdd71cda9a1620caffc91b027606a65679c9d88c526a80de23", size = 86820, upload-time = "2026-02-17T14:45:57.518Z" }, +] + +[[package]] +name = "cmd2" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-strenum", marker = "python_full_version < '3.11'" }, + { name = "gnureadline", marker = "sys_platform == 'darwin'" }, + { name = "pyperclip" }, + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "rich-argparse" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/f7/224338332fd867dcef9004a7d576b3909b5b27a6686d42a1cfa31ee6d1a1/cmd2-3.1.0.tar.gz", hash = "sha256:cce3aece018b0b1055988adaa2b687ac9c1df38bfd2abfc29dbeb51a9707de33", size = 1002416, upload-time = "2025-12-25T20:10:34.132Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/ae/4f6fe2c5d53abdbee53ce58bbf2f1aa6a1215de8c7b595c3905f22bf2d20/cmd2-3.1.0-py3-none-any.whl", hash = "sha256:deb6b71bf1d34560c54c92807439bf699dbc2956f085be463067bdc890c76414", size = 148793, upload-time = "2025-12-25T20:10:36.165Z" }, +] + +[[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.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/83/153f54356c7c200013a752ce1ed5448573dca546ce125801afca9e1ac1a4/coverage-7.10.5.tar.gz", hash = "sha256:f2e57716a78bc3ae80b2207be0709a3b2b63b9f2dcf9740ee6ac03588a2015b6", size = 821662, upload-time = "2025-08-23T14:42:44.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/70/e77b0061a6c7157bfce645c6b9a715a08d4c86b3360a7b3252818080b817/coverage-7.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c6a5c3414bfc7451b879141ce772c546985163cf553f08e0f135f0699a911801", size = 216774, upload-time = "2025-08-23T14:40:26.301Z" }, + { url = "https://files.pythonhosted.org/packages/91/08/2a79de5ecf37ee40f2d898012306f11c161548753391cec763f92647837b/coverage-7.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bc8e4d99ce82f1710cc3c125adc30fd1487d3cf6c2cd4994d78d68a47b16989a", size = 217175, upload-time = "2025-08-23T14:40:29.142Z" }, + { url = "https://files.pythonhosted.org/packages/64/57/0171d69a699690149a6ba6a4eb702814448c8d617cf62dbafa7ce6bfdf63/coverage-7.10.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:02252dc1216e512a9311f596b3169fad54abcb13827a8d76d5630c798a50a754", size = 243931, upload-time = "2025-08-23T14:40:30.735Z" }, + { url = "https://files.pythonhosted.org/packages/15/06/3a67662c55656702bd398a727a7f35df598eb11104fcb34f1ecbb070291a/coverage-7.10.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:73269df37883e02d460bee0cc16be90509faea1e3bd105d77360b512d5bb9c33", size = 245740, upload-time = "2025-08-23T14:40:32.302Z" }, + { url = "https://files.pythonhosted.org/packages/00/f4/f8763aabf4dc30ef0d0012522d312f0b7f9fede6246a1f27dbcc4a1e523c/coverage-7.10.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8a81b0614642f91c9effd53eec284f965577591f51f547a1cbeb32035b4c2f", size = 247600, upload-time = "2025-08-23T14:40:33.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/31/6632219a9065e1b83f77eda116fed4c76fb64908a6a9feae41816dab8237/coverage-7.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6a29f8e0adb7f8c2b95fa2d4566a1d6e6722e0a637634c6563cb1ab844427dd9", size = 245640, upload-time = "2025-08-23T14:40:35.248Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e2/3dba9b86037b81649b11d192bb1df11dde9a81013e434af3520222707bc8/coverage-7.10.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fcf6ab569436b4a647d4e91accba12509ad9f2554bc93d3aee23cc596e7f99c3", size = 243659, upload-time = "2025-08-23T14:40:36.815Z" }, + { url = "https://files.pythonhosted.org/packages/02/b9/57170bd9f3e333837fc24ecc88bc70fbc2eb7ccfd0876854b0c0407078c3/coverage-7.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:90dc3d6fb222b194a5de60af8d190bedeeddcbc7add317e4a3cd333ee6b7c879", size = 244537, upload-time = "2025-08-23T14:40:38.737Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1c/93ac36ef1e8b06b8d5777393a3a40cb356f9f3dab980be40a6941e443588/coverage-7.10.5-cp310-cp310-win32.whl", hash = "sha256:414a568cd545f9dc75f0686a0049393de8098414b58ea071e03395505b73d7a8", size = 219285, upload-time = "2025-08-23T14:40:40.342Z" }, + { url = "https://files.pythonhosted.org/packages/30/95/23252277e6e5fe649d6cd3ed3f35d2307e5166de4e75e66aa7f432abc46d/coverage-7.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:e551f9d03347196271935fd3c0c165f0e8c049220280c1120de0084d65e9c7ff", size = 220185, upload-time = "2025-08-23T14:40:42.026Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f2/336d34d2fc1291ca7c18eeb46f64985e6cef5a1a7ef6d9c23720c6527289/coverage-7.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c177e6ffe2ebc7c410785307758ee21258aa8e8092b44d09a2da767834f075f2", size = 216890, upload-time = "2025-08-23T14:40:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/92448b07cc1cf2b429d0ce635f59cf0c626a5d8de21358f11e92174ff2a6/coverage-7.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:14d6071c51ad0f703d6440827eaa46386169b5fdced42631d5a5ac419616046f", size = 217287, upload-time = "2025-08-23T14:40:45.214Z" }, + { url = "https://files.pythonhosted.org/packages/96/ba/ad5b36537c5179c808d0ecdf6e4aa7630b311b3c12747ad624dcd43a9b6b/coverage-7.10.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:61f78c7c3bc272a410c5ae3fde7792b4ffb4acc03d35a7df73ca8978826bb7ab", size = 247683, upload-time = "2025-08-23T14:40:46.791Z" }, + { url = "https://files.pythonhosted.org/packages/28/e5/fe3bbc8d097029d284b5fb305b38bb3404895da48495f05bff025df62770/coverage-7.10.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f39071caa126f69d63f99b324fb08c7b1da2ec28cbb1fe7b5b1799926492f65c", size = 249614, upload-time = "2025-08-23T14:40:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/69/9c/a1c89a8c8712799efccb32cd0a1ee88e452f0c13a006b65bb2271f1ac767/coverage-7.10.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343a023193f04d46edc46b2616cdbee68c94dd10208ecd3adc56fcc54ef2baa1", size = 251719, upload-time = "2025-08-23T14:40:49.349Z" }, + { url = "https://files.pythonhosted.org/packages/e9/be/5576b5625865aa95b5633315f8f4142b003a70c3d96e76f04487c3b5cc95/coverage-7.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:585ffe93ae5894d1ebdee69fc0b0d4b7c75d8007983692fb300ac98eed146f78", size = 249411, upload-time = "2025-08-23T14:40:50.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/e39a113d4209da0dbbc9385608cdb1b0726a4d25f78672dc51c97cfea80f/coverage-7.10.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b0ef4e66f006ed181df29b59921bd8fc7ed7cd6a9289295cd8b2824b49b570df", size = 247466, upload-time = "2025-08-23T14:40:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/40/cb/aebb2d8c9e3533ee340bea19b71c5b76605a0268aa49808e26fe96ec0a07/coverage-7.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb7b0bbf7cc1d0453b843eca7b5fa017874735bef9bfdfa4121373d2cc885ed6", size = 248104, upload-time = "2025-08-23T14:40:54.064Z" }, + { url = "https://files.pythonhosted.org/packages/08/e6/26570d6ccce8ff5de912cbfd268e7f475f00597cb58da9991fa919c5e539/coverage-7.10.5-cp311-cp311-win32.whl", hash = "sha256:1d043a8a06987cc0c98516e57c4d3fc2c1591364831e9deb59c9e1b4937e8caf", size = 219327, upload-time = "2025-08-23T14:40:55.424Z" }, + { url = "https://files.pythonhosted.org/packages/79/79/5f48525e366e518b36e66167e3b6e5db6fd54f63982500c6a5abb9d3dfbd/coverage-7.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:fefafcca09c3ac56372ef64a40f5fe17c5592fab906e0fdffd09543f3012ba50", size = 220213, upload-time = "2025-08-23T14:40:56.724Z" }, + { url = "https://files.pythonhosted.org/packages/40/3c/9058128b7b0bf333130c320b1eb1ae485623014a21ee196d68f7737f8610/coverage-7.10.5-cp311-cp311-win_arm64.whl", hash = "sha256:7e78b767da8b5fc5b2faa69bb001edafcd6f3995b42a331c53ef9572c55ceb82", size = 218893, upload-time = "2025-08-23T14:40:58.011Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/40d75c7128f871ea0fd829d3e7e4a14460cad7c3826e3b472e6471ad05bd/coverage-7.10.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c2d05c7e73c60a4cecc7d9b60dbfd603b4ebc0adafaef371445b47d0f805c8a9", size = 217077, upload-time = "2025-08-23T14:40:59.329Z" }, + { url = "https://files.pythonhosted.org/packages/18/a8/f333f4cf3fb5477a7f727b4d603a2eb5c3c5611c7fe01329c2e13b23b678/coverage-7.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:32ddaa3b2c509778ed5373b177eb2bf5662405493baeff52278a0b4f9415188b", size = 217310, upload-time = "2025-08-23T14:41:00.628Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2c/fbecd8381e0a07d1547922be819b4543a901402f63930313a519b937c668/coverage-7.10.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd382410039fe062097aa0292ab6335a3f1e7af7bba2ef8d27dcda484918f20c", size = 248802, upload-time = "2025-08-23T14:41:02.012Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bc/1011da599b414fb6c9c0f34086736126f9ff71f841755786a6b87601b088/coverage-7.10.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7fa22800f3908df31cea6fb230f20ac49e343515d968cc3a42b30d5c3ebf9b5a", size = 251550, upload-time = "2025-08-23T14:41:03.438Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6f/b5c03c0c721c067d21bc697accc3642f3cef9f087dac429c918c37a37437/coverage-7.10.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f366a57ac81f5e12797136552f5b7502fa053c861a009b91b80ed51f2ce651c6", size = 252684, upload-time = "2025-08-23T14:41:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/f9/50/d474bc300ebcb6a38a1047d5c465a227605d6473e49b4e0d793102312bc5/coverage-7.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1dc8f1980a272ad4a6c84cba7981792344dad33bf5869361576b7aef42733a", size = 250602, upload-time = "2025-08-23T14:41:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2d/548c8e04249cbba3aba6bd799efdd11eee3941b70253733f5d355d689559/coverage-7.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2285c04ee8676f7938b02b4936d9b9b672064daab3187c20f73a55f3d70e6b4a", size = 248724, upload-time = "2025-08-23T14:41:08.429Z" }, + { url = "https://files.pythonhosted.org/packages/e2/96/a7c3c0562266ac39dcad271d0eec8fc20ab576e3e2f64130a845ad2a557b/coverage-7.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2492e4dd9daab63f5f56286f8a04c51323d237631eb98505d87e4c4ff19ec34", size = 250158, upload-time = "2025-08-23T14:41:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/74d4be58c70c42ef0b352d597b022baf12dbe2b43e7cb1525f56a0fb1d4b/coverage-7.10.5-cp312-cp312-win32.whl", hash = "sha256:38a9109c4ee8135d5df5505384fc2f20287a47ccbe0b3f04c53c9a1989c2bbaf", size = 219493, upload-time = "2025-08-23T14:41:11.095Z" }, + { url = "https://files.pythonhosted.org/packages/4f/08/364e6012d1d4d09d1e27437382967efed971d7613f94bca9add25f0c1f2b/coverage-7.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:6b87f1ad60b30bc3c43c66afa7db6b22a3109902e28c5094957626a0143a001f", size = 220302, upload-time = "2025-08-23T14:41:12.449Z" }, + { url = "https://files.pythonhosted.org/packages/db/d5/7c8a365e1f7355c58af4fe5faf3f90cc8e587590f5854808d17ccb4e7077/coverage-7.10.5-cp312-cp312-win_arm64.whl", hash = "sha256:672a6c1da5aea6c629819a0e1461e89d244f78d7b60c424ecf4f1f2556c041d8", size = 218936, upload-time = "2025-08-23T14:41:13.872Z" }, + { url = "https://files.pythonhosted.org/packages/08/b6/fff6609354deba9aeec466e4bcaeb9d1ed3e5d60b14b57df2a36fb2273f2/coverage-7.10.5-py3-none-any.whl", hash = "sha256:0be24d35e4db1d23d0db5c0f6a74a962e2ec83c426b5cac09f4234aadef38e4a", size = 208736, upload-time = "2025-08-23T14:42:43.145Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/9b/e301418629f7bfdf72db9e80ad6ed9d1b83c487c471803eaa6464c511a01/cryptography-46.0.2.tar.gz", hash = "sha256:21b6fc8c71a3f9a604f028a329e5560009cc4a3a828bfea5fcba8eb7647d88fe", size = 749293, upload-time = "2025-10-01T00:29:11.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/98/7a8df8c19a335c8028414738490fc3955c0cecbfdd37fcc1b9c3d04bd561/cryptography-46.0.2-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3e32ab7dd1b1ef67b9232c4cf5e2ee4cd517d4316ea910acaaa9c5712a1c663", size = 7261255, upload-time = "2025-10-01T00:27:22.947Z" }, + { url = "https://files.pythonhosted.org/packages/c6/38/b2adb2aa1baa6706adc3eb746691edd6f90a656a9a65c3509e274d15a2b8/cryptography-46.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1fd1a69086926b623ef8126b4c33d5399ce9e2f3fac07c9c734c2a4ec38b6d02", size = 4297596, upload-time = "2025-10-01T00:27:25.258Z" }, + { url = "https://files.pythonhosted.org/packages/e4/27/0f190ada240003119488ae66c897b5e97149292988f556aef4a6a2a57595/cryptography-46.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb7fb9cd44c2582aa5990cf61a4183e6f54eea3172e54963787ba47287edd135", size = 4450899, upload-time = "2025-10-01T00:27:27.458Z" }, + { url = "https://files.pythonhosted.org/packages/85/d5/e4744105ab02fdf6bb58ba9a816e23b7a633255987310b4187d6745533db/cryptography-46.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9066cfd7f146f291869a9898b01df1c9b0e314bfa182cef432043f13fc462c92", size = 4300382, upload-time = "2025-10-01T00:27:29.091Z" }, + { url = "https://files.pythonhosted.org/packages/33/fb/bf9571065c18c04818cb07de90c43fc042c7977c68e5de6876049559c72f/cryptography-46.0.2-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:97e83bf4f2f2c084d8dd792d13841d0a9b241643151686010866bbd076b19659", size = 4017347, upload-time = "2025-10-01T00:27:30.767Z" }, + { url = "https://files.pythonhosted.org/packages/35/72/fc51856b9b16155ca071080e1a3ad0c3a8e86616daf7eb018d9565b99baa/cryptography-46.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:4a766d2a5d8127364fd936572c6e6757682fc5dfcbdba1632d4554943199f2fa", size = 4983500, upload-time = "2025-10-01T00:27:32.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/0f51e926799025e31746d454ab2e36f8c3f0d41592bc65cb9840368d3275/cryptography-46.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:fab8f805e9675e61ed8538f192aad70500fa6afb33a8803932999b1049363a08", size = 4482591, upload-time = "2025-10-01T00:27:34.869Z" }, + { url = "https://files.pythonhosted.org/packages/86/96/4302af40b23ab8aa360862251fb8fc450b2a06ff24bc5e261c2007f27014/cryptography-46.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1e3b6428a3d56043bff0bb85b41c535734204e599c1c0977e1d0f261b02f3ad5", size = 4300019, upload-time = "2025-10-01T00:27:37.029Z" }, + { url = "https://files.pythonhosted.org/packages/9b/59/0be12c7fcc4c5e34fe2b665a75bc20958473047a30d095a7657c218fa9e8/cryptography-46.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:1a88634851d9b8de8bb53726f4300ab191d3b2f42595e2581a54b26aba71b7cc", size = 4950006, upload-time = "2025-10-01T00:27:40.272Z" }, + { url = "https://files.pythonhosted.org/packages/55/1d/42fda47b0111834b49e31590ae14fd020594d5e4dadd639bce89ad790fba/cryptography-46.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:be939b99d4e091eec9a2bcf41aaf8f351f312cd19ff74b5c83480f08a8a43e0b", size = 4482088, upload-time = "2025-10-01T00:27:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/60f583f69aa1602c2bdc7022dae86a0d2b837276182f8c1ec825feb9b874/cryptography-46.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f13b040649bc18e7eb37936009b24fd31ca095a5c647be8bb6aaf1761142bd1", size = 4425599, upload-time = "2025-10-01T00:27:44.616Z" }, + { url = "https://files.pythonhosted.org/packages/d1/57/d8d4134cd27e6e94cf44adb3f3489f935bde85f3a5508e1b5b43095b917d/cryptography-46.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bdc25e4e01b261a8fda4e98618f1c9515febcecebc9566ddf4a70c63967043b", size = 4697458, upload-time = "2025-10-01T00:27:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2b/531e37408573e1da33adfb4c58875013ee8ac7d548d1548967d94a0ae5c4/cryptography-46.0.2-cp311-abi3-win32.whl", hash = "sha256:8b9bf67b11ef9e28f4d78ff88b04ed0929fcd0e4f70bb0f704cfc32a5c6311ee", size = 3056077, upload-time = "2025-10-01T00:27:48.424Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cd/2f83cafd47ed2dc5a3a9c783ff5d764e9e70d3a160e0df9a9dcd639414ce/cryptography-46.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:758cfc7f4c38c5c5274b55a57ef1910107436f4ae842478c4989abbd24bd5acb", size = 3512585, upload-time = "2025-10-01T00:27:50.521Z" }, + { url = "https://files.pythonhosted.org/packages/00/36/676f94e10bfaa5c5b86c469ff46d3e0663c5dc89542f7afbadac241a3ee4/cryptography-46.0.2-cp311-abi3-win_arm64.whl", hash = "sha256:218abd64a2e72f8472c2102febb596793347a3e65fafbb4ad50519969da44470", size = 2927474, upload-time = "2025-10-01T00:27:52.91Z" }, + { url = "https://files.pythonhosted.org/packages/d5/bb/fa95abcf147a1b0bb94d95f53fbb09da77b24c776c5d87d36f3d94521d2c/cryptography-46.0.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a08e7401a94c002e79dc3bc5231b6558cd4b2280ee525c4673f650a37e2c7685", size = 7248090, upload-time = "2025-10-01T00:28:22.846Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/f42071ce0e3ffbfa80a88feadb209c779fda92a23fbc1e14f74ebf72ef6b/cryptography-46.0.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d30bc11d35743bf4ddf76674a0a369ec8a21f87aaa09b0661b04c5f6c46e8d7b", size = 4293123, upload-time = "2025-10-01T00:28:25.072Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/1fdbd2e5c1ba822828d250e5a966622ef00185e476d1cd2726b6dd135e53/cryptography-46.0.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bca3f0ce67e5a2a2cf524e86f44697c4323a86e0fd7ba857de1c30d52c11ede1", size = 4439524, upload-time = "2025-10-01T00:28:26.808Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/5e4989a7d102d4306053770d60f978c7b6b1ea2ff8c06e0265e305b23516/cryptography-46.0.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ff798ad7a957a5021dcbab78dfff681f0cf15744d0e6af62bd6746984d9c9e9c", size = 4297264, upload-time = "2025-10-01T00:28:29.327Z" }, + { url = "https://files.pythonhosted.org/packages/28/78/b56f847d220cb1d6d6aef5a390e116ad603ce13a0945a3386a33abc80385/cryptography-46.0.2-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cb5e8daac840e8879407acbe689a174f5ebaf344a062f8918e526824eb5d97af", size = 4011872, upload-time = "2025-10-01T00:28:31.479Z" }, + { url = "https://files.pythonhosted.org/packages/e1/80/2971f214b066b888944f7b57761bf709ee3f2cf805619a18b18cab9b263c/cryptography-46.0.2-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:3f37aa12b2d91e157827d90ce78f6180f0c02319468a0aea86ab5a9566da644b", size = 4978458, upload-time = "2025-10-01T00:28:33.267Z" }, + { url = "https://files.pythonhosted.org/packages/a5/84/0cb0a2beaa4f1cbe63ebec4e97cd7e0e9f835d0ba5ee143ed2523a1e0016/cryptography-46.0.2-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5e38f203160a48b93010b07493c15f2babb4e0f2319bbd001885adb3f3696d21", size = 4472195, upload-time = "2025-10-01T00:28:36.039Z" }, + { url = "https://files.pythonhosted.org/packages/30/8b/2b542ddbf78835c7cd67b6fa79e95560023481213a060b92352a61a10efe/cryptography-46.0.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d19f5f48883752b5ab34cff9e2f7e4a7f216296f33714e77d1beb03d108632b6", size = 4296791, upload-time = "2025-10-01T00:28:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/78/12/9065b40201b4f4876e93b9b94d91feb18de9150d60bd842a16a21565007f/cryptography-46.0.2-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:04911b149eae142ccd8c9a68892a70c21613864afb47aba92d8c7ed9cc001023", size = 4939629, upload-time = "2025-10-01T00:28:39.654Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9e/6507dc048c1b1530d372c483dfd34e7709fc542765015425f0442b08547f/cryptography-46.0.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8b16c1ede6a937c291d41176934268e4ccac2c6521c69d3f5961c5a1e11e039e", size = 4471988, upload-time = "2025-10-01T00:28:41.822Z" }, + { url = "https://files.pythonhosted.org/packages/b1/86/d025584a5f7d5c5ec8d3633dbcdce83a0cd579f1141ceada7817a4c26934/cryptography-46.0.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:747b6f4a4a23d5a215aadd1d0b12233b4119c4313df83ab4137631d43672cc90", size = 4422989, upload-time = "2025-10-01T00:28:43.608Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/536370418b38a15a61bbe413006b79dfc3d2b4b0eafceb5581983f973c15/cryptography-46.0.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b275e398ab3a7905e168c036aad54b5969d63d3d9099a0a66cc147a3cc983be", size = 4685578, upload-time = "2025-10-01T00:28:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/15/52/ea7e2b1910f547baed566c866fbb86de2402e501a89ecb4871ea7f169a81/cryptography-46.0.2-cp38-abi3-win32.whl", hash = "sha256:0b507c8e033307e37af61cb9f7159b416173bdf5b41d11c4df2e499a1d8e007c", size = 3036711, upload-time = "2025-10-01T00:28:47.096Z" }, + { url = "https://files.pythonhosted.org/packages/71/9e/171f40f9c70a873e73c2efcdbe91e1d4b1777a03398fa1c4af3c56a2477a/cryptography-46.0.2-cp38-abi3-win_amd64.whl", hash = "sha256:f9b2dc7668418fb6f221e4bf701f716e05e8eadb4f1988a2487b11aedf8abe62", size = 3500007, upload-time = "2025-10-01T00:28:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/3e/7c/15ad426257615f9be8caf7f97990cf3dcbb5b8dd7ed7e0db581a1c4759dd/cryptography-46.0.2-cp38-abi3-win_arm64.whl", hash = "sha256:91447f2b17e83c9e0c89f133119d83f94ce6e0fb55dd47da0a959316e6e9cfa1", size = 2918153, upload-time = "2025-10-01T00:28:51.003Z" }, + { url = "https://files.pythonhosted.org/packages/25/b2/067a7db693488f19777ecf73f925bcb6a3efa2eae42355bafaafa37a6588/cryptography-46.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f25a41f5b34b371a06dad3f01799706631331adc7d6c05253f5bca22068c7a34", size = 3701860, upload-time = "2025-10-01T00:28:53.003Z" }, + { url = "https://files.pythonhosted.org/packages/87/12/47c2aab2c285f97c71a791169529dbb89f48fc12e5f62bb6525c3927a1a2/cryptography-46.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e12b61e0b86611e3f4c1756686d9086c1d36e6fd15326f5658112ad1f1cc8807", size = 3429917, upload-time = "2025-10-01T00:28:55.03Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/1aabe338149a7d0f52c3e30f2880b20027ca2a485316756ed6f000462db3/cryptography-46.0.2-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:1d3b3edd145953832e09607986f2bd86f85d1dc9c48ced41808b18009d9f30e5", size = 3714495, upload-time = "2025-10-01T00:28:57.222Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0a/0d10eb970fe3e57da9e9ddcfd9464c76f42baf7b3d0db4a782d6746f788f/cryptography-46.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fe245cf4a73c20592f0f48da39748b3513db114465be78f0a36da847221bd1b4", size = 4243379, upload-time = "2025-10-01T00:28:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/7d/60/e274b4d41a9eb82538b39950a74ef06e9e4d723cb998044635d9deb1b435/cryptography-46.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2b9cad9cf71d0c45566624ff76654e9bae5f8a25970c250a26ccfc73f8553e2d", size = 4409533, upload-time = "2025-10-01T00:29:00.785Z" }, + { url = "https://files.pythonhosted.org/packages/19/9a/fb8548f762b4749aebd13b57b8f865de80258083fe814957f9b0619cfc56/cryptography-46.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9bd26f2f75a925fdf5e0a446c0de2714f17819bf560b44b7480e4dd632ad6c46", size = 4243120, upload-time = "2025-10-01T00:29:02.515Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/883f24147fd4a0c5cab74ac7e36a1ff3094a54ba5c3a6253d2ff4b19255b/cryptography-46.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:7282d8f092b5be7172d6472f29b0631f39f18512a3642aefe52c3c0e0ccfad5a", size = 4408940, upload-time = "2025-10-01T00:29:04.42Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b5/c5e179772ec38adb1c072b3aa13937d2860509ba32b2462bf1dda153833b/cryptography-46.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c4b93af7920cdf80f71650769464ccf1fb49a4b56ae0024173c24c48eb6b1612", size = 3438518, upload-time = "2025-10-01T00:29:06.139Z" }, +] + +[[package]] +name = "debtcollector" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/e2/a45b5a620145937529c840df5e499c267997e85de40df27d54424a158d3c/debtcollector-3.0.0.tar.gz", hash = "sha256:2a8917d25b0e1f1d0d365d3c1c6ecfc7a522b1e9716e8a1a4a915126f7ccea6f", size = 31322, upload-time = "2024-02-22T15:39:20.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/ca/863ed8fa66d6f986de6ad7feccc5df96e37400845b1eeb29889a70feea99/debtcollector-3.0.0-py3-none-any.whl", hash = "sha256:46f9dacbe8ce49c47ebf2bf2ec878d50c9443dfae97cc7b8054be684e54c3e91", size = 23035, upload-time = "2024-02-22T15:39:18.99Z" }, +] + +[[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 = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "dogpile-cache" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "stevedore" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/301ff89746e76745b937606df4753c032787c59ecb37dd4d4250bddc8929/dogpile_cache-1.5.0.tar.gz", hash = "sha256:849c5573c9a38f155cd4173103c702b637ede0361c12e864876877d0cd125eec", size = 947962, upload-time = "2025-10-11T17:35:36.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/80/12235e5b75bb2c586733280854f131b86051e0bbdfb55349ff70d0f72cf9/dogpile_cache-1.5.0-py3-none-any.whl", hash = "sha256:dc7b47d37844db15e8fdc0243c1b58857a2ddc52a5118237a97127bac200e18d", size = 64447, upload-time = "2025-10-11T17:35:38.573Z" }, +] + +[[package]] +name = "durationpy" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, +] + +[[package]] +name = "ewccli" +version = "0.6.0" +source = { editable = "." } +dependencies = [ + { name = "ansible" }, + { name = "ansible-runner" }, + { name = "click" }, + { name = "cryptography" }, + { name = "kubernetes" }, + { name = "prompt-toolkit" }, + { name = "pydantic" }, + { name = "python-openstackclient" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "rich-click" }, +] + +[package.optional-dependencies] +test = [ + { name = "coverage" }, + { name = "pre-commit" }, + { name = "pydeps" }, + { name = "pytest" }, + { name = "pytest-html" }, + { name = "pytest-mock" }, + { name = "sphinx" }, + { name = "sphinx-click" }, + { name = "sphinx-rtd-theme" }, +] + +[package.metadata] +requires-dist = [ + { name = "ansible", specifier = "==10.7.0" }, + { name = "ansible-runner", specifier = "==2.4.0" }, + { name = "click", specifier = "==8.3.0" }, + { name = "coverage", marker = "extra == 'test'", specifier = "==7.10.5" }, + { name = "cryptography", specifier = "==46.0.2" }, + { name = "kubernetes", specifier = "==34.1.0" }, + { name = "pre-commit", marker = "extra == 'test'", specifier = "==4.3.0" }, + { name = "prompt-toolkit", specifier = "==3.0.52" }, + { name = "pydantic", specifier = "==2.12.5" }, + { name = "pydeps", marker = "extra == 'test'", specifier = "==3.0.1" }, + { name = "pytest", marker = "extra == 'test'", specifier = "==8.4.1" }, + { name = "pytest-html", marker = "extra == 'test'", specifier = "==4.1.1" }, + { name = "pytest-mock", marker = "extra == 'test'", specifier = "==3.14.1" }, + { name = "python-openstackclient", specifier = "==8.2.0" }, + { name = "pyyaml", specifier = "==6.0.3" }, + { name = "requests", specifier = "==2.32.5" }, + { name = "rich", specifier = "==14.1.0" }, + { name = "rich-click", specifier = "==1.9.2" }, + { name = "sphinx", marker = "extra == 'test'", specifier = "==8.1.3" }, + { name = "sphinx-click", marker = "extra == 'test'", specifier = "==6.0.0" }, + { name = "sphinx-rtd-theme", marker = "extra == 'test'", specifier = "==3.0.2" }, +] +provides-extras = ["test"] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, +] + +[[package]] +name = "gnureadline" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/33/d0a1a41e687f0d1956cc5a7b07735c6893f3fa061440fddb7a2c9d2bcd35/gnureadline-8.3.3.tar.gz", hash = "sha256:0972392bd2f31244e2d981178246fe8b729c8766454fdaeb275946ac47b7e9fd", size = 3595875, upload-time = "2026-01-06T15:03:17.802Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/f8/89e78ddcd8cc4589406d009184647c0bdcbfaf209f8a96005a7d0c1c272f/gnureadline-8.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a0427e9f75a0407391bdc2e0e76b06807b50a0e1fb73cf99b3d40a4dd1299c43", size = 166883, upload-time = "2026-01-06T15:03:54.181Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/1d5b155b980953cd27400f7107f6c38661f88482442ea27379614e6689d7/gnureadline-8.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87a8d9ba3e87c495b9388ef5566a647d7ab1db345b841c745ae2c21033a37856", size = 166878, upload-time = "2026-01-06T15:03:55.497Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/1a9b7c9b7b03022d8dfa02e17f66e819ef377c7c48cf91173826422382e1/gnureadline-8.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fff8def8a9ec595e6dd9186bc4fc7061aaee34e4a0b762b120ef2398bbbbafc8", size = 166892, upload-time = "2026-01-06T15:04:00.751Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a8/c5bb8a49dcea7819ce1a5816365f6aa15bc04efb91cc820dd985a55b9362/gnureadline-8.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa03cc35adeddb05412fda494b3d6851e810401341aa7abee7347f116dc74ad6", size = 166898, upload-time = "2026-01-06T15:04:02.192Z" }, + { url = "https://files.pythonhosted.org/packages/ce/29/cad97d1e8fc3102169a84f8fbf299b7306ebe27c2523dd0e441b40b29646/gnureadline-8.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:04ad9724dd1783d140146a1e83313918741975c1226a5dfa1b2e97560d8e36c7", size = 166926, upload-time = "2026-01-06T15:04:06.588Z" }, + { url = "https://files.pythonhosted.org/packages/76/80/fadacc11c6ebba0a49e66c1279c95dfc4caeb3bcf05da8965fc2efb5f163/gnureadline-8.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:831599cd9fea95eae2110646d274ed0fe0e0c20cf32e0eb01a5225d9dad4f1b4", size = 166744, upload-time = "2026-01-06T15:04:07.714Z" }, +] + +[[package]] +name = "google-auth" +version = "2.49.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, +] + +[[package]] +name = "identify" +version = "2.6.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, +] + +[[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 = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[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 = "iso8601" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload-time = "2023-10-03T00:25:39.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" }, +] + +[[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 = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + +[[package]] +name = "keystoneauth1" +version = "5.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "iso8601" }, + { name = "os-service-types" }, + { name = "pbr" }, + { name = "requests" }, + { name = "stevedore" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/bc/d99872ca0bc8bf5f248b50e3d7386dedec5278f8dd989a2a981d329a8069/keystoneauth1-5.13.1.tar.gz", hash = "sha256:e011e47ac3f3c671ffae33505c095548650cc19dab7f6af3b2ea5bd18c98f0c9", size = 288548, upload-time = "2026-02-20T15:16:47.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/68/ff03933cff7568277e2d55cb33aeb60a5c2d77bd88b0ea139244079df638/keystoneauth1-5.13.1-py3-none-any.whl", hash = "sha256:6e6d0296bc341e5f9a08e985f7083206c93a4ffd999933c2d1b7e1c833c2e501", size = 343539, upload-time = "2026-02-20T15:16:45.873Z" }, +] + +[[package]] +name = "kubernetes" +version = "34.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "durationpy" }, + { name = "google-auth" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/55/3f880ef65f559cbed44a9aa20d3bdbc219a2c3a3bac4a30a513029b03ee9/kubernetes-34.1.0.tar.gz", hash = "sha256:8fe8edb0b5d290a2f3ac06596b23f87c658977d46b5f8df9d0f4ea83d0003912", size = 1083771, upload-time = "2025-09-29T20:23:49.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/ec/65f7d563aa4a62dd58777e8f6aa882f15db53b14eb29aba0c28a20f7eb26/kubernetes-34.1.0-py2.py3-none-any.whl", hash = "sha256:bffba2272534e224e6a7a74d582deb0b545b7c9879d2cd9e4aae9481d1f2cc2a", size = 2008380, upload-time = "2025-09-29T20:23:47.684Z" }, +] + +[[package]] +name = "lockfile" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/47/72cb04a58a35ec495f96984dddb48232b551aafb95bde614605b754fe6f7/lockfile-0.12.2.tar.gz", hash = "sha256:6aed02de03cba24efabcd600b30540140634fc06cfa603822d508d5361e9f799", size = 20874, upload-time = "2015-11-25T18:29:58.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/22/9460e311f340cb62d26a38c419b1381b8593b0bb6b5d1f056938b086d362/lockfile-0.12.2-py2.py3-none-any.whl", hash = "sha256:6c3cb24f344923d30b2785d5ad75182c8ea7ac1b6171b08657258ec7429d50fa", size = 13564, upload-time = "2015-11-25T18:29:51.462Z" }, +] + +[[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/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { 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" }, +] + +[[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 = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/a2/3b68a9e769db68668b25c6108444a35f9bd163bb848c0650d516761a59c0/msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2", size = 81318, upload-time = "2025-10-08T09:14:38.722Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e1/2b720cc341325c00be44e1ed59e7cfeae2678329fbf5aa68f5bda57fe728/msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87", size = 83786, upload-time = "2025-10-08T09:14:40.082Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/c2241de64bfceac456b140737812a2ab310b10538a7b34a1d393b748e095/msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251", size = 398240, upload-time = "2025-10-08T09:14:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/b7/09/2a06956383c0fdebaef5aa9246e2356776f12ea6f2a44bd1368abf0e46c4/msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a", size = 406070, upload-time = "2025-10-08T09:14:42.821Z" }, + { url = "https://files.pythonhosted.org/packages/0e/74/2957703f0e1ef20637d6aead4fbb314330c26f39aa046b348c7edcf6ca6b/msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f", size = 393403, upload-time = "2025-10-08T09:14:44.38Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/3bfc12aa90f77b37322fc33e7a8a7c29ba7c8edeadfa27664451801b9860/msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f", size = 398947, upload-time = "2025-10-08T09:14:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4f/05fcebd3b4977cb3d840f7ef6b77c51f8582086de5e642f3fefee35c86fc/msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9", size = 64769, upload-time = "2025-10-08T09:14:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3e/b4547e3a34210956382eed1c85935fff7e0f9b98be3106b3745d7dec9c5e/msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa", size = 71293, upload-time = "2025-10-08T09:14:48.665Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, + { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, +] + +[[package]] +name = "netaddr" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/90/188b2a69654f27b221fba92fda7217778208532c962509e959a9cee5229d/netaddr-1.3.0.tar.gz", hash = "sha256:5c3c3d9895b551b763779ba7db7a03487dc1f8e3b385af819af341ae9ef6e48a", size = 2260504, upload-time = "2024-05-28T21:30:37.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cc/f4fe2c7ce68b92cbf5b2d379ca366e1edae38cccaad00f69f529b460c3ef/netaddr-1.3.0-py3-none-any.whl", hash = "sha256:c2c6a8ebe5554ce33b7d5b3a306b71bbb373e000bbbf2350dd5213cc56e3dbbe", size = 2262023, upload-time = "2024-05-28T21:30:34.191Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[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 = "openstacksdk" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "decorator" }, + { name = "dogpile-cache" }, + { name = "iso8601" }, + { name = "jmespath" }, + { name = "jsonpatch" }, + { name = "keystoneauth1" }, + { name = "os-service-types" }, + { name = "pbr" }, + { name = "platformdirs" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "requestsexceptions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/32/5f614212ccc23323755dd83fc0c23600666161a4d3e81dce9bdcf8048aab/openstacksdk-4.10.0.tar.gz", hash = "sha256:5dde9ae3f1e2411a87ff57b2d78da53fac8eae9e5bac8e5870927cb62ddfc033", size = 1319494, upload-time = "2026-02-17T14:51:51.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/e4/8cb9f24ed26169249de8552184de7524a4788cce5616475ab38734bcb6d4/openstacksdk-4.10.0-py3-none-any.whl", hash = "sha256:c75927fb29a9c82d8dd116f16c72093a623d507f9a4aca63f3c26ad8ddf99855", size = 1859111, upload-time = "2026-02-17T14:51:49.704Z" }, +] + +[[package]] +name = "os-service-types" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pbr" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/62/31e39aa8f2ac5bff0b061ce053f0610c9fe659e12aeca20bfb26d1665024/os_service_types-1.8.2.tar.gz", hash = "sha256:ab7648d7232849943196e1bb00a30e2e25e600fa3b57bb241d15b7f521b5b575", size = 27476, upload-time = "2025-11-21T13:55:47.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/26/0937af7b4383f1eba5bca789b8d191c0e09e59bb64962b18f4a14534ce41/os_service_types-1.8.2-py3-none-any.whl", hash = "sha256:f78890d71814deffabf0ed4358288ec2ced579bc4d0bb87a79ae806cbb4deb6e", size = 24876, upload-time = "2025-11-21T13:55:46.093Z" }, +] + +[[package]] +name = "osc-lib" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cliff" }, + { name = "keystoneauth1" }, + { name = "openstacksdk" }, + { name = "oslo-i18n" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "requests" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/08/a59bd83e6850d5a27606de1a9fc0b80a2e9b7452ca90ed0ecdaa581a91e6/osc_lib-4.4.0.tar.gz", hash = "sha256:6a615d744f03fba513d92eb4760a9cd5baa96e3d8530611fe53b217062bf317e", size = 105435, upload-time = "2026-02-17T14:52:18.053Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/a9/47ca295bfdac805708615b0239a22750fd59b10ba5930240f2f0208983c2/osc_lib-4.4.0-py3-none-any.whl", hash = "sha256:f9a15389d93ac8e9f2bb22bc2bf974a971ea67376470058b48a1403224ecd39b", size = 96928, upload-time = "2026-02-17T14:52:16.703Z" }, +] + +[[package]] +name = "oslo-config" +version = "10.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "netaddr" }, + { name = "oslo-i18n" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rfc3986" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/a6/aaf41cba43f8934d9c5db35f49fd8aa083279831f11974bea0816c593891/oslo_config-10.3.0.tar.gz", hash = "sha256:c405a40a8b05aa97bb5c24bb0b849981a7a5b7d56304df40632722312c58eaca", size = 164302, upload-time = "2026-02-17T15:19:16.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/4c/f6be02e23c8764683a891839a6431131bbbd8e93ab2701b1edde3493251b/oslo_config-10.3.0-py3-none-any.whl", hash = "sha256:b17d983bd1845087d282f19d583cae0da5bfe725dc6884c9a7d50454839640c9", size = 132620, upload-time = "2026-02-17T15:19:14.978Z" }, +] + +[[package]] +name = "oslo-i18n" +version = "6.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pbr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/4e/0ed2248dfc4c8e993064b3b7419835fc1f1adbab6917f41a011157ed50d5/oslo_i18n-6.7.2.tar.gz", hash = "sha256:b1241ad3eee216e9dc9acb4336fce0bd79c4c286751ee70dfa42ff2f9763d34f", size = 50005, upload-time = "2026-02-17T16:07:43.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/d0/0c350384b916ad046dd0a7920e15a9ab539b5639ef8c902c76fb4bbfd256/oslo_i18n-6.7.2-py3-none-any.whl", hash = "sha256:5505cfc03a917b448bfaaf6f1221a1c36efbcc1a5b8c29407824bf5b056c5e0e", size = 47721, upload-time = "2026-02-17T16:07:42.558Z" }, +] + +[[package]] +name = "oslo-serialization" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "oslo-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/66/ec1b215f2a5005e43803d904ad537734cfabf499dd89f95988e2173e5867/oslo_serialization-5.9.1.tar.gz", hash = "sha256:086ab78a15f33f02e647bdb3ca36632480d94cf661cf1fb118adebdeee5d4be7", size = 36935, upload-time = "2026-02-17T15:47:10.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/09/bdf07e4de30f0c445f05eccbdd951824a2feac0d592387e929816cec94de/oslo_serialization-5.9.1-py3-none-any.whl", hash = "sha256:7326426cff711fbf0565d369c8f7ccb33126ee2dc96fd89334ce584d790e49de", size = 27505, upload-time = "2026-02-17T15:47:08.994Z" }, +] + +[[package]] +name = "oslo-utils" +version = "10.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "debtcollector" }, + { name = "iso8601" }, + { name = "netaddr" }, + { name = "oslo-i18n" }, + { name = "packaging" }, + { name = "pbr" }, + { name = "psutil" }, + { name = "pyparsing" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/a5/6e9fb7904250e786f4afb137a23a2ec27098136efb8e72a5414cc66ae566/oslo_utils-10.0.0.tar.gz", hash = "sha256:bb46713e760d94446a084f5e94c1cf273935369308ad88ee5b53917923d9c393", size = 141716, upload-time = "2026-02-20T13:45:06.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/06/ccc01ae7088822157babc7ac265609d60c448ad28e48a0f891d3d0125527/oslo_utils-10.0.0-py3-none-any.whl", hash = "sha256:cda6926cc4cf090ac6a92f694c3f5a6983b082c3c5ae7f47e71945634534802e", size = 136290, upload-time = "2026-02-20T13:45:04.699Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pbr" +version = "7.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/ab/1de9a4f730edde1bdbbc2b8d19f8fa326f036b4f18b2f72cfbea7dc53c26/pbr-7.0.3.tar.gz", hash = "sha256:b46004ec30a5324672683ec848aed9e8fc500b0d261d40a3229c2d2bbfcedc29", size = 135625, upload-time = "2025-11-03T17:04:56.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/db/61efa0d08a99f897ef98256b03e563092d36cc38dc4ebe4a85020fe40b31/pbr-7.0.3-py2.py3-none-any.whl", hash = "sha256:ff223894eb1cd271a98076b13d3badff3bb36c424074d26334cd25aebeecea6b", size = 131898, upload-time = "2025-11-03T17:04:54.875Z" }, +] + +[[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 = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[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 = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "prettytable" +version = "3.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/45/b0847d88d6cfeb4413566738c8bbf1e1995fad3d42515327ff32cc1eb578/prettytable-3.17.0.tar.gz", hash = "sha256:59f2590776527f3c9e8cf9fe7b66dd215837cca96a9c39567414cbc632e8ddb0", size = 67892, upload-time = "2025-11-14T17:33:20.212Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl", hash = "sha256:aad69b294ddbe3e1f95ef8886a060ed1666a0b83018bbf56295f6f226c43d287", size = 34433, upload-time = "2025-11-14T17:33:19.093Z" }, +] + +[[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 = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[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 = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[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 = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +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/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[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/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { 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/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydeps" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "stdlib-list" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/03/ce4baba41362297576f84f2d1906af25e43b46cc368afda4ac8bfe4bfd81/pydeps-3.0.1.tar.gz", hash = "sha256:a57415a8fae2ff6840a199b7dfcfecb90c37e4b9b54b58a111808a3440bc03bc", size = 53070, upload-time = "2025-02-04T11:50:10.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/ea/663366200286a95fa6ac0ea3a67510cc5799983b102bddc845d9370bf1c8/pydeps-3.0.1-py3-none-any.whl", hash = "sha256:7c86ee63c9ee6ddd088c840364981c5aa214a994d323bb7fa4724fca30829bee", size = 47596, upload-time = "2025-02-04T11:50:07.717Z" }, +] + +[[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 = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + +[[package]] +name = "pyreadline3" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "pytest-html" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "pytest" }, + { name = "pytest-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ab/4862dcb5a8a514bd87747e06b8d55483c0c9e987e1b66972336946e49b49/pytest_html-4.1.1.tar.gz", hash = "sha256:70a01e8ae5800f4a074b56a4cb1025c8f4f9b038bba5fe31e3c98eb996686f07", size = 150773, upload-time = "2023-11-07T15:44:28.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl", hash = "sha256:c8152cea03bd4e9bee6d525573b67bbc6622967b72b9628dda0ea3e2a0b5dd71", size = 23491, upload-time = "2023-11-07T15:44:27.149Z" }, +] + +[[package]] +name = "pytest-metadata" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/85/8c969f8bec4e559f8f2b958a15229a35495f5b4ce499f6b865eac54b878d/pytest_metadata-3.1.1.tar.gz", hash = "sha256:d2a29b0355fbc03f168aa96d41ff88b1a3b44a3b02acbe491801c98a048017c8", size = 9952, upload-time = "2024-02-12T19:38:44.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b", size = 11428, upload-time = "2024-02-12T19:38:42.531Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, +] + +[[package]] +name = "python-cinderclient" +version = "9.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "keystoneauth1" }, + { name = "oslo-i18n" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "prettytable" }, + { name = "requests" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/35/f597e287af7c5a7245ab8c873295e4befa395555438ce75369a364577ca5/python_cinderclient-9.9.0.tar.gz", hash = "sha256:697e4d12c249f39b41ecf4fa6fcb8c38cbf2d6b2d84d6f515ed567b82dcd0bd1", size = 236806, upload-time = "2026-03-02T09:57:21.567Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/bb/03659e4ab881a1b2828a37662d18b679be2f93f3163094c4efcaead8e62d/python_cinderclient-9.9.0-py3-none-any.whl", hash = "sha256:1c7a5cb0d8202225fd8e7abc60b7cee6e23a5a2ba44f9afb0f92408330486e39", size = 256610, upload-time = "2026-03-02T09:57:19.651Z" }, +] + +[[package]] +name = "python-daemon" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lockfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/37/4f10e37bdabc058a32989da2daf29e57dc59dbc5395497f3d36d5f5e2694/python_daemon-3.1.2.tar.gz", hash = "sha256:f7b04335adc473de877f5117e26d5f1142f4c9f7cd765408f0877757be5afbf4", size = 71576, upload-time = "2024-12-03T08:41:07.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/3c/b88167e2d6785c0e781ee5d498b07472aeb9b6765da3b19e7cc9e0813841/python_daemon-3.1.2-py3-none-any.whl", hash = "sha256:b906833cef63502994ad48e2eab213259ed9bb18d54fa8774dcba2ff7864cec6", size = 30872, upload-time = "2024-12-03T08:41:03.322Z" }, +] + +[[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 = "python-discovery" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/7e/9f3b0dd3a074a6c3e1e79f35e465b1f2ee4b262d619de00cfce523cc9b24/python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5", size = 56945, upload-time = "2026-03-10T15:08:15.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485, upload-time = "2026-03-10T15:08:13.06Z" }, +] + +[[package]] +name = "python-keystoneclient" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "debtcollector" }, + { name = "keystoneauth1" }, + { name = "oslo-config" }, + { name = "oslo-i18n" }, + { name = "oslo-serialization" }, + { name = "oslo-utils" }, + { name = "packaging" }, + { name = "pbr" }, + { name = "requests" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/ef/c8c68219a2bf9f296ad18cb0b9804c45adfdceee72d51684225488746262/python_keystoneclient-5.8.0.tar.gz", hash = "sha256:3ca87c67c404298ce862310b569f545a58acf75cd5685094c82f35320b3a355d", size = 322844, upload-time = "2026-02-27T15:36:24.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/35/483db648710bd0aeefd505db288490c229fef0e1fb7b78d8f277e2dc375b/python_keystoneclient-5.8.0-py3-none-any.whl", hash = "sha256:0cc96cc719bbd8b1ca8fda71e38017dba3e87ebe3b479fcc9261a67e30b21b85", size = 397325, upload-time = "2026-02-27T15:36:23.21Z" }, +] + +[[package]] +name = "python-openstackclient" +version = "8.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cliff" }, + { name = "cryptography" }, + { name = "iso8601" }, + { name = "openstacksdk" }, + { name = "osc-lib" }, + { name = "oslo-i18n" }, + { name = "pbr" }, + { name = "python-cinderclient" }, + { name = "python-keystoneclient" }, + { name = "requests" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/8f/7852b32aa5759ab9333b5ad50834304e61bf06b87fa01ad82063e12411aa/python_openstackclient-8.2.0.tar.gz", hash = "sha256:d612af18dfc66cc8f31e6ce96690b6c273ade8a240ec40b7f4835a8896fbbe01", size = 930209, upload-time = "2025-08-21T09:35:00.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/b0/7d34b084e2b190c0dc3fc149ff28b6a5e50c333b1443388d1970c3a8b9e5/python_openstackclient-8.2.0-py3-none-any.whl", hash = "sha256:3404243629974547de1c8c06e78438ee38c6a66ab1620da4d5200127d50c99d3", size = 1084482, upload-time = "2025-08-21T09:34:58.595Z" }, +] + +[[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/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { 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" }, +] + +[[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 = "requestsexceptions" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/61b9652d3256503c99b0b8f145d9c8aa24c514caff6efc229989505937c1/requestsexceptions-1.4.0.tar.gz", hash = "sha256:b095cbc77618f066d459a02b137b020c37da9f46d9b057704019c9f77dba3065", size = 6880, upload-time = "2018-02-01T17:04:45.294Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/8c/49ca60ea8c907260da4662582c434bec98716177674e88df3fd340acf06d/requestsexceptions-1.4.0-py2.py3-none-any.whl", hash = "sha256:3083d872b6e07dc5c323563ef37671d992214ad9a32b0ca4a3d7f5500bf38ce3", size = 3802, upload-time = "2018-02-01T17:04:39.07Z" }, +] + +[[package]] +name = "resolvelib" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/10/f699366ce577423cbc3df3280063099054c23df70856465080798c6ebad6/resolvelib-1.0.1.tar.gz", hash = "sha256:04ce76cbd63fded2078ce224785da6ecd42b9564b1390793f64ddecbe997b309", size = 21065, upload-time = "2023-03-09T05:10:38.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fc/e9ccf0521607bcd244aa0b3fbd574f71b65e9ce6a112c83af988bbbe2e23/resolvelib-1.0.1-py2.py3-none-any.whl", hash = "sha256:d2da45d1a8dfee81bdd591647783e340ef3bcb104b54c383f70d422ef5cc7dbf", size = 17194, upload-time = "2023-03-09T05:10:36.214Z" }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, +] + +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + +[[package]] +name = "rich-argparse" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/f7/1c65e0245d4c7009a87ac92908294a66e7e7635eccf76a68550f40c6df80/rich_argparse-1.7.2.tar.gz", hash = "sha256:64fd2e948fc96e8a1a06e0e72c111c2ce7f3af74126d75c0f5f63926e7289cd1", size = 38500, upload-time = "2025-11-01T10:35:44.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/80/97b6f357ac458d9ad9872cc3183ca09ef7439ac89e030ea43053ba1294b6/rich_argparse-1.7.2-py3-none-any.whl", hash = "sha256:0559b1f47a19bbeb82bf15f95a057f99bcbbc98385532f57937f9fc57acc501a", size = 25476, upload-time = "2025-11-01T10:35:42.681Z" }, +] + +[[package]] +name = "rich-click" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/4d/e8fcbd785a93dc5d7aef38f8aa4ade1e31b0c820eb2e8ff267056eda70b1/rich_click-1.9.2.tar.gz", hash = "sha256:1c4212f05561be0cac6a9c1743e1ebcd4fe1fb1e311f9f672abfada3be649db6", size = 73533, upload-time = "2025-10-04T21:56:25.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/27/7a82106d69738aefb81e044d6dd278053c5263581c5e8e5330e1339b8444/rich_click-1.9.2-py3-none-any.whl", hash = "sha256:5079dad67ed7df434a9ec1f20b1d62d831e58c78740026f968ce3d3b861f01a0", size = 70153, upload-time = "2025-10-04T21:56:24.066Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[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 = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx-click" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "docutils" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/0a/5b1e8d0579dbb4ca8114e456ca4a68020bfe8e15c7001f3856be4929ab83/sphinx_click-6.0.0.tar.gz", hash = "sha256:f5d664321dc0c6622ff019f1e1c84e58ce0cecfddeb510e004cf60c2a3ab465b", size = 29574, upload-time = "2024-05-15T14:49:17.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/d7/8621c4726ad3f788a1db4c0c409044b16edc563f5c9542807b3724037555/sphinx_click-6.0.0-py3-none-any.whl", hash = "sha256:1e0a3c83bcb7c55497751b19d07ebe56b5d7b85eb76dd399cf9061b497adc317", size = 9922, upload-time = "2024-05-15T14:49:15.768Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/44/c97faec644d29a5ceddd3020ae2edffa69e7d00054a8c7a6021e82f20335/sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85", size = 7620463, upload-time = "2024-11-13T11:06:04.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/77/46e3bac77b82b4df5bb5b61f2de98637724f246b4966cfc34bc5895d852a/sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13", size = 7655561, upload-time = "2024-11-13T11:06:02.094Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "stdlib-list" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/25/f1540879c8815387980e56f973e54605bd924612399ace31487f7444171c/stdlib_list-0.12.0.tar.gz", hash = "sha256:517824f27ee89e591d8ae7c1dd9ff34f672eae50ee886ea31bb8816d77535675", size = 60923, upload-time = "2025-10-24T19:21:22.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/3d/2970b27a11ae17fb2d353e7a179763a2fe6f37d6d2a9f4d40104a2f132e9/stdlib_list-0.12.0-py3-none-any.whl", hash = "sha256:df2d11e97f53812a1756fb5510393a11e3b389ebd9239dc831c7f349957f62f2", size = 87615, upload-time = "2025-10-24T19:21:20.619Z" }, +] + +[[package]] +name = "stevedore" +version = "5.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[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-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.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268, upload-time = "2024-12-22T07:47:30.032Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369, upload-time = "2024-12-22T07:47:28.074Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + +[[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 = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/d2/387594fb592d027366645f3d7cc9b4d7ca7be93845fbaba6d835a912ef3c/wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c", size = 60669, upload-time = "2026-03-06T02:52:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/c9/18/3f373935bc5509e7ac444c8026a56762e50c1183e7061797437ca96c12ce/wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f", size = 61603, upload-time = "2026-03-06T02:54:21.032Z" }, + { url = "https://files.pythonhosted.org/packages/c2/7a/32758ca2853b07a887a4574b74e28843919103194bb47001a304e24af62f/wrapt-2.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5681123e60aed0e64c7d44f72bbf8b4ce45f79d81467e2c4c728629f5baf06eb", size = 113632, upload-time = "2026-03-06T02:53:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d5/eeaa38f670d462e97d978b3b0d9ce06d5b91e54bebac6fbed867809216e7/wrapt-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8b28e97a44d21836259739ae76284e180b18abbb4dcfdff07a415cf1016c3e", size = 115644, upload-time = "2026-03-06T02:54:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/e3/09/2a41506cb17affb0bdf9d5e2129c8c19e192b388c4c01d05e1b14db23c00/wrapt-2.1.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef91c95a50596fcdc31397eb6955476f82ae8a3f5a8eabdc13611b60ee380ba", size = 112016, upload-time = "2026-03-06T02:54:43.274Z" }, + { url = "https://files.pythonhosted.org/packages/64/15/0e6c3f5e87caadc43db279724ee36979246d5194fa32fed489c73643ba59/wrapt-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dad63212b168de8569b1c512f4eac4b57f2c6934b30df32d6ee9534a79f1493f", size = 114823, upload-time = "2026-03-06T02:54:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/56/b2/0ad17c8248f4e57bedf44938c26ec3ee194715f812d2dbbd9d7ff4be6c06/wrapt-2.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d307aa6888d5efab2c1cde09843d48c843990be13069003184b67d426d145394", size = 111244, upload-time = "2026-03-06T02:54:02.149Z" }, + { url = "https://files.pythonhosted.org/packages/ff/04/bcdba98c26f2c6522c7c09a726d5d9229120163493620205b2f76bd13c01/wrapt-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c87cf3f0c85e27b3ac7d9ad95da166bf8739ca215a8b171e8404a2d739897a45", size = 113307, upload-time = "2026-03-06T02:54:12.428Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1b/5e2883c6bc14143924e465a6fc5a92d09eeabe35310842a481fb0581f832/wrapt-2.1.2-cp310-cp310-win32.whl", hash = "sha256:d1c5fea4f9fe3762e2b905fdd67df51e4be7a73b7674957af2d2ade71a5c075d", size = 57986, upload-time = "2026-03-06T02:54:26.823Z" }, + { url = "https://files.pythonhosted.org/packages/42/5a/4efc997bccadd3af5749c250b49412793bc41e13a83a486b2b54a33e240c/wrapt-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:d8f7740e1af13dff2684e4d56fe604a7e04d6c94e737a60568d8d4238b9a0c71", size = 60336, upload-time = "2026-03-06T02:54:18Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f5/a2bb833e20181b937e87c242645ed5d5aa9c373006b0467bfe1a35c727d0/wrapt-2.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:1c6cc827c00dc839350155f316f1f8b4b0c370f52b6a19e782e2bda89600c7dc", size = 58757, upload-time = "2026-03-06T02:53:51.545Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" }, + { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" }, + { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] From 764ce68831781fa4b8bf4892e5339d5605aceff6 Mon Sep 17 00:00:00 2001 From: Kamil Rajtar Date: Tue, 23 Jun 2026 15:08:54 +0200 Subject: [PATCH 06/12] fix: allow fixed callback port via EWC_CLI_OIDC_CALLBACK_PORT env var --- ewccli/backends/keycloak/callback_server.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ewccli/backends/keycloak/callback_server.py b/ewccli/backends/keycloak/callback_server.py index 8a958a9..1c62da6 100644 --- a/ewccli/backends/keycloak/callback_server.py +++ b/ewccli/backends/keycloak/callback_server.py @@ -1,5 +1,6 @@ """Lightweight HTTP server to receive the OIDC authorization code callback.""" +import os import threading from http.server import BaseHTTPRequestHandler, HTTPServer from typing import Optional @@ -41,9 +42,10 @@ def __init__(self, expected_state: str): self.port: int = 0 def start(self) -> None: - """Start the server on a random loopback port.""" + """Start the server on a loopback port.""" handler = self._make_handler() - self._httpd = HTTPServer(("127.0.0.1", 0), handler) + port = int(os.getenv("EWC_CLI_OIDC_CALLBACK_PORT", "0")) + self._httpd = HTTPServer(("127.0.0.1", port), handler) self.port = self._httpd.server_address[1] self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) self._thread.start() From 650c3d4010454f088f3ff6f9e6d66b27758a193e Mon Sep 17 00:00:00 2001 From: Kamil Rajtar Date: Tue, 23 Jun 2026 16:40:43 +0200 Subject: [PATCH 07/12] =?UTF-8?q?refactor:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20drop=20dead=20token=5Fmanager,=20clean=20up=20login?= =?UTF-8?q?=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove token_manager.py and its test: get_valid_access_token() was never called; refresh tokens were persisted to disk for no functional benefit. Move _compute_expires_at inline into keycloak_backend.py. - Remove .idea/ IDE files and add .idea/ to .gitignore. - Revert whitespace-only reformatting in commons_infra.py; keep only the new connect_to_openstack_backend helper function. - Collapse triple federee/region resolution in login_command.py: run Keycloak first, then do a single interactive-prompt + validation pass. - Replace xdg-open subprocess with webbrowser.open directly (cross-platform). --- .gitignore | 1 + .idea/.gitignore | 10 -- .idea/ewccli.iml | 11 -- .idea/misc.xml | 6 - .idea/modules.xml | 8 - .idea/vcs.xml | 7 - ewccli/backends/keycloak/keycloak_backend.py | 27 ++- ewccli/backends/keycloak/token_manager.py | 159 ---------------- ewccli/commands/commons_infra.py | 179 +++++++++---------- ewccli/commands/login_command.py | 135 ++++---------- ewccli/tests/ewccli_config_test.py | 52 ------ ewccli/tests/test_keycloak_backend.py | 8 +- ewccli/tests/test_keycloak_token_manager.py | 171 ------------------ ewccli/utils.py | 20 --- 14 files changed, 139 insertions(+), 655 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/ewccli.iml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml delete mode 100644 ewccli/backends/keycloak/token_manager.py delete mode 100644 ewccli/tests/test_keycloak_token_manager.py diff --git a/.gitignore b/.gitignore index 576debf..84f0f34 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ coverage.xml report.xml **/.local CHANGELOG_UNRELEASED.md +.idea/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index ab1f416..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Ignored default folder with query files -/queries/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/.idea/ewccli.iml b/.idea/ewccli.iml deleted file mode 100644 index 7b28c72..0000000 --- a/.idea/ewccli.iml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 4b151ab..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index fb7ab4d..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 8306744..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/ewccli/backends/keycloak/keycloak_backend.py b/ewccli/backends/keycloak/keycloak_backend.py index 914fd85..49160fb 100644 --- a/ewccli/backends/keycloak/keycloak_backend.py +++ b/ewccli/backends/keycloak/keycloak_backend.py @@ -1,8 +1,8 @@ """Keycloak login orchestrator — ties PKCE, callback, OIDC, and portal together.""" -import subprocess import webbrowser from dataclasses import dataclass +from datetime import datetime, timezone, timedelta from typing import Optional from click import ClickException @@ -12,13 +12,18 @@ from ewccli.backends.keycloak.oidc_client import OIDCClient from ewccli.backends.keycloak.pkce import generate_pkce_pair, generate_state from ewccli.backends.keycloak.portal_client import PortalClient -from ewccli.backends.keycloak.token_manager import _compute_expires_at from ewccli.logger import get_logger _LOGGER = get_logger(__name__) _console = Console() +def _compute_expires_at(expires_in: int) -> str: + """Compute the absolute expiry timestamp from an expires_in value.""" + expiry = datetime.now(timezone.utc) + timedelta(seconds=expires_in) + return expiry.isoformat() + + @dataclass class KeycloakLoginResult: """Result of a successful Keycloak login.""" @@ -96,21 +101,13 @@ def keycloak_login( if open_browser: try: - subprocess.Popen( - ["xdg-open", auth_url], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) + webbrowser.open(auth_url) _console.print("[green]Browser opened automatically.[/green]") except Exception: - try: - webbrowser.open(auth_url) - _console.print("[green]Browser opened automatically.[/green]") - except Exception: - _console.print( - "[yellow]Could not open browser automatically. " - "Please copy the URL above manually.[/yellow]" - ) + _console.print( + "[yellow]Could not open browser automatically. " + "Please copy the URL above manually.[/yellow]" + ) else: _console.print( "[yellow]--no-browser: copy the URL above manually.[/yellow]" diff --git a/ewccli/backends/keycloak/token_manager.py b/ewccli/backends/keycloak/token_manager.py deleted file mode 100644 index cc929b6..0000000 --- a/ewccli/backends/keycloak/token_manager.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Token manager — handles silent OIDC token refresh with rotation.""" - -from datetime import datetime, timezone, timedelta -from typing import Optional -from pathlib import Path -from configparser import ConfigParser -import os - -from click import ClickException - -from ewccli.backends.keycloak.oidc_client import OIDCClient -from ewccli.configuration import config as ewc_hub_config -from ewccli.logger import get_logger - -_LOGGER = get_logger(__name__) - -# Refresh if the access token expires within this many seconds -_REFRESH_SKEW_SECONDS = 60 - - -def _parse_iso_timestamp(ts: Optional[str]) -> Optional[datetime]: - """Parse an ISO 8601 timestamp string into a timezone-aware datetime.""" - if not ts: - return None - try: - # Handle both with and without 'Z' suffix - clean = ts.replace("Z", "+00:00") - return datetime.fromisoformat(clean) - except (ValueError, TypeError): - return None - - -def _is_expired(expires_at: Optional[datetime], skew_seconds: int = _REFRESH_SKEW_SECONDS) -> bool: - """Check if a token is expired or about to expire (within skew window).""" - if expires_at is None: - return True - now = datetime.now(timezone.utc) - return now >= (expires_at - timedelta(seconds=skew_seconds)) - - -def _compute_expires_at(expires_in: int) -> str: - """Compute the absolute expiry timestamp from an expires_in value.""" - expiry = datetime.now(timezone.utc) + timedelta(seconds=expires_in) - return expiry.isoformat() - - -def get_valid_access_token( - profile: dict, - profiles_file_path: Optional[Path] = None, -) -> str: - """Return a valid access token, refreshing if necessary. - - This function checks if the stored access token is still valid. If not, - it attempts a silent refresh using the stored refresh token. On success, - it updates the profile INI file with the new tokens (rotation). On failure, - it raises a ClickException telling the user to re-authenticate. - - Args: - profile: The loaded CLI profile dict (from load_cli_profile()). - profiles_file_path: Path to the profiles INI file. Defaults to the - standard EWC_CLI_PROFILES_PATH. - - Returns: - A valid access token string. - - Raises: - ClickException: If the refresh token is missing, expired, or invalid. - """ - if profiles_file_path is None: - profiles_file_path = ewc_hub_config.EWC_CLI_PROFILES_PATH - - access_token = profile.get("keycloak_access_token") - expires_at_str = profile.get("keycloak_token_expires_at") - refresh_token = profile.get("keycloak_refresh_token") - - # If the access token is still valid, return it - expires_at = _parse_iso_timestamp(expires_at_str) - if access_token and not _is_expired(expires_at): - return access_token - - # Token is expired or about to expire — try to refresh - if not refresh_token: - raise ClickException( - "Your EWC session has expired. " - "Please run: ewc login --keycloak" - ) - - _LOGGER.info("Access token expired, attempting silent refresh...") - - oidc_client = OIDCClient( - keycloak_url=ewc_hub_config.EWC_CLI_KEYCLOAK_URL, - realm=ewc_hub_config.EWC_CLI_KEYCLOAK_REALM, - client_id=ewc_hub_config.EWC_CLI_KEYCLOAK_CLIENT_ID, - scope=ewc_hub_config.EWC_CLI_KEYCLOAK_SCOPE, - ) - - try: - new_tokens = oidc_client.refresh_tokens(refresh_token=refresh_token) - except Exception as e: - raise ClickException( - f"Your EWC session has expired and could not be refreshed: {e}. " - "Please run: ewc login --keycloak" - ) - - new_access_token = new_tokens.get("access_token") - new_refresh_token = new_tokens.get("refresh_token") - new_expires_in = new_tokens.get("expires_in", 300) - new_expires_at = _compute_expires_at(new_expires_in) - - if not new_access_token: - raise ClickException( - "Token refresh succeeded but no access_token was returned. " - "Please run: ewc login --keycloak" - ) - - # Update the profile INI with the rotated tokens - _update_profile_tokens( - profiles_file_path=profiles_file_path, - profile_name=profile.get("profile"), - access_token=new_access_token, - refresh_token=new_refresh_token, - expires_at=new_expires_at, - id_token=new_tokens.get("id_token"), - ) - - _LOGGER.info("Successfully refreshed OIDC tokens.") - - return new_access_token - - -def _update_profile_tokens( - profiles_file_path: Path, - profile_name: Optional[str], - access_token: str, - refresh_token: Optional[str], - expires_at: str, - id_token: Optional[str] = None, -) -> None: - """Update the OIDC token fields in the profile INI file.""" - if not profile_name: - _LOGGER.warning("No profile name provided, cannot persist refreshed tokens.") - return - - cfg = ConfigParser() - cfg.read(profiles_file_path) - - if profile_name not in cfg: - _LOGGER.warning(f"Profile '{profile_name}' not found, cannot persist refreshed tokens.") - return - - cfg[profile_name]["keycloak_access_token"] = access_token - if refresh_token: - cfg[profile_name]["keycloak_refresh_token"] = refresh_token - if id_token: - cfg[profile_name]["keycloak_id_token"] = id_token - cfg[profile_name]["keycloak_token_expires_at"] = expires_at - - with open(profiles_file_path, "w") as f: - cfg.write(f) diff --git a/ewccli/commands/commons_infra.py b/ewccli/commands/commons_infra.py index ebc4dda..fea10eb 100644 --- a/ewccli/commands/commons_infra.py +++ b/ewccli/commands/commons_infra.py @@ -34,9 +34,9 @@ def check_user_ssh_keys( - ssh_public_key_path: Optional[str] = None, - ssh_private_key_path: Optional[str] = None, - dry_run: bool = False + ssh_public_key_path: Optional[str] = None, + ssh_private_key_path: Optional[str] = None, + dry_run: bool = False ): """Check if SSH keys are compatible or missing.""" if dry_run: @@ -72,13 +72,13 @@ def check_user_ssh_keys( def check_server_conflict_with_inputs( - server_info: dict, - server_info_image: Optional[str] = None, - image_name: Optional[str] = None, - keypair_name: Optional[str] = None, - flavour_name: Optional[str] = None, - networks: Optional[tuple] = None, - security_groups: Optional[tuple] = None, + server_info: dict, + server_info_image: Optional[str] = None, + image_name: Optional[str] = None, + keypair_name: Optional[str] = None, + flavour_name: Optional[str] = None, + networks: Optional[tuple] = None, + security_groups: Optional[tuple] = None, ): """Check if user-provided values conflict with an existing server.""" if not server_info: @@ -138,11 +138,11 @@ def _get_security_groups_string(server_info): def show_server_input_requested_summary( - security_groups: tuple, - networks: tuple, - image_name: Optional[str] = None, - flavour_name: Optional[str] = None, - keypair_name: Optional[str] = None, + security_groups: tuple, + networks: tuple, + image_name: Optional[str] = None, + flavour_name: Optional[str] = None, + keypair_name: Optional[str] = None, ): """Print table with inputs for the server.""" table = Table( @@ -219,12 +219,12 @@ def check_ssh_keys_exist(ssh_public_key_path: Path, ssh_private_key_path: Path) if missing_msgs: panel_content = ( - "\n".join(missing_msgs) - + "\n\n" - + "[bold yellow]Tip:[/bold yellow] You can run ewc login and create them.\n" - + "[bold yellow]Tip:[/bold yellow] You can specify custom paths with:\n" - + '[green]export EWC_CLI_SSH_PRIVATE_KEY_PATH="/path/to/id_rsa"[/green]\n' - + '[green]export EWC_CLI_SSH_PUBLIC_KEY_PATH="/path/to/id_rsa.pub"[/green]' + "\n".join(missing_msgs) + + "\n\n" + + "[bold yellow]Tip:[/bold yellow] You can run ewc login and create them.\n" + + "[bold yellow]Tip:[/bold yellow] You can specify custom paths with:\n" + + '[green]export EWC_CLI_SSH_PRIVATE_KEY_PATH="/path/to/id_rsa"[/green]\n' + + '[green]export EWC_CLI_SSH_PUBLIC_KEY_PATH="/path/to/id_rsa.pub"[/green]' ) console.print( @@ -238,9 +238,9 @@ def check_ssh_keys_exist(ssh_public_key_path: Path, ssh_private_key_path: Path) def normalize_os_image( - image_name: str, - federee: str, - region: str + image_name: str, + federee: str, + region: str ) -> tuple[str | None, bool]: """ Normalize OS image names provided. @@ -314,13 +314,13 @@ def normalize_os_image( def resolve_image_and_flavor( - conn: connection.Connection, - openstack_backend: OpenstackBackend, - federee: str, - region: str, - flavour_name: Optional[str] = None, - image_name: Optional[str] = None, - is_gpu: bool = False, + conn: connection.Connection, + openstack_backend: OpenstackBackend, + federee: str, + region: str, + flavour_name: Optional[str] = None, + image_name: Optional[str] = None, + is_gpu: bool = False, ) -> Tuple[int, str, Dict[str, str]]: """ Resolve both the image and flavor for the given federee. @@ -390,8 +390,8 @@ def resolve_image_and_flavor( # Now check the image provided and verify is supported. if not normalized_image_name: - total_images = ewc_hub_config.EWC_CLI_CPU_IMAGES + [ - ewc_hub_config.EWC_CLI_GPU_IMAGES_SITE_MAP[federee][region]] + + total_images = ewc_hub_config.EWC_CLI_CPU_IMAGES + [ewc_hub_config.EWC_CLI_GPU_IMAGES_SITE_MAP[federee][region]] error_message = ( f"❌ Unsupported OS image for the EWC CLI: {image_name}\n\n" f"🖥️ EWC Supported images (short names): [bold green]{', '.join(total_images)}[/bold green]\n" @@ -410,8 +410,7 @@ def resolve_image_and_flavor( ) # if users use long names, let's check if they are using the latest known image and give them a warning in case. - if image_name not in [ - ewc_hub_config.EWC_CLI_GPU_IMAGES_SITE_MAP[federee][region]] and not is_short_name and latest_image: + if image_name not in [ewc_hub_config.EWC_CLI_GPU_IMAGES_SITE_MAP[federee][region]] and not is_short_name and latest_image: if latest_image.name != image_name: _LOGGER.warning( f"You are not using latest image for {image_name}." @@ -450,8 +449,8 @@ def resolve_image_and_flavor( def resolve_machine_ip( - federee: str, - server_info: dict, + federee: str, + server_info: dict, ) -> Tuple[int, str, Optional[Dict[str, Optional[str]]]]: """ Resolve the internal and external IPs of a machine. @@ -521,9 +520,9 @@ def resolve_machine_ip( def get_deployed_server_info( - federee: str, - server_info: dict, - image_name: Optional[str] = None, + federee: str, + server_info: dict, + image_name: Optional[str] = None, ): """Get deployed server info.""" _LOGGER.debug(server_info) @@ -572,7 +571,7 @@ def get_deployed_server_info( def list_server_details( - vm_info: dict, + vm_info: dict, ): """Print detailed info of a single server in a two-column table.""" console = Console() @@ -607,17 +606,17 @@ def list_server_details( def pre_deploy_server_setup( - openstack_backend: OpenstackBackend, - openstack_api: connection.Connection, - federee: str, - region: str, - server_inputs: dict, - ssh_public_key_path: str, - ssh_private_key_path: str, - ssh_private_encoded: Optional[str] = None, - ssh_public_encoded: Optional[str] = None, - dry_run: bool = False, - force: bool = False, + openstack_backend: OpenstackBackend, + openstack_api: connection.Connection, + federee: str, + region: str, + server_inputs: dict, + ssh_public_key_path: str, + ssh_private_key_path: str, + ssh_private_encoded: Optional[str] = None, + ssh_public_encoded: Optional[str] = None, + dry_run: bool = False, + force: bool = False, ): """Pre deploy server setup steps: @@ -787,9 +786,9 @@ def pre_deploy_server_setup( def identify_server_reconfiguration( - openstack_api: connection.Connection, - server_inputs: dict, - pre_deploy_server_outputs: dict + openstack_api: connection.Connection, + server_inputs: dict, + pre_deploy_server_outputs: dict ): """Identify resources to be reconfigured.""" outputs: dict[str, Optional[str]] = {} @@ -814,8 +813,8 @@ def identify_server_reconfiguration( if existing_server_info: if not ( - existing_server_info.metadata.get("deployed") - and existing_server_info.metadata.get("deployed") == "ewccli" + existing_server_info.metadata.get("deployed") + and existing_server_info.metadata.get("deployed") == "ewccli" ): return ( 1, @@ -859,14 +858,14 @@ def identify_server_reconfiguration( def deploy_server( - openstack_backend: OpenstackBackend, - openstack_api: connection.Connection, - federee: str, - server_inputs: dict, - pre_deploy_server_outputs: dict, - boot_from_volume: bool = False, - dry_run: bool = False, - force: bool = False, + openstack_backend: OpenstackBackend, + openstack_api: connection.Connection, + federee: str, + server_inputs: dict, + pre_deploy_server_outputs: dict, + boot_from_volume: bool = False, + dry_run: bool = False, + force: bool = False, ): """Deploy Server in Openstack.""" outputs: dict[str, Optional[str]] = {} @@ -959,12 +958,12 @@ def deploy_server( def post_deploy_server_setup( - openstack_backend: OpenstackBackend, - openstack_api: connection.Connection, - federee: str, - server_inputs: dict, - server_info: dict, - dry_run: bool = False, + openstack_backend: OpenstackBackend, + openstack_api: connection.Connection, + federee: str, + server_inputs: dict, + server_info: dict, + dry_run: bool = False, ): """Post deploy server setup steps: @@ -1048,17 +1047,17 @@ def post_deploy_server_setup( def create_server_command( - openstack_backend: OpenstackBackend, - openstack_api: connection.Connection, - federee: str, - region: str, - server_inputs: dict, - ssh_public_key_path: str, - ssh_private_key_path: str, - ssh_private_encoded: Optional[str] = None, - ssh_public_encoded: Optional[str] = None, - dry_run: bool = False, - force: bool = False, + openstack_backend: OpenstackBackend, + openstack_api: connection.Connection, + federee: str, + region: str, + server_inputs: dict, + ssh_public_key_path: str, + ssh_private_key_path: str, + ssh_private_encoded: Optional[str] = None, + ssh_public_encoded: Optional[str] = None, + dry_run: bool = False, + force: bool = False, ): """Create Server command.""" #### PRE DEPLOY SERVER ACTION @@ -1073,7 +1072,7 @@ def create_server_command( ssh_public_key_path=ssh_public_key_path, ssh_private_key_path=ssh_private_key_path, dry_run=dry_run, - force=force, + force=force, ) boot_from_volume = False @@ -1098,7 +1097,7 @@ def create_server_command( identify_server_reconfiguration( openstack_api=openstack_api, server_inputs=server_inputs, - pre_deploy_server_outputs=pre_deploy_server_outputs + pre_deploy_server_outputs=pre_deploy_server_outputs ) #### DEPLOY SERVER ACTION @@ -1141,14 +1140,12 @@ def create_server_command( def connect_to_openstack_backend( - ctx, - auth_url: Optional[str] = None, - application_credential_id: Optional[str] = None, - application_credential_secret: Optional[str] = None): - - # Step 1: Authenticate and initialize the OpenStack connection + ctx, + auth_url: Optional[str] = None, + application_credential_id: Optional[str] = None, + application_credential_secret: Optional[str] = None, +): try: - # Step 1: Authenticate and initialize the OpenStack connection openstack_api = ctx.openstack_backend.connect( auth_url=auth_url, application_credential_id=application_credential_id, diff --git a/ewccli/commands/login_command.py b/ewccli/commands/login_command.py index 61d862f..eb0417b 100644 --- a/ewccli/commands/login_command.py +++ b/ewccli/commands/login_command.py @@ -420,44 +420,54 @@ def init_command( # token: str, ): """EWC CLI Login.""" - if keycloak and not federee: - # When using keycloak without an explicit federee, defer selection - # until after the portal returns federee/region. - pass - elif not federee: - # If --federee is not passed, ask interactively + # --- Keycloak OIDC login path (run first; may fill federee/region/tenant_name) --- + if keycloak: + from ewccli.backends.keycloak.keycloak_backend import keycloak_login + + kc_result = keycloak_login( + config=ewc_hub_config, + open_browser=not no_browser, + federee=federee, + region=region, + ) + + if kc_result.application_credential_id: + application_credential_id = kc_result.application_credential_id + application_credential_secret = kc_result.application_credential_secret + + if kc_result.federee: + federee = kc_result.federee + if kc_result.region: + region = kc_result.region + if kc_result.tenant_name: + tenant_name = kc_result.tenant_name + + # --- Interactive prompts for whatever is still missing --- + if not federee: federee = select_federee() if not federee: console.print("No federee selection made. Exiting.") return - if federee: - console.print(f"Considering federee: {federee}") + console.print(f"Considering federee: {federee}") - if keycloak and not region: - pass - elif not region: - # If --federee is not passed, ask interactively + if not region: region = select_region(federee=federee) if not region: console.print("No region selection made. Exiting.") return - if federee and region: - allowed_regions = ewc_hub_config.allowed_regions(federee) + allowed_regions = ewc_hub_config.allowed_regions(federee) + if region not in allowed_regions: + raise click.BadParameter( + f"Region '{region}' is not valid for federee '{federee}'. " + f"Allowed: {', '.join(allowed_regions)}" + ) - if region not in allowed_regions: - raise click.BadParameter( - f"Region '{region}' is not valid for federee '{federee}'. " - f"Allowed: {', '.join(allowed_regions)}" - ) + if not tenant_name: + tenant_name = click.prompt("Tenant name") - # When using keycloak without explicit profile/federee/region, defer - # profile resolution until after the OIDC flow (which may fill them in). - if keycloak and not profile and not (federee and region and tenant_name): - resolved_profile = None - else: - resolved_profile = _resolve_profile(profile, federee, region, tenant_name) + resolved_profile = _resolve_profile(profile, federee, region, tenant_name) profiles_file_path = ewc_hub_config.EWC_CLI_PROFILES_PATH cfg = ConfigParser() @@ -466,8 +476,7 @@ def init_command( if not os.path.exists(profiles_file_path) or not cfg.sections(): pass else: - # Check only when the profile path exist - if resolved_profile and resolved_profile in cfg: + if resolved_profile in cfg: click.secho( f"❌ Profile '{resolved_profile}' already exists in {ewc_hub_config.EWC_CLI_PROFILES_PATH}", fg="red", @@ -479,74 +488,6 @@ def init_command( ) raise click.Abort() - # If tenant_name is missing and not using keycloak, prompt for it - if not keycloak and not tenant_name: - tenant_name = click.prompt("Tenant name") - - # --- Keycloak OIDC login path --- - keycloak_access_token = None - keycloak_refresh_token = None - keycloak_id_token = None - keycloak_token_expires_at = None - - if keycloak: - from ewccli.backends.keycloak.keycloak_backend import keycloak_login - - kc_result = keycloak_login( - config=ewc_hub_config, - open_browser=not no_browser, - federee=federee, - region=region, - ) - - # If the portal returned credentials, use them - if kc_result.application_credential_id: - application_credential_id = kc_result.application_credential_id - application_credential_secret = kc_result.application_credential_secret - - # If the portal returned federee/region/tenant_name, use them - if kc_result.federee: - federee = kc_result.federee - if kc_result.region: - region = kc_result.region - if kc_result.tenant_name: - tenant_name = kc_result.tenant_name - - # Store OIDC tokens for future refresh - keycloak_access_token = kc_result.access_token - keycloak_refresh_token = kc_result.refresh_token - keycloak_id_token = kc_result.id_token - keycloak_token_expires_at = kc_result.token_expires_at - - # Re-resolve profile now that keycloak may have filled in - # federee/region/tenant_name. - if keycloak and not profile and not (federee and region and tenant_name): - # Still missing values — prompt interactively for the ones we don't have - if not federee: - federee = select_federee() - if not federee: - console.print("No federee selection made. Exiting.") - return - console.print(f"Considering federee: {federee}") - - if not region: - region = select_region(federee=federee) - if not region: - console.print("No region selection made. Exiting.") - return - - if not tenant_name: - tenant_name = click.prompt("Tenant name") - - allowed_regions = ewc_hub_config.allowed_regions(federee) - if region not in allowed_regions: - raise click.BadParameter( - f"Region '{region}' is not valid for federee '{federee}'. " - f"Allowed: {', '.join(allowed_regions)}" - ) - - resolved_profile = _resolve_profile(profile, federee, region, tenant_name) - ssh_private_key_path_to_save, ssh_public_key_path_to_save = check_and_generate_ssh_keys( ssh_public_key_path=ssh_public_key_path, ssh_private_key_path=ssh_private_key_path, @@ -621,10 +562,6 @@ def init_command( # token=token, application_credential_id=application_credential_id, application_credential_secret=application_credential_secret, - keycloak_access_token=keycloak_access_token, - keycloak_refresh_token=keycloak_refresh_token, - keycloak_id_token=keycloak_id_token, - keycloak_token_expires_at=keycloak_token_expires_at, ) console.print( diff --git a/ewccli/tests/ewccli_config_test.py b/ewccli/tests/ewccli_config_test.py index e097370..76262cf 100644 --- a/ewccli/tests/ewccli_config_test.py +++ b/ewccli/tests/ewccli_config_test.py @@ -143,55 +143,3 @@ def test_overwrite_profile_not_allowed(profile_file_path, ssh_paths): ) -def test_save_and_load_profile_with_oidc_tokens(profile_file_path, ssh_paths): - ssh_private, ssh_public = ssh_paths - - save_cli_profile( - federee="EUMETSAT", - region="ECIS-R1", - tenant_name="TeamA", - ssh_private_key_path_to_save=ssh_private, - ssh_public_key_path_to_save=ssh_public, - application_credential_id="app-id", - application_credential_secret="app-secret", - keycloak_access_token="access123", - keycloak_refresh_token="refresh456", - keycloak_id_token="id789", - keycloak_token_expires_at="2026-06-23T12:00:00+00:00", - profiles_file_path=str(profile_file_path), - ) - - data = load_cli_profile( - profile="eumetsat-ecis-r1-teama", - profiles_file_path=str(profile_file_path), - ) - - assert data["keycloak_access_token"] == "access123" - assert data["keycloak_refresh_token"] == "refresh456" - assert data["keycloak_id_token"] == "id789" - assert data["keycloak_token_expires_at"] == "2026-06-23T12:00:00+00:00" - - -def test_load_profile_without_oidc_tokens_returns_none(profile_file_path, ssh_paths): - """Profiles saved without OIDC tokens should load fine with None.""" - ssh_private, ssh_public = ssh_paths - - save_cli_profile( - federee="EUMETSAT", - region="ECIS-R1", - tenant_name="TeamA", - ssh_private_key_path_to_save=ssh_private, - ssh_public_key_path_to_save=ssh_public, - application_credential_id="app-id", - application_credential_secret="app-secret", - profiles_file_path=str(profile_file_path), - ) - - data = load_cli_profile( - profile="eumetsat-ecis-r1-teama", - profiles_file_path=str(profile_file_path), - ) - - assert data.get("keycloak_access_token") is None - assert data.get("keycloak_refresh_token") is None - assert data.get("keycloak_token_expires_at") is None diff --git a/ewccli/tests/test_keycloak_backend.py b/ewccli/tests/test_keycloak_backend.py index e51243a..9ce6b22 100644 --- a/ewccli/tests/test_keycloak_backend.py +++ b/ewccli/tests/test_keycloak_backend.py @@ -30,7 +30,6 @@ def mock_config_no_portal(): return config -@patch("ewccli.backends.keycloak.keycloak_backend.subprocess") @patch("ewccli.backends.keycloak.keycloak_backend.webbrowser") @patch("ewccli.backends.keycloak.keycloak_backend.PortalClient") @patch("ewccli.backends.keycloak.keycloak_backend.OIDCClient") @@ -40,7 +39,6 @@ def test_keycloak_login_success( mock_oidc_cls, mock_portal_cls, mock_webbrowser, - mock_subprocess, mock_config, ): # Callback server @@ -90,10 +88,8 @@ def test_keycloak_login_success( assert result.federee == "EUMETSAT" assert result.region == "ECIS-R1" - # Browser was opened (via subprocess.Popen) - mock_subprocess.Popen.assert_called_once() - # webbrowser.open should not be called (subprocess takes priority) - mock_webbrowser.open.assert_not_called() + # Browser was opened via webbrowser.open + mock_webbrowser.open.assert_called_once() # Callback server was started and stopped mock_server.start.assert_called_once() diff --git a/ewccli/tests/test_keycloak_token_manager.py b/ewccli/tests/test_keycloak_token_manager.py deleted file mode 100644 index 06b78d8..0000000 --- a/ewccli/tests/test_keycloak_token_manager.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Tests for the token manager.""" -import pytest -from datetime import datetime, timezone, timedelta -from unittest.mock import patch, MagicMock -from click import ClickException - -from ewccli.backends.keycloak.token_manager import ( - _parse_iso_timestamp, - _is_expired, - _compute_expires_at, - get_valid_access_token, - _update_profile_tokens, -) - - -def test_parse_iso_timestamp_with_z(): - ts = _parse_iso_timestamp("2026-06-23T12:00:00Z") - assert ts is not None - assert ts.year == 2026 - assert ts.month == 6 - assert ts.day == 23 - - -def test_parse_iso_timestamp_with_offset(): - ts = _parse_iso_timestamp("2026-06-23T12:00:00+00:00") - assert ts is not None - assert ts.tzinfo is not None - - -def test_parse_iso_timestamp_none(): - assert _parse_iso_timestamp(None) is None - assert _parse_iso_timestamp("") is None - - -def test_parse_iso_timestamp_invalid(): - assert _parse_iso_timestamp("not-a-date") is None - - -def test_is_expired_with_past_time(): - past = datetime.now(timezone.utc) - timedelta(hours=1) - assert _is_expired(past) is True - - -def test_is_expired_with_future_time(): - future = datetime.now(timezone.utc) + timedelta(hours=1) - assert _is_expired(future) is False - - -def test_is_expired_with_soon_future(): - """Token expiring within the skew window should be considered expired.""" - soon = datetime.now(timezone.utc) + timedelta(seconds=30) - assert _is_expired(soon, skew_seconds=60) is True - - -def test_is_expired_none(): - assert _is_expired(None) is True - - -def test_compute_expires_at(): - expires_at = _compute_expires_at(300) - parsed = _parse_iso_timestamp(expires_at) - assert parsed is not None - # Should be about 5 minutes in the future - now = datetime.now(timezone.utc) - delta = parsed - now - assert 290 <= delta.total_seconds() <= 305 - - -def test_get_valid_access_token_returns_valid_token(): - """If the access token is not expired, return it without refreshing.""" - profile = { - "profile": "test", - "keycloak_access_token": "valid-token", - "keycloak_token_expires_at": ( - datetime.now(timezone.utc) + timedelta(hours=1) - ).isoformat(), - "keycloak_refresh_token": "refresh-token", - } - result = get_valid_access_token(profile) - assert result == "valid-token" - - -@patch("ewccli.backends.keycloak.token_manager.OIDCClient") -def test_get_valid_access_token_refreshes_expired_token(mock_oidc_cls): - mock_oidc = MagicMock() - mock_oidc.refresh_tokens.return_value = { - "access_token": "new-access", - "refresh_token": "new-refresh", - "id_token": "new-id", - "expires_in": 300, - } - mock_oidc_cls.return_value = mock_oidc - - profile = { - "profile": "test", - "keycloak_access_token": "old-access", - "keycloak_token_expires_at": ( - datetime.now(timezone.utc) - timedelta(hours=1) - ).isoformat(), - "keycloak_refresh_token": "old-refresh", - } - - with patch( - "ewccli.backends.keycloak.token_manager._update_profile_tokens" - ) as mock_update: - result = get_valid_access_token(profile) - assert result == "new-access" - mock_oidc.refresh_tokens.assert_called_once_with(refresh_token="old-refresh") - mock_update.assert_called_once() - - -def test_get_valid_access_token_no_refresh_token_raises(): - profile = { - "profile": "test", - "keycloak_access_token": "expired", - "keycloak_token_expires_at": ( - datetime.now(timezone.utc) - timedelta(hours=1) - ).isoformat(), - "keycloak_refresh_token": None, - } - with pytest.raises(ClickException, match="session has expired"): - get_valid_access_token(profile) - - -@patch("ewccli.backends.keycloak.token_manager.OIDCClient") -def test_get_valid_access_token_refresh_failure_raises(mock_oidc_cls): - mock_oidc = MagicMock() - mock_oidc.refresh_tokens.side_effect = Exception("invalid_grant") - mock_oidc_cls.return_value = mock_oidc - - profile = { - "profile": "test", - "keycloak_access_token": "expired", - "keycloak_token_expires_at": ( - datetime.now(timezone.utc) - timedelta(hours=1) - ).isoformat(), - "keycloak_refresh_token": "old-refresh", - } - with pytest.raises(ClickException, match="could not be refreshed"): - get_valid_access_token(profile) - - -def test_update_profile_tokens(tmp_path): - from configparser import ConfigParser - - profiles_file = tmp_path / "profiles" - cfg = ConfigParser() - cfg["test"] = { - "federee": "EUMETSAT", - "keycloak_access_token": "old", - } - with open(profiles_file, "w") as f: - cfg.write(f) - - _update_profile_tokens( - profiles_file_path=profiles_file, - profile_name="test", - access_token="new-token", - refresh_token="new-refresh", - expires_at="2026-06-23T13:00:00+00:00", - id_token="new-id", - ) - - cfg2 = ConfigParser() - cfg2.read(profiles_file) - assert cfg2["test"]["keycloak_access_token"] == "new-token" - assert cfg2["test"]["keycloak_refresh_token"] == "new-refresh" - assert cfg2["test"]["keycloak_id_token"] == "new-id" - assert cfg2["test"]["keycloak_token_expires_at"] == "2026-06-23T13:00:00+00:00" - # Existing keys should be preserved - assert cfg2["test"]["federee"] == "EUMETSAT" diff --git a/ewccli/utils.py b/ewccli/utils.py index 08caad1..f4990bd 100644 --- a/ewccli/utils.py +++ b/ewccli/utils.py @@ -111,10 +111,6 @@ def save_cli_profile( token: Optional[str] = None, application_credential_id: Optional[str] = None, application_credential_secret: Optional[str] = None, - keycloak_access_token: Optional[str] = None, - keycloak_refresh_token: Optional[str] = None, - keycloak_id_token: Optional[str] = None, - keycloak_token_expires_at: Optional[str] = None, profiles_file_path: Path = ewc_hub_config.EWC_CLI_PROFILES_PATH, ) -> None: """ @@ -180,18 +176,6 @@ def save_cli_profile( "application_credential_secret" ] = application_credential_secret - if keycloak_access_token: - cfg[resolved_profile]["keycloak_access_token"] = keycloak_access_token - - if keycloak_refresh_token: - cfg[resolved_profile]["keycloak_refresh_token"] = keycloak_refresh_token - - if keycloak_id_token: - cfg[resolved_profile]["keycloak_id_token"] = keycloak_id_token - - if keycloak_token_expires_at: - cfg[resolved_profile]["keycloak_token_expires_at"] = keycloak_token_expires_at - os.makedirs(os.path.dirname(profiles_file_path), exist_ok=True) with open(profiles_file_path, "w") as f: cfg.write(f) @@ -385,10 +369,6 @@ def load_cli_profile( "token": section.get("token"), "application_credential_id": section.get("application_credential_id"), "application_credential_secret": section.get("application_credential_secret"), - "keycloak_access_token": section.get("keycloak_access_token"), - "keycloak_refresh_token": section.get("keycloak_refresh_token"), - "keycloak_id_token": section.get("keycloak_id_token"), - "keycloak_token_expires_at": section.get("keycloak_token_expires_at"), } From fcc31b585ae3f928b29878a05195242bf0e11c27 Mon Sep 17 00:00:00 2001 From: Kamil Rajtar Date: Wed, 24 Jun 2026 16:48:44 +0200 Subject: [PATCH 08/12] Remove local artifcats + add gitignore --- .gitignore | 1 + .hermes/plans/2026-06-23_keycloak-login.md | 1831 -------------------- 2 files changed, 1 insertion(+), 1831 deletions(-) delete mode 100644 .hermes/plans/2026-06-23_keycloak-login.md diff --git a/.gitignore b/.gitignore index 84f0f34..18324dc 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ report.xml **/.local CHANGELOG_UNRELEASED.md .idea/ +.hermes/ \ No newline at end of file diff --git a/.hermes/plans/2026-06-23_keycloak-login.md b/.hermes/plans/2026-06-23_keycloak-login.md deleted file mode 100644 index 792d421..0000000 --- a/.hermes/plans/2026-06-23_keycloak-login.md +++ /dev/null @@ -1,1831 +0,0 @@ -# Keycloak OIDC Login for ewccli — Implementation Plan - -> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. - -**Goal:** Add a `ewc login --keycloak` flow that authenticates the user via Keycloak OIDC (authorization code + PKCE), then calls an EWC portal API to obtain OpenStack application credentials, storing them in the existing profile format — fully backward compatible with the current manual-credential login. - -**Architecture:** - -``` -User runs: ewc login --keycloak [--federee X --region Y] - - ┌──────────┐ 1. start callback server (127.0.0.1:port) - │ ewccli │ 2. build auth URL (PKCE code_challenge) - │ │ 3. print URL + open browser - │ │◄───4. browser redirects with ?code=...&state=... - │ │ 5. exchange code for tokens (code_verifier) - │ │ 6. call portal API with Bearer access_token - │ │ 7. receive app_credential_id/secret/auth_url - │ │ 8. interactive federee/region/SSH (if not from portal) - │ │ 9. save_cli_profile() — same INI format as today - └──────────┘ -``` - -The downstream OpenStack connection path (`OpenstackBackend.connect()` with `v3applicationcredential`) is unchanged. Keycloak is purely a new *way to obtain* the app creds. - -**Tech Stack:** Python stdlib (`http.server`, `secrets`, `hashlib`, `base64`, `urllib.parse`, `webbrowser`, `threading`), `requests` (already a dependency). No new dependencies required. - -**Assumed Portal API Contract** (the plan defines this; adjust when the real API is known): - -``` -POST {EWC_CLI_PORTAL_API_URL}/api/v1/credentials/openstack -Headers: Authorization: Bearer -Body (optional): {"federee": "EUMETSAT", "region": "ECIS-R1"} - -Response 200: -{ - "application_credential_id": "...", - "application_credential_secret": "...", - "auth_url": "https://keystone.api.r1.cloud.eumetsat.int", - "federee": "EUMETSAT", - "region": "ECIS-R1", - "tenant_name": "user-tenant" -} -``` - -If `federee`/`region` are omitted from the request, the portal may return the user's default project credentials, or return credentials for all available federees. The CLI handles either case. - -**Assumed Keycloak Config** (overridable via env vars): - -| Config key | Env var | Default | -|---|---|---| -| `EWC_CLI_KEYCLOAK_URL` | `EWC_CLI_KEYCLOAK_URL` | `https://auth.europeanweather.cloud` | -| `EWC_CLI_KEYCLOAK_REALM` | `EWC_CLI_KEYCLOAK_REALM` | `ewc` | -| `EWC_CLI_KEYCLOAK_CLIENT_ID` | `EWC_CLI_KEYCLOAK_CLIENT_ID` | `ewccli` | -| `EWC_CLI_PORTAL_API_URL` | `EWC_CLI_PORTAL_API_URL` | `https://europeanweather.cloud` | -| `EWC_CLI_KEYCLOAK_SCOPE` | `EWC_CLI_KEYCLOAK_SCOPE` | `openid profile email` | -| `EWC_CLI_OIDC_CALLBACK_TIMEOUT` | `EWC_CLI_OIDC_CALLBACK_TIMEOUT` | `300` (seconds) | - ---- - -## Current State Summary - -### Files that matter - -| File | Role | -|---|---| -| `ewccli/commands/login_command.py` | `ewc login` command + `init_options` decorator + `init_command()` logic | -| `ewccli/ewccli.py` | Registers the `login` command, wires `@init_options` | -| `ewccli/utils.py` | `save_cli_profile()`, `load_cli_profile()`, `save_default_login_profile()`, `_resolve_profile()` — INI-based profile at `~/.ewccli/profiles` | -| `ewccli/configuration.py` | `EWCCLIConfiguration` class — all config constants (paths, URLs, images, flavors, site map) | -| `ewccli/backends/openstack/backend_ostack.py` | `OpenstackBackend` — `connect()` uses `v3applicationcredential` auth type | -| `ewccli/commands/commons_infra.py` | `connect_to_openstack_backend()` helper | -| `ewccli/commands/infra_command.py` | `ewc infra` group — loads profile, instantiates `OpenstackBackend`, calls `connect()` | -| `ewccli/commands/hub/hub_command.py` | `ewc hub deploy` — same pattern: loads profile, creates backend, connects | -| `ewccli/enums.py` | `Federee`, `Region` enums | -| `ewccli/tests/ewccli_login_test.py` | Tests for `check_and_generate_ssh_keys` | -| `ewccli/tests/ewccli_config_test.py` | Tests for `save_cli_profile`/`load_cli_profile` | - -### Current login flow (what stays unchanged) - -1. `ewc login` → `init_command()` in `login_command.py` -2. Interactive: select federee (RadioList), select region (RadioList), enter app cred id/secret (click.prompt), handle SSH keys -3. `save_default_login_profile()` + `save_cli_profile()` write INI to `~/.ewccli/profiles` -4. Downstream: `load_cli_profile()` reads the INI, `OpenstackBackend` uses app creds to connect - -### What changes - -- `init_options` gets a new `--keycloak` flag (and `--no-browser`) -- `init_command()` gets a new branch: when `--keycloak` is set, run the OIDC flow instead of prompting for app creds -- New package `ewccli/backends/keycloak/` with the OIDC logic -- `configuration.py` gets Keycloak config constants -- `save_cli_profile()` / `load_cli_profile()` optionally store/load OIDC tokens (for refresh) -- New `token_manager.py` handles silent token refresh with rotation -- `ewccli.py` `init()` function signature gains the new params - -### What does NOT change - -- Profile INI format (new optional keys are additive) -- `OpenstackBackend` and its `connect()` method -- `connect_to_openstack_backend()` helper -- `load_cli_profile()` return dict shape (new optional keys only) -- All downstream commands (`infra`, `hub`) — they read app creds from the profile as before - ---- - -## Task Breakdown - -### Task 1: Add Keycloak/OIDC configuration constants - -**Objective:** Add config values for Keycloak URL, realm, client_id, portal API URL, scope, and callback timeout to `EWCCLIConfiguration`. - -**Files:** -- Modify: `ewccli/configuration.py` (add after line 31, the `EWC_CLI_DEFAULT_FEDEREE` line) - -**Step 1: Add config constants** - -Add these class attributes to `EWCCLIConfiguration`: - -```python - # Keycloak / OIDC configuration - EWC_CLI_KEYCLOAK_URL = os.getenv( - "EWC_CLI_KEYCLOAK_URL", "https://auth.europeanweather.cloud" - ) - EWC_CLI_KEYCLOAK_REALM = os.getenv("EWC_CLI_KEYCLOAK_REALM", "ewc") - EWC_CLI_KEYCLOAK_CLIENT_ID = os.getenv("EWC_CLI_KEYCLOAK_CLIENT_ID", "ewccli") - EWC_CLI_KEYCLOAK_SCOPE = os.getenv("EWC_CLI_KEYCLOAK_SCOPE", "openid profile email") - EWC_CLI_PORTAL_API_URL = os.getenv( - "EWC_CLI_PORTAL_API_URL", "https://europeanweather.cloud" - ) - EWC_CLI_OIDC_CALLBACK_TIMEOUT = int( - os.getenv("EWC_CLI_OIDC_CALLBACK_TIMEOUT", "300") - ) -``` - -**Step 2: Verify** - -Run: `python -c "from ewccli.configuration import config; print(config.EWC_CLI_KEYCLOAK_URL, config.EWC_CLI_KEYCLOAK_CLIENT_ID)"` -Expected: prints the default URL and `ewccli`. - -**Step 3: Commit** - -```bash -git add ewccli/configuration.py -git commit -m "feat: add Keycloak/OIDC configuration constants" -``` - ---- - -### Task 2: Create PKCE utilities module - -**Objective:** Create a module that generates PKCE code_verifier, code_challenge (S256), and a random state token. - -**Files:** -- Create: `ewccli/backends/keycloak/__init__.py` (empty) -- Create: `ewccli/backends/keycloak/pkce.py` - -**Step 1: Write failing test** - -Create `ewccli/tests/test_keycloak_pkce.py`: - -```python -"""Tests for PKCE utilities.""" -import base64 -import hashlib - -from ewccli.backends.keycloak.pkce import generate_pkce_pair, generate_state - - -def test_generate_pkce_pair_returns_verifier_and_challenge(): - verifier, challenge = generate_pkce_pair() - assert isinstance(verifier, str) - assert isinstance(challenge, str) - assert len(verifier) >= 43 - assert len(verifier) <= 128 - # Challenge must be base64url(SHA256(verifier)) without padding - expected = ( - base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()) - .decode("ascii") - .rstrip("=") - ) - assert challenge == expected - - -def test_generate_pkce_pair_is_random(): - v1, c1 = generate_pkce_pair() - v2, c2 = generate_pkce_pair() - assert v1 != v2 - assert c1 != c2 - - -def test_generate_state_is_random_string(): - s1 = generate_state() - s2 = generate_state() - assert isinstance(s1, str) - assert len(s1) >= 32 - assert s1 != s2 -``` - -**Step 2: Run test to verify failure** - -Run: `pytest ewccli/tests/test_keycloak_pkce.py -v` -Expected: FAIL — `ModuleNotFoundError: No module named 'ewccli.backends.keycloak.pkce'` - -**Step 3: Implement pkce.py** - -```python -"""PKCE (Proof Key for Code Exchange) utilities for OIDC flows.""" - -import base64 -import hashlib -import secrets - - -def generate_pkce_pair() -> tuple[str, str]: - """Generate a PKCE code_verifier and its S256 code_challenge. - - Returns: - A tuple of (code_verifier, code_challenge). The verifier is a - random URL-safe string of 43-128 chars. The challenge is - base64url(SHA256(verifier)) without padding. - """ - # Generate 32 random bytes -> 43 base64url chars (min length per RFC 7636) - code_verifier = ( - base64.urlsafe_b64encode(secrets.token_bytes(32)) - .decode("ascii") - .rstrip("=") - ) - # S256 challenge - digest = hashlib.sha256(code_verifier.encode("ascii")).digest() - code_challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") - return code_verifier, code_challenge - - -def generate_state() -> str: - """Generate a random state token for CSRF protection in OIDC flows.""" - return secrets.token_urlsafe(32) -``` - -**Step 4: Run test to verify pass** - -Run: `pytest ewccli/tests/test_keycloak_pkce.py -v` -Expected: PASS — 3 passed - -**Step 5: Commit** - -```bash -git add ewccli/backends/keycloak/__init__.py ewccli/backends/keycloak/pkce.py ewccli/tests/test_keycloak_pkce.py -git commit -m "feat: add PKCE utilities for OIDC flows" -``` - ---- - -### Task 3: Create the OIDC callback HTTP server - -**Objective:** Create a lightweight HTTP server that listens on a random loopback port, receives the OIDC authorization code callback, and returns it to the main thread. - -**Files:** -- Create: `ewccli/backends/keycloak/callback_server.py` - -**Step 1: Write failing test** - -Create `ewccli/tests/test_keycloak_callback_server.py`: - -```python -"""Tests for the OIDC callback server.""" -import time -import urllib.request - -from ewccli.backends.keycloak.callback_server import CallbackServer - - -def test_callback_server_receives_code(): - server = CallbackServer(expected_state="mystate") - server.start() - - # Simulate browser redirect - url = f"http://127.0.0.1:{server.port}/callback?code=mycode&state=mystate" - urllib.request.urlopen(url, timeout=5) - - # Wait for the server to process - result = server.wait_for_callback(timeout=5) - server.stop() - - assert result is not None - code, state = result - assert code == "mycode" - assert state == "mystate" - - -def test_callback_server_rejects_wrong_state(): - server = CallbackServer(expected_state="correct") - server.start() - - url = f"http://127.0.0.1:{server.port}/callback?code=mycode&state=wrong" - urllib.request.urlopen(url, timeout=5) - - result = server.wait_for_callback(timeout=3) - server.stop() - - # Should return None or raise — state mismatch means no valid callback - assert result is None - - -def test_callback_server_timeout(): - server = CallbackServer(expected_state="mystate") - server.start() - - result = server.wait_for_callback(timeout=1) - server.stop() - - assert result is None - - -def test_callback_server_port_is_assigned(): - server = CallbackServer(expected_state="mystate") - server.start() - assert server.port > 0 - server.stop() -``` - -**Step 2: Run test to verify failure** - -Run: `pytest ewccli/tests/test_keycloak_callback_server.py -v` -Expected: FAIL — `ModuleNotFoundError` - -**Step 3: Implement callback_server.py** - -```python -"""Lightweight HTTP server to receive the OIDC authorization code callback.""" - -import threading -from http.server import BaseHTTPRequestHandler, HTTPServer -from typing import Optional, tuple -from urllib.parse import urlparse, parse_qs - - -_SUCCESS_HTML = ( - b"" - b"

✅ Authentication successful!

" - b"

You can close this browser tab and return to your terminal.

" - b"" -) - -_ERROR_HTML = ( - b"" - b"

❌ Authentication failed

" - b"

State mismatch. Please try again.

" - b"" -) - - -class CallbackServer: - """HTTP server that listens for the OIDC redirect callback on localhost. - - Usage: - server = CallbackServer(expected_state="...") - server.start() - # ... open browser to auth URL ... - result = server.wait_for_callback(timeout=300) - server.stop() - """ - - def __init__(self, expected_state: str): - self._expected_state = expected_state - self._result: Optional[tuple[str, str]] = None - self._error: Optional[str] = None - self._httpd: Optional[HTTPServer] = None - self._thread: Optional[threading.Thread] = None - self.port: int = 0 - - def start(self) -> None: - """Start the server on a random loopback port.""" - handler = self._make_handler() - self._httpd = HTTPServer(("127.0.0.1", 0), handler) - self.port = self._httpd.server_address[1] - self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) - self._thread.start() - - def stop(self) -> None: - """Shut down the server.""" - if self._httpd: - self._httpd.shutdown() - self._httpd.server_close() - if self._thread: - self._thread.join(timeout=2) - - def wait_for_callback(self, timeout: float = 300) -> Optional[tuple[str, str]]: - """Block until the callback is received or timeout. - - Returns (code, state) on success, or None on timeout/error. - """ - if self._thread is None: - return None - self._thread.join(timeout=timeout) - if self._thread.is_alive(): - return None # timed out - return self._result - - @property - def error(self) -> Optional[str]: - """Return error description if one occurred.""" - return self._error - - @property - def redirect_uri(self) -> str: - """The redirect_uri to pass to the authorization endpoint.""" - return f"http://127.0.0.1:{self.port}/callback" - - def _make_handler(self): - """Create a request handler class bound to this server instance.""" - - expected_state = self._expected_state - outer = self # closure over the CallbackServer instance - - class _Handler(BaseHTTPRequestHandler): - def do_GET(self): # noqa: N802 - parsed = urlparse(self.path) - if parsed.path != "/callback": - self.send_response(404) - self.end_headers() - return - - params = parse_qs(parsed.query) - code = params.get("code", [None])[0] - state = params.get("state", [None])[0] - - if state != expected_state: - outer._error = "State mismatch" - self.send_response(400) - self.send_header("Content-Type", "text/html") - self.end_headers() - self.wfile.write(_ERROR_HTML) - # Still stop the server so wait_for_callback returns - threading.Thread( - target=outer._httpd.shutdown, daemon=True - ).start() - return - - if code is None: - error = params.get("error", ["unknown"])[0] - outer._error = f"Authorization error: {error}" - self.send_response(400) - self.send_header("Content-Type", "text/html") - self.end_headers() - self.wfile.write(_ERROR_HTML) - threading.Thread( - target=outer._httpd.shutdown, daemon=True - ).start() - return - - outer._result = (code, state) - self.send_response(200) - self.send_header("Content-Type", "text/html") - self.end_headers() - self.wfile.write(_SUCCESS_HTML) - # Shut down the server in a separate thread so this handler - # can finish sending the response first. - threading.Thread( - target=outer._httpd.shutdown, daemon=True - ).start() - - def log_message(self, format, *args): # noqa: A002 - pass # silence stderr logging - - return _Handler -``` - -**Step 4: Run test to verify pass** - -Run: `pytest ewccli/tests/test_keycloak_callback_server.py -v` -Expected: PASS — 4 passed - -**Step 5: Commit** - -```bash -git add ewccli/backends/keycloak/callback_server.py ewccli/tests/test_keycloak_callback_server.py -git commit -m "feat: add OIDC callback HTTP server" -``` - ---- - -### Task 4: Create the OIDC client (auth URL + token exchange) - -**Objective:** Build the authorization URL, exchange the authorization code for tokens, and optionally refresh tokens. - -**Files:** -- Create: `ewccli/backends/keycloak/oidc_client.py` - -**Step 1: Write failing test** - -Create `ewccli/tests/test_keycloak_oidc_client.py`: - -```python -"""Tests for the OIDC client.""" -import pytest -from unittest.mock import patch, MagicMock - -from ewccli.backends.keycloak.oidc_client import OIDCClient -from ewccli.backends.keycloak.pkce import generate_pkce_pair - - -@pytest.fixture -def oidc_client(): - return OIDCClient( - keycloak_url="https://auth.example.com", - realm="ewc", - client_id="ewccli", - scope="openid profile", - ) - - -def test_build_authorization_url(oidc_client): - verifier, challenge = generate_pkce_pair() - url = oidc_client.build_authorization_url( - redirect_uri="http://127.0.0.1:12345/callback", - code_challenge=challenge, - state="mystate", - ) - assert "https://auth.example.com/realms/ewc/protocol/openid-connect/auth" in url - assert "client_id=ewccli" in url - assert "redirect_uri=" in url - assert "response_type=code" in url - assert "code_challenge=" in url - assert "code_challenge_method=S256" in url - assert "state=mystate" in url - assert "scope=openid" in url - - -@patch("ewccli.backends.keycloak.oidc_client.requests.post") -def test_exchange_code_for_tokens(mock_post, oidc_client): - mock_response = MagicMock() - mock_response.json.return_value = { - "access_token": "access123", - "refresh_token": "refresh456", - "id_token": "id789", - "expires_in": 3600, - "token_type": "Bearer", - } - mock_response.raise_for_status = MagicMock() - mock_post.return_value = mock_response - - tokens = oidc_client.exchange_code_for_tokens( - code="mycode", - code_verifier="myverifier", - redirect_uri="http://127.0.0.1:12345/callback", - ) - - assert tokens["access_token"] == "access123" - assert tokens["refresh_token"] == "refresh456" - assert tokens["id_token"] == "id789" - assert tokens["expires_in"] == 3600 - - # Verify the POST call - mock_post.assert_called_once() - call_args = mock_post.call_args - assert "token" in call_args[0][0] # URL contains /token - assert call_args[1]["data"]["grant_type"] == "authorization_code" - assert call_args[1]["data"]["code"] == "mycode" - assert call_args[1]["data"]["code_verifier"] == "myverifier" - - -@patch("ewccli.backends.keycloak.oidc_client.requests.post") -def test_refresh_tokens(mock_post, oidc_client): - mock_response = MagicMock() - mock_response.json.return_value = { - "access_token": "new_access", - "refresh_token": "new_refresh", - "expires_in": 3600, - } - mock_response.raise_for_status = MagicMock() - mock_post.return_value = mock_response - - tokens = oidc_client.refresh_tokens(refresh_token="old_refresh") - - assert tokens["access_token"] == "new_access" - call_args = mock_post.call_args - assert call_args[1]["data"]["grant_type"] == "refresh_token" - assert call_args[1]["data"]["refresh_token"] == "old_refresh" -``` - -**Step 2: Run test to verify failure** - -Run: `pytest ewccli/tests/test_keycloak_oidc_client.py -v` -Expected: FAIL — `ModuleNotFoundError` - -**Step 3: Implement oidc_client.py** - -```python -"""OIDC client for Keycloak authorization code + PKCE flow.""" - -from typing import Optional -from urllib.parse import urlencode - -import requests - -from ewccli.logger import get_logger - -_LOGGER = get_logger(__name__) - - -class OIDCClient: - """Handles OIDC authorization URL construction and token exchange.""" - - def __init__( - self, - keycloak_url: str, - realm: str, - client_id: str, - scope: str = "openid profile email", - ): - self._keycloak_url = keycloak_url.rstrip("/") - self._realm = realm - self._client_id = client_id - self._scope = scope - - @property - def authorization_endpoint(self) -> str: - return ( - f"{self._keycloak_url}/realms/{self._realm}" - "/protocol/openid-connect/auth" - ) - - @property - def token_endpoint(self) -> str: - return ( - f"{self._keycloak_url}/realms/{self._realm}" - "/protocol/openid-connect/token" - ) - - def build_authorization_url( - self, - redirect_uri: str, - code_challenge: str, - state: str, - ) -> str: - """Build the OIDC authorization URL with PKCE.""" - params = { - "client_id": self._client_id, - "redirect_uri": redirect_uri, - "response_type": "code", - "scope": self._scope, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - "state": state, - } - return f"{self.authorization_endpoint}?{urlencode(params)}" - - def exchange_code_for_tokens( - self, - code: str, - code_verifier: str, - redirect_uri: str, - ) -> dict: - """Exchange the authorization code for access/refresh tokens. - - Returns the token response dict with keys: - access_token, refresh_token, id_token, expires_in, token_type. - """ - data = { - "grant_type": "authorization_code", - "code": code, - "redirect_uri": redirect_uri, - "client_id": self._client_id, - "code_verifier": code_verifier, - } - _LOGGER.debug("Exchanging authorization code for tokens") - response = requests.post(self.token_endpoint, data=data, timeout=30) - response.raise_for_status() - return response.json() - - def refresh_tokens(self, refresh_token: str) -> dict: - """Use a refresh token to obtain new tokens. - - Returns the token response dict. - """ - data = { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": self._client_id, - } - _LOGGER.debug("Refreshing OIDC tokens") - response = requests.post(self.token_endpoint, data=data, timeout=30) - response.raise_for_status() - return response.json() -``` - -**Step 4: Run test to verify pass** - -Run: `pytest ewccli/tests/test_keycloak_oidc_client.py -v` -Expected: PASS — 3 passed - -**Step 5: Commit** - -```bash -git add ewccli/backends/keycloak/oidc_client.py ewccli/tests/test_keycloak_oidc_client.py -git commit -m "feat: add OIDC client for Keycloak token exchange" -``` - ---- - -### Task 5: Create the portal API client - -**Objective:** Call the EWC portal API with the OIDC access token to obtain OpenStack application credentials. - -**Files:** -- Create: `ewccli/backends/keycloak/portal_client.py` - -**Step 1: Write failing test** - -Create `ewccli/tests/test_keycloak_portal_client.py`: - -```python -"""Tests for the portal API client.""" -import pytest -from unittest.mock import patch, MagicMock - -from ewccli.backends.keycloak.portal_client import PortalClient, PortalCredentials - - -@pytest.fixture -def portal_client(): - return PortalClient( - portal_api_url="https://europeanweather.cloud", - ) - - -@patch("ewccli.backends.keycloak.portal_client.requests.post") -def test_fetch_openstack_credentials(mock_post, portal_client): - mock_response = MagicMock() - mock_response.json.return_value = { - "application_credential_id": "app-id-123", - "application_credential_secret": "app-secret-456", - "auth_url": "https://keystone.api.r1.cloud.eumetsat.int", - "federee": "EUMETSAT", - "region": "ECIS-R1", - "tenant_name": "my-tenant", - } - mock_response.raise_for_status = MagicMock() - mock_post.return_value = mock_response - - creds = portal_client.fetch_openstack_credentials( - access_token="oidc-token-789", - ) - - assert isinstance(creds, PortalCredentials) - assert creds.application_credential_id == "app-id-123" - assert creds.application_credential_secret == "app-secret-456" - assert creds.auth_url == "https://keystone.api.r1.cloud.eumetsat.int" - assert creds.federee == "EUMETSAT" - assert creds.region == "ECIS-R1" - assert creds.tenant_name == "my-tenant" - - call_args = mock_post.call_args - assert "Bearer oidc-token-789" in call_args[1]["headers"]["Authorization"] - - -@patch("ewccli.backends.keycloak.portal_client.requests.post") -def test_fetch_openstack_credentials_with_federee_region(mock_post, portal_client): - mock_response = MagicMock() - mock_response.json.return_value = { - "application_credential_id": "id", - "application_credential_secret": "secret", - "auth_url": "https://keystone.example.com", - "federee": "ECMWF", - "region": "CC1", - "tenant_name": "tenant", - } - mock_response.raise_for_status = MagicMock() - mock_post.return_value = mock_response - - portal_client.fetch_openstack_credentials( - access_token="token", - federee="ECMWF", - region="CC1", - ) - - call_args = mock_post.call_args - assert call_args[1]["json"]["federee"] == "ECMWF" - assert call_args[1]["json"]["region"] == "CC1" -``` - -**Step 2: Run test to verify failure** - -Run: `pytest ewccli/tests/test_keycloak_portal_client.py -v` -Expected: FAIL — `ModuleNotFoundError` - -**Step 3: Implement portal_client.py** - -```python -"""Portal API client — exchanges OIDC tokens for OpenStack application credentials.""" - -from dataclasses import dataclass -from typing import Optional - -import requests - -from ewccli.logger import get_logger - -_LOGGER = get_logger(__name__) - - -@dataclass -class PortalCredentials: - """OpenStack application credentials returned by the EWC portal.""" - - application_credential_id: str - application_credential_secret: str - auth_url: str - federee: Optional[str] = None - region: Optional[str] = None - tenant_name: Optional[str] = None - - -class PortalClient: - """Calls the EWC portal API to obtain OpenStack application credentials.""" - - def __init__(self, portal_api_url: str): - self._portal_api_url = portal_api_url.rstrip("/") - - @property - def credentials_endpoint(self) -> str: - return f"{self._portal_api_url}/api/v1/credentials/openstack" - - def fetch_openstack_credentials( - self, - access_token: str, - federee: Optional[str] = None, - region: Optional[str] = None, - ) -> PortalCredentials: - """Fetch OpenStack application credentials from the portal API. - - Args: - access_token: The OIDC access token from Keycloak. - federee: Optional federee to request credentials for. - region: Optional region to request credentials for. - - Returns: - PortalCredentials dataclass with the app cred id/secret and auth_url. - - Raises: - requests.HTTPError: If the API call fails. - """ - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - } - body: dict = {} - if federee: - body["federee"] = federee - if region: - body["region"] = region - - _LOGGER.info("Fetching OpenStack credentials from EWC portal") - response = requests.post( - self.credentials_endpoint, - headers=headers, - json=body if body else None, - timeout=30, - ) - response.raise_for_status() - data = response.json() - - return PortalCredentials( - application_credential_id=data["application_credential_id"], - application_credential_secret=data["application_credential_secret"], - auth_url=data["auth_url"], - federee=data.get("federee"), - region=data.get("region"), - tenant_name=data.get("tenant_name"), - ) -``` - -**Step 4: Run test to verify pass** - -Run: `pytest ewccli/tests/test_keycloak_portal_client.py -v` -Expected: PASS — 2 passed - -**Step 5: Commit** - -```bash -git add ewccli/backends/keycloak/portal_client.py ewccli/tests/test_keycloak_portal_client.py -git commit -m "feat: add portal API client for OpenStack credential exchange" -``` - ---- - -### Task 6: Create the Keycloak login orchestrator - -**Objective:** Tie together PKCE, callback server, OIDC client, and portal client into a single `keycloak_login()` function that the login command calls. - -**Files:** -- Create: `ewccli/backends/keycloak/keycloak_backend.py` - -**Step 1: Write failing test** - -Create `ewccli/tests/test_keycloak_backend.py`: - -```python -"""Tests for the Keycloak login orchestrator.""" -import pytest -from unittest.mock import patch, MagicMock - -from ewccli.backends.keycloak.keycloak_backend import keycloak_login, KeycloakLoginResult - - -@pytest.fixture -def mock_config(): - config = MagicMock() - config.EWC_CLI_KEYCLOAK_URL = "https://auth.example.com" - config.EWC_CLI_KEYCLOAK_REALM = "ewc" - config.EWC_CLI_KEYCLOAK_CLIENT_ID = "ewccli" - config.EWC_CLI_KEYCLOAK_SCOPE = "openid profile" - config.EWC_CLI_PORTAL_API_URL = "https://portal.example.com" - config.EWC_CLI_OIDC_CALLBACK_TIMEOUT = 10 - return config - - -@patch("ewccli.backends.keycloak.keycloak_backend.webbrowser") -@patch("ewccli.backends.keycloak.keycloak_backend.PortalClient") -@patch("ewccli.backends.keycloak.keycloak_backend.OIDCClient") -@patch("ewccli.backends.keycloak.keycloak_backend.CallbackServer") -def test_keycloak_login_success( - mock_cb_server_cls, - mock_oidc_cls, - mock_portal_cls, - mock_webbrowser, - mock_config, -): - # Callback server - mock_server = MagicMock() - mock_server.port = 12345 - mock_server.redirect_uri = "http://127.0.0.1:12345/callback" - mock_server.wait_for_callback.return_value = ("mycode", "mystate") - mock_server.error = None - mock_cb_server_cls.return_value = mock_server - - # OIDC client - mock_oidc = MagicMock() - mock_oidc.build_authorization_url.return_value = "https://auth.example.com/auth?..." - mock_oidc.exchange_code_for_tokens.return_value = { - "access_token": "access123", - "refresh_token": "refresh456", - "id_token": "id789", - "expires_in": 3600, - } - mock_oidc_cls.return_value = mock_oidc - - # Portal client - mock_portal = MagicMock() - mock_creds = MagicMock() - mock_creds.application_credential_id = "app-id" - mock_creds.application_credential_secret = "app-secret" - mock_creds.auth_url = "https://keystone.example.com" - mock_creds.federee = "EUMETSAT" - mock_creds.region = "ECIS-R1" - mock_creds.tenant_name = "tenant" - mock_portal.fetch_openstack_credentials.return_value = mock_creds - mock_portal_cls.return_value = mock_portal - - result = keycloak_login( - config=mock_config, - open_browser=True, - federee="EUMETSAT", - region="ECIS-R1", - ) - - assert isinstance(result, KeycloakLoginResult) - assert result.application_credential_id == "app-id" - assert result.application_credential_secret == "app-secret" - assert result.auth_url == "https://keystone.example.com" - assert result.access_token == "access123" - assert result.refresh_token == "refresh456" - assert result.federee == "EUMETSAT" - assert result.region == "ECIS-R1" - - # Browser was opened - mock_webbrowser.open.assert_called_once() - - # Callback server was started and stopped - mock_server.start.assert_called_once() - mock_server.stop.assert_called_once() - - -@patch("ewccli.backends.keycloak.keycloak_backend.webbrowser") -@patch("ewccli.backends.keycloak.keycloak_backend.CallbackServer") -def test_keycloak_login_timeout( - mock_cb_server_cls, - mock_webbrowser, - mock_config, -): - mock_server = MagicMock() - mock_server.port = 12345 - mock_server.wait_for_callback.return_value = None # timeout - mock_server.error = None - mock_cb_server_cls.return_value = mock_server - - from click import ClickException - - with pytest.raises(ClickException, match="timed out|timeout"): - keycloak_login( - config=mock_config, - open_browser=False, - ) -``` - -**Step 2: Run test to verify failure** - -Run: `pytest ewccli/tests/test_keycloak_backend.py -v` -Expected: FAIL — `ModuleNotFoundError` - -**Step 3: Implement keycloak_backend.py** - -```python -"""Keycloak login orchestrator — ties PKCE, callback, OIDC, and portal together.""" - -import webbrowser -from dataclasses import dataclass -from typing import Optional - -from click import ClickException -from rich.console import Console - -from ewccli.backends.keycloak.callback_server import CallbackServer -from ewccli.backends.keycloak.oidc_client import OIDCClient -from ewccli.backends.keycloak.pkce import generate_pkce_pair, generate_state -from ewccli.backends.keycloak.portal_client import PortalClient -from ewccli.logger import get_logger - -_LOGGER = get_logger(__name__) -_console = Console() - - -@dataclass -class KeycloakLoginResult: - """Result of a successful Keycloak login.""" - - application_credential_id: str - application_credential_secret: str - auth_url: str - access_token: str - refresh_token: Optional[str] - id_token: Optional[str] - expires_in: int - federee: Optional[str] = None - region: Optional[str] = None - tenant_name: Optional[str] = None - - -def keycloak_login( - config, - open_browser: bool = True, - federee: Optional[str] = None, - region: Optional[str] = None, -) -> KeycloakLoginResult: - """Run the full Keycloak OIDC login flow. - - 1. Start a local callback server - 2. Build the authorization URL (PKCE) - 3. Print URL and optionally open browser - 4. Wait for callback - 5. Exchange code for tokens - 6. Call portal API for OpenStack credentials - - Args: - config: EWCCLIConfiguration instance with Keycloak settings. - open_browser: If True, attempt to open the browser automatically. - federee: Optional federee to pass to the portal API. - region: Optional region to pass to the portal API. - - Returns: - KeycloakLoginResult with app creds and OIDC tokens. - - Raises: - ClickException: On timeout, state mismatch, or API errors. - """ - timeout = config.EWC_CLI_OIDC_CALLBACK_TIMEOUT - - # 1. Generate PKCE pair and state - code_verifier, code_challenge = generate_pkce_pair() - state = generate_state() - - # 2. Start callback server - server = CallbackServer(expected_state=state) - server.start() - _LOGGER.debug(f"Callback server listening on port {server.port}") - - # 3. Build OIDC client and authorization URL - oidc_client = OIDCClient( - keycloak_url=config.EWC_CLI_KEYCLOAK_URL, - realm=config.EWC_CLI_KEYCLOAK_REALM, - client_id=config.EWC_CLI_KEYCLOAK_CLIENT_ID, - scope=config.EWC_CLI_KEYCLOAK_SCOPE, - ) - - auth_url = oidc_client.build_authorization_url( - redirect_uri=server.redirect_uri, - code_challenge=code_challenge, - state=state, - ) - - # 4. Print URL and optionally open browser - _console.print( - "\n[bold cyan]🔑 EWC Keycloak Login[/bold cyan]\n" - "Open the following URL in your browser to authenticate:\n" - ) - _console.print(f"[link={auth_url}]{auth_url}[/link]\n") - - if open_browser: - try: - webbrowser.open(auth_url) - _console.print("[green]🌐 Browser opened automatically.[/green]") - except Exception: - _console.print( - "[yellow]⚠️ Could not open browser automatically. " - "Please copy the URL above manually.[/yellow]" - ) - else: - _console.print( - "[yellow]📋 --no-browser: copy the URL above manually.[/yellow]" - ) - - _console.print(f"\n⏳ Waiting for authentication (timeout: {timeout}s)...") - - # 5. Wait for callback - callback_result = server.wait_for_callback(timeout=timeout) - server.stop() - - if callback_result is None: - if server.error: - raise ClickException( - f"OIDC authentication failed: {server.error}" - ) - raise ClickException( - f"OIDC authentication timed out after {timeout} seconds. " - "Please try again." - ) - - code, received_state = callback_result - - # 6. Exchange code for tokens - try: - tokens = oidc_client.exchange_code_for_tokens( - code=code, - code_verifier=code_verifier, - redirect_uri=f"http://127.0.0.1:{server.port}/callback", - ) - except Exception as e: - raise ClickException( - f"Failed to exchange authorization code for tokens: {e}" - ) - - _console.print("[green]✅ Authentication successful![/green]") - - # 7. Fetch OpenStack credentials from portal - portal_client = PortalClient( - portal_api_url=config.EWC_CLI_PORTAL_API_URL, - ) - - try: - creds = portal_client.fetch_openstack_credentials( - access_token=tokens["access_token"], - federee=federee, - region=region, - ) - except Exception as e: - raise ClickException( - f"Failed to fetch OpenStack credentials from EWC portal: {e}" - ) - - _console.print("[green]✅ OpenStack credentials obtained![/green]") - - return KeycloakLoginResult( - application_credential_id=creds.application_credential_id, - application_credential_secret=creds.application_credential_secret, - auth_url=creds.auth_url, - access_token=tokens["access_token"], - refresh_token=tokens.get("refresh_token"), - id_token=tokens.get("id_token"), - expires_in=tokens.get("expires_in", 3600), - federee=creds.federee, - region=creds.region, - tenant_name=creds.tenant_name, - ) -``` - -**Step 4: Run test to verify pass** - -Run: `pytest ewccli/tests/test_keycloak_backend.py -v` -Expected: PASS — 2 passed - -**Step 5: Commit** - -```bash -git add ewccli/backends/keycloak/keycloak_backend.py ewccli/tests/test_keycloak_backend.py -git commit -m "feat: add Keycloak login orchestrator" -``` - ---- - -### Task 7: Extend profile storage for OIDC tokens - -**Objective:** Add optional `keycloak_*` keys to `save_cli_profile()` and `load_cli_profile()` so OIDC tokens can be persisted for future refresh. - -**Files:** -- Modify: `ewccli/utils.py` — `save_cli_profile()` (line ~104) and `load_cli_profile()` (line ~184) - -**Step 1: Write failing test** - -Add to `ewccli/tests/ewccli_config_test.py`: - -```python -def test_save_and_load_profile_with_oidc_tokens(profile_file_path, ssh_paths): - ssh_private, ssh_public = ssh_paths - - save_cli_profile( - federee="EUMETSAT", - region="ECIS-R1", - tenant_name="TeamA", - ssh_private_key_path_to_save=ssh_private, - ssh_public_key_path_to_save=ssh_public, - application_credential_id="app-id", - application_credential_secret="app-secret", - keycloak_access_token="access123", - keycloak_refresh_token="refresh456", - keycloak_id_token="id789", - keycloak_token_expires_in=3600, - profiles_file_path=str(profile_file_path), - ) - - data = load_cli_profile( - profile="eumetsat-ecis-r1-teama", - profiles_file_path=str(profile_file_path), - ) - - assert data["keycloak_access_token"] == "access123" - assert data["keycloak_refresh_token"] == "refresh456" - assert data["keycloak_id_token"] == "id789" - assert data["keycloak_token_expires_in"] == "3600" - - -def test_load_profile_without_oidc_tokens_returns_none(profile_file_path, ssh_paths): - """Profiles saved without OIDC tokens should load fine with None.""" - ssh_private, ssh_public = ssh_paths - - save_cli_profile( - federee="EUMETSAT", - region="ECIS-R1", - tenant_name="TeamA", - ssh_private_key_path_to_save=ssh_private, - ssh_public_key_path_to_save=ssh_public, - application_credential_id="app-id", - application_credential_secret="app-secret", - profiles_file_path=str(profile_file_path), - ) - - data = load_cli_profile( - profile="eumetsat-ecis-r1-teama", - profiles_file_path=str(profile_file_path), - ) - - assert data.get("keycloak_access_token") is None - assert data.get("keycloak_refresh_token") is None -``` - -**Step 2: Run test to verify failure** - -Run: `pytest ewccli/tests/ewccli_config_test.py -v -k "oidc"` -Expected: FAIL — `TypeError: save_cli_profile() got an unexpected keyword argument 'keycloak_access_token'` - -**Step 3: Modify save_cli_profile()** - -In `ewccli/utils.py`, add new optional parameters to `save_cli_profile()`: - -```python -def save_cli_profile( - federee: str, - region: str, - tenant_name: str, - ssh_private_key_path_to_save: str, - ssh_public_key_path_to_save: str, - profile: Optional[str] = None, - token: Optional[str] = None, - application_credential_id: Optional[str] = None, - application_credential_secret: Optional[str] = None, - keycloak_access_token: Optional[str] = None, - keycloak_refresh_token: Optional[str] = None, - keycloak_id_token: Optional[str] = None, - keycloak_token_expires_in: Optional[int] = None, - profiles_file_path: Path = ewc_hub_config.EWC_CLI_PROFILES_PATH, -) -> None: -``` - -In the "Sensitive" section of the function body (after the `application_credential_secret` block, around line 177), add: - -```python - if keycloak_access_token: - cfg[resolved_profile]["keycloak_access_token"] = keycloak_access_token - - if keycloak_refresh_token: - cfg[resolved_profile]["keycloak_refresh_token"] = keycloak_refresh_token - - if keycloak_id_token: - cfg[resolved_profile]["keycloak_id_token"] = keycloak_id_token - - if keycloak_token_expires_in: - cfg[resolved_profile]["keycloak_token_expires_in"] = str( - keycloak_token_expires_in - ) -``` - -**Step 4: Modify load_cli_profile()** - -In the return dict of `load_cli_profile()` (around line 362), add: - -```python - "keycloak_access_token": section.get("keycloak_access_token"), - "keycloak_refresh_token": section.get("keycloak_refresh_token"), - "keycloak_id_token": section.get("keycloak_id_token"), - "keycloak_token_expires_in": section.get("keycloak_token_expires_in"), -``` - -**Step 5: Run test to verify pass** - -Run: `pytest ewccli/tests/ewccli_config_test.py -v` -Expected: PASS — all tests pass (including existing ones — backward compatible) - -**Step 6: Commit** - -```bash -git add ewccli/utils.py ewccli/tests/ewccli_config_test.py -git commit -m "feat: extend profile storage for OIDC tokens" -``` - ---- - -### Task 8: Add `--keycloak` flag to login command options - -**Objective:** Add the `--keycloak` and `--no-browser` CLI options to the `init_options` decorator. - -**Files:** -- Modify: `ewccli/commands/login_command.py` — `init_options()` function (line ~126) - -**Step 1: Add options to init_options** - -In `init_options()`, add these options before the `return func` (after the `--profile` option, around line 219): - -```python - func = click.option( - "--keycloak", - is_flag=True, - default=False, - envvar="EWC_CLI_KEYCLOAK_LOGIN", - help=( - "Login via Keycloak OIDC (browser-based). " - "Opens a browser for authentication and fetches " - "OpenStack credentials automatically from the EWC portal. " - "Can also be set via EWC_CLI_KEYCLOAK_LOGIN=1." - ), - )(func) - func = click.option( - "--no-browser", - is_flag=True, - default=False, - help=( - "Print the login URL instead of opening a browser. " - "Useful for SSH sessions or headless environments." - ), - )(func) -``` - -**Step 2: Update the init() command in ewccli.py** - -In `ewccli/ewccli.py`, update the `init()` function signature to accept the new params: - -```python -@cli.command(name="login", help="Initialize configuration for EWC CLI.") -@init_options -def init( - application_credential_id: str, - application_credential_secret: str, - ssh_public_key_path: str, - ssh_private_key_path: str, - tenant_name: str, - federee: str, - region: str, - profile: Optional[str] = None, - keycloak: bool = False, - no_browser: bool = False, -): - """Login command.""" - init_command( - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, - ssh_public_key_path=ssh_public_key_path, - ssh_private_key_path=ssh_private_key_path, - tenant_name=tenant_name, - federee=federee, - profile=profile, - region=region, - keycloak=keycloak, - no_browser=no_browser, - ) -``` - -**Step 3: Verify the CLI renders the new options** - -Run: `ewc login --help` -Expected: shows `--keycloak` and `--no-browser` in help output. - -**Step 4: Commit** - -```bash -git add ewccli/commands/login_command.py ewccli/ewccli.py -git commit -m "feat: add --keycloak and --no-browser flags to ewc login" -``` - ---- - -### Task 9: Implement the Keycloak login path in init_command() - -**Objective:** When `--keycloak` is set, run the OIDC flow instead of prompting for app creds. Integrate the result into the existing profile-saving logic. - -**Files:** -- Modify: `ewccli/commands/login_command.py` — `init_command()` function (line ~386) - -**Step 1: Update init_command() signature** - -Change the function signature to accept the new params: - -```python -def init_command( - application_credential_id: str, - application_credential_secret: str, - ssh_public_key_path: str, - ssh_private_key_path: str, - tenant_name: str, - federee: str, - region: str, - profile: str = None, - keycloak: bool = False, - no_browser: bool = False, -): - """EWC CLI Login.""" -``` - -**Step 2: Add the Keycloak branch** - -After the `resolved_profile` computation (line ~422) and the profile-exists check, but before the SSH key handling (line ~444), insert the Keycloak login branch: - -```python - # --- Keycloak OIDC login path --- - keycloak_access_token = None - keycloak_refresh_token = None - keycloak_id_token = None - keycloak_token_expires_in = None - - if keycloak: - from ewccli.backends.keycloak.keycloak_backend import keycloak_login - - kc_result = keycloak_login( - config=ewc_hub_config, - open_browser=not no_browser, - federee=federee, - region=region, - ) - - # Use credentials from the portal - application_credential_id = kc_result.application_credential_id - application_credential_secret = kc_result.application_credential_secret - - # If the portal returned federee/region/tenant_name, use them - if kc_result.federee: - federee = kc_result.federee - if kc_result.region: - region = kc_result.region - if kc_result.tenant_name: - tenant_name = kc_result.tenant_name - - # Store OIDC tokens for future refresh - keycloak_access_token = kc_result.access_token - keycloak_refresh_token = kc_result.refresh_token - keycloak_id_token = kc_result.id_token - keycloak_token_expires_in = kc_result.expires_in - - # Skip the openstack_config_available / manual credential prompts below - # since we have the credentials already. - elif not federee: -``` - -Then the existing `elif not federee:` / `if not region:` interactive selection blocks follow naturally. - -The existing block at lines 451-478 (cloud.yaml check and manual credential prompt) should be wrapped so it only runs when `keycloak` is False: - -```python - if not keycloak: - if openstack_config_available(): - console.print( - "🔑 [bold green]Openstack cloud.yaml found at ~/.config/openstack/clouds.yaml[/bold green]" - " – skipping Openstack ID and secret requirements." - ) - application_credential_id = "" - application_credential_secret = "" - - elif not application_credential_id or not application_credential_secret: - if not application_credential_id: - application_credential_id = ( - application_credential_id - or os.getenv("OS_APPLICATION_CREDENTIAL_ID") - or click.prompt( - "Enter OpenStack Application Credential ID", hide_input=True - ) - ) - - if not application_credential_secret: - application_credential_secret = ( - application_credential_secret - or os.getenv("OS_APPLICATION_CREDENTIAL_SECRET") - or click.prompt( - "Enter OpenStack Application Credential Secret", hide_input=True - ) - ) -``` - -**Step 3: Pass OIDC tokens to save_cli_profile** - -At the bottom of `init_command()`, update both `save_default_login_profile()` and `save_cli_profile()` calls to include the OIDC token params: - -```python - save_default_login_profile( - federee=federee, - region=region, - tenant_name=tenant_name, - ssh_private_key_path_to_save=ssh_private_key_path_to_save, - ssh_public_key_path_to_save=ssh_public_key_path_to_save, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, - ) - - # Save config - save_cli_profile( - federee=federee, - region=region, - tenant_name=tenant_name, - ssh_private_key_path_to_save=ssh_private_key_path_to_save, - ssh_public_key_path_to_save=ssh_public_key_path_to_save, - profile=profile, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, - keycloak_access_token=keycloak_access_token, - keycloak_refresh_token=keycloak_refresh_token, - keycloak_id_token=keycloak_id_token, - keycloak_token_expires_in=keycloak_token_expires_in, - ) -``` - -Note: `save_default_login_profile()` does not need the OIDC tokens — it's just a fallback default. Only the explicit `save_cli_profile()` call gets the tokens. - -**Step 4: Verify the command works** - -Run: `ewc login --help` -Expected: help text shows `--keycloak` flag. - -Run: `ewc login --keycloak --dry-run` (if dry-run is available) or just verify it doesn't crash on import. - -**Step 5: Commit** - -```bash -git add ewccli/commands/login_command.py -git commit -m "feat: implement Keycloak OIDC login path in ewc login" -``` - ---- - -### Task 10: Handle missing tenant_name in Keycloak flow - -**Objective:** The current `init_command` requires `tenant_name` (it's `prompt=True, required=True` in `init_options`). When using `--keycloak`, the tenant_name may come from the portal API instead. We need to make `tenant_name` optional when `--keycloak` is used. - -**Files:** -- Modify: `ewccli/commands/login_command.py` — `init_options()` `--tenant-name` option (line ~128) - -**Step 1: Make tenant_name not prompted when keycloak is used** - -Change the `--tenant-name` option from `prompt=True, required=True` to `required=False` (the prompt will be handled in `init_command()` when not using keycloak): - -```python - func = click.option( - "--tenant-name", - envvar="EWC_CLI_LOGIN_TENANT_NAME", - required=False, - callback=validate_tenant_name, - help=( - "Name of your tenancy in EWC, used to identify cloud configurations.\n" - "Must follow the format: 'part1-part2-part3' (e.g. 'demo-user-eu'), " - "where each part is alphanumeric and separated by dashes.\n" - "Required when not using --keycloak. " - "Can also be set via the EWC_CLI_LOGIN_TENANT_NAME environment variable." - ), - )(func) -``` - -**Step 2: Add prompt in init_command when tenant_name is missing and not keycloak** - -In `init_command()`, after the keycloak branch, add: - -```python - if not keycloak and not tenant_name: - tenant_name = click.prompt("Tenant name") -``` - -And move the `validate_tenant_name` validation to after this prompt (or rely on the callback which already ran for CLI-provided values; for prompted values, call it manually): - -```python - if not keycloak and tenant_name: - # Re-validate since it may have been prompted - import re - pattern = r"^[a-zA-Z0-9]+-[a-zA-Z0-9]+-[a-zA-Z0-9]+$" - if not re.match(pattern, tenant_name): - raise click.BadParameter( - "Config name must be exactly 3 alphanumeric parts separated by dashes." - ) -``` - -**Step 3: Verify** - -Run: `ewc login --help` -Expected: `--tenant-name` no longer shows as required. - -**Step 4: Commit** - -```bash -git add ewccli/commands/login_command.py -git commit -m "fix: make tenant_name optional when using --keycloak login" -``` - ---- - -### Task 11: Update the README documentation - -**Objective:** Document the new `--keycloak` login flow in the README. - -**Files:** -- Modify: `README.md` — "Login to prepare the environment" section (line ~178) - -**Step 1: Add Keycloak login documentation** - -After the existing `ewc login` section, add: - -```markdown -### Login with Keycloak (OIDC) - -Instead of manually entering OpenStack application credentials, you can authenticate via Keycloak: - -```bash -ewc login --keycloak -``` - -This will: -1. Open a browser window for Keycloak authentication -2. After successful login, fetch OpenStack credentials from the EWC portal -3. Save everything to your profile - -If you're on a headless machine or SSH session, use `--no-browser` to print the URL instead: - -```bash -ewc login --keycloak --no-browser -``` - -You can still combine with other flags: - -```bash -ewc login --keycloak --federee EUMETSAT --region ECIS-R1 -``` - -**Configuration:** - -The Keycloak settings can be overridden via environment variables: - -| Variable | Default | Description | -|---|---|---| -| `EWC_CLI_KEYCLOAK_URL` | `https://auth.europeanweather.cloud` | Keycloak server URL | -| `EWC_CLI_KEYCLOAK_REALM` | `ewc` | Keycloak realm | -| `EWC_CLI_KEYCLOAK_CLIENT_ID` | `ewccli` | OIDC client ID | -| `EWC_CLI_PORTAL_API_URL` | `https://europeanweather.cloud` | EWC portal API URL | -| `EWC_CLI_OIDC_CALLBACK_TIMEOUT` | `300` | Callback wait timeout (seconds) | -``` - -**Step 2: Commit** - -```bash -git add README.md -git commit -m "docs: document Keycloak OIDC login" -``` - ---- - -### Task 12: Run full test suite and fix any regressions - -**Objective:** Ensure all existing tests still pass and new tests are green. - -**Step 1: Run all tests** - -```bash -cd /home/kamil/projects/ewccli -pytest ewccli/tests/ -v -``` - -**Step 2: Fix any failures** - -If existing tests fail due to the `init_command` signature change (e.g., tests that call `init_command` directly), update the test calls to include `keycloak=False, no_browser=False`. - -**Step 3: Verify CLI still works** - -```bash -ewc login --help -ewc version -``` - -**Step 4: Final commit if fixes were needed** - -```bash -git add -A -git commit -m "test: fix regressions from Keycloak login integration" -``` - ---- - -## File Summary - -### New files (7) - -| File | Purpose | -|---|---| -| `ewccli/backends/keycloak/__init__.py` | Package init | -| `ewccli/backends/keycloak/pkce.py` | PKCE code_verifier/code_challenge generation | -| `ewccli/backends/keycloak/callback_server.py` | Loopback HTTP server for OIDC callback | -| `ewccli/backends/keycloak/oidc_client.py` | Auth URL builder + token exchange/refresh | -| `ewccli/backends/keycloak/portal_client.py` | Portal API client → app creds | -| `ewccli/backends/keycloak/keycloak_backend.py` | Orchestrator (`keycloak_login()`) | -| `ewccli/tests/test_keycloak_*.py` (4 files) | Tests for each module | - -### Modified files (4) - -| File | Changes | -|---|---| -| `ewccli/configuration.py` | +6 config constants for Keycloak/OIDC | -| `ewccli/utils.py` | `save_cli_profile` / `load_cli_profile` gain optional `keycloak_*` params | -| `ewccli/commands/login_command.py` | `init_options` gets `--keycloak`/`--no-browser`; `init_command` gets OIDC branch; `--tenant-name` made optional | -| `ewccli/ewccli.py` | `init()` signature updated with new params | -| `README.md` | Documentation for `--keycloak` | - -### Unchanged files (all downstream) - -- `ewccli/backends/openstack/backend_ostack.py` — `OpenstackBackend` and `connect()` untouched -- `ewccli/commands/commons_infra.py` — `connect_to_openstack_backend()` untouched -- `ewccli/commands/infra_command.py` — reads profile as before -- `ewccli/commands/hub/hub_command.py` — reads profile as before - ---- - -## Token Refresh Strategy - -### Industry best practices applied - -- **Short-lived access tokens** (5 min default): if stolen, the abuse window is small -- **Longer-lived refresh tokens** (7 days max): user authenticates once, CLI silently refreshes -- **Refresh token rotation** (RFC 9700): each refresh returns a NEW refresh_token and invalidates the old one. The CLI must immediately overwrite the stored refresh_token. This detects theft — if someone steals and uses a refresh_token, the legitimate CLI's next refresh fails. -- **Proactive refresh**: check expiry before use, not after a 401. Refresh if the access token is expired or about to expire (within a 60s skew window). - -### Token storage fields in the profile - -| Key | Type | Description | -|---|---|---| -| `keycloak_access_token` | str | Current OIDC access token | -| `keycloak_refresh_token` | str | Current refresh token (rotated on each refresh) | -| `keycloak_id_token` | str | ID token (JWT with user claims) | -| `keycloak_token_expires_at` | str (ISO 8601) | Absolute expiry timestamp of the access token. Stored as ISO 8601 UTC so no "now + expires_in" recalculation is needed. | - -### Refresh flow - -``` -get_valid_access_token(profile): - 1. Parse keycloak_token_expires_at from profile - 2. If not expired (with 60s skew): return stored access_token - 3. If expired and no refresh_token: raise ClickException("Session expired. Run: ewc login --keycloak") - 4. If expired and refresh_token exists: - a. Call oidc_client.refresh_tokens(refresh_token) - b. Receive new access_token + new refresh_token (rotation) - c. Calculate new expires_at = now + expires_in - d. Update profile INI with new tokens + expires_at - e. Return new access_token - 5. If refresh fails (HTTP 400 invalid_grant): - raise ClickException("Session expired. Run: ewc login --keycloak") -``` - -### Recommended Keycloak realm settings - -- Access token lifespan: 5 min -- Client: public (PKCE, no client secret) -- Refresh token: enabled -- Revoke Refresh Token: ON (rotation) -- Refresh Token Max Reuse: 0 (single-use) -- SSO Session Idle Timeout: 30 min -- SSO Session Max Lifespan: 7 days - -This means: user authenticates once, the CLI silently refreshes for up to 7 days, then they see "Session expired, run ewc login --keycloak". - ---- - -## Risks and Tradeoffs - -1. **Portal API contract is assumed.** The endpoint path (`/api/v1/credentials/openstack`), request shape, and response shape are all assumed. When the real API is known, only `portal_client.py` needs updating — the rest of the flow is contract-agnostic. - -2. **OIDC tokens stored in plaintext INI.** The `keycloak_access_token` and `keycloak_refresh_token` are stored in `~/.ewccli/profiles` in plaintext, same as the existing `application_credential_secret`. This is consistent but not ideal. A future enhancement could use the OS keyring. - -3. **Loopback callback server.** The callback server binds to `127.0.0.1` on a random port. If the user is behind a strict firewall or in a container without loopback, this won't work. The `--no-browser` flag helps with SSH sessions (user copies URL to local browser), but the callback still needs to reach the machine where the CLI is running. For pure headless/CI scenarios, a `--device-code` flow (RFC 8628) would be better — out of scope for this plan. - -4. **`tenant_name` validation.** The existing `validate_tenant_name` callback enforces a 3-part dash-separated pattern. When the portal API returns a `tenant_name`, it may not match this pattern. The validation is skipped in the keycloak path (the callback only runs on CLI-provided values, and the keycloak branch sets tenant_name after validation). If the portal's tenant_name format differs, it's stored as-is. - -5. **Multiple federees.** A user may have projects on both EUMETSAT and ECMWF. The current `ewc login --keycloak` flow creates one profile per invocation. To support multiple federees, the user runs `ewc login --keycloak --federee ECMWF --profile ecmwf-profile` separately. The portal API would need to support the `federee`/`region` query params as described in the contract. - -6. **Refresh token rotation atomicity.** If the CLI crashes between receiving a new refresh_token and writing it to the profile INI, the old refresh_token is already invalidated server-side. The user must re-authenticate. This is an accepted tradeoff of rotation — it's a rare edge case and the security benefit outweighs it. - ---- - -## Open Questions (to resolve during implementation) - -1. Does the portal API return `auth_url`, or should the CLI derive it from the `EWC_CLI_SITE_MAP` using the returned `federee`/`region`? If the portal returns it, we use it directly (more flexible). If not, we fall back to `EWC_CLI_SITE_MAP[federee][region]`. - -2. Should `--keycloak` be the default in the future (i.e., `ewc login` with no flags triggers OIDC)? For now it's opt-in via the flag. Consider deprecating the manual flow once the portal API is stable. - -3. Should the CLI create the application credential (via OpenStack API) if the portal only returns a project-scoped token? This would add a step: after OIDC login, use the token to authenticate to Keystone, then create an app cred via `openstack.identity.v3.application_credential`. This is more complex but removes the portal dependency. Out of scope for this plan. From 2075b745962b6f7abcbe421a2dc06a4004c01a7c Mon Sep 17 00:00:00 2001 From: Kamil Rajtar Date: Thu, 25 Jun 2026 09:41:10 +0200 Subject: [PATCH 09/12] Refactor keycloak backend --- ewccli/backends/keycloak/keycloak_backend.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ewccli/backends/keycloak/keycloak_backend.py b/ewccli/backends/keycloak/keycloak_backend.py index 49160fb..7227625 100644 --- a/ewccli/backends/keycloak/keycloak_backend.py +++ b/ewccli/backends/keycloak/keycloak_backend.py @@ -1,6 +1,8 @@ """Keycloak login orchestrator — ties PKCE, callback, OIDC, and portal together.""" +import os import webbrowser +from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime, timezone, timedelta from typing import Optional From 4c7d837b7e0a0721537c5c447111a8394f2051f7 Mon Sep 17 00:00:00 2001 From: Kamil Rajtar Date: Thu, 25 Jun 2026 11:32:08 +0200 Subject: [PATCH 10/12] feat(ewc-login): Remove gating, use KeyCloak to get credentials from openbao --- README.md | 74 +++- ewccli/backends/keycloak/callback_server.py | 23 +- ewccli/backends/keycloak/keycloak_backend.py | 111 ++--- ewccli/backends/keycloak/portal_client.py | 81 ---- ewccli/backends/openbao/__init__.py | 8 + ewccli/commands/commons_infra.py | 38 +- ewccli/commands/hub/hub_command.py | 183 ++++---- ewccli/commands/infra_command.py | 192 ++++----- ewccli/commands/login_command.py | 398 ++++++++---------- ewccli/configuration.py | 22 +- ewccli/ewccli.py | 8 - ewccli/tests/ewccli_config_test.py | 81 +++- ewccli/tests/ewccli_login_test.py | 204 ++++++++- ewccli/tests/test_keycloak_backend.py | 115 +---- ewccli/tests/test_keycloak_callback_server.py | 33 ++ ewccli/tests/test_keycloak_portal_client.py | 114 ----- ewccli/utils.py | 119 +++--- 17 files changed, 906 insertions(+), 898 deletions(-) delete mode 100644 ewccli/backends/keycloak/portal_client.py create mode 100644 ewccli/backends/openbao/__init__.py delete mode 100644 ewccli/tests/test_keycloak_portal_client.py diff --git a/README.md b/README.md index 8ae5878..18d0071 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,21 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile ewc login ``` +Keycloak authentication is **mandatory** for all EWC CLI commands (except `ewc version`). The login command: + +1. Opens a browser window for Keycloak OIDC authentication (auth code + PKCE) → an **ephemeral** access token (not stored). +2. Uses that token to authenticate to **OpenBao** (JWT/OIDC auth method) → an ephemeral OpenBao client token. +3. Reads secrets from OpenBao: + - Kubernetes kubeconfig → saved to `~/.ewccli/kubeconfigs/.yaml` + - OpenStack application credentials (id + secret) → saved to the profile. +4. Saves the downstream credentials to your profile. **No Keycloak/OpenBao tokens are persisted.** + +When a downstream credential expires (OpenStack 401/403), the command tells you to re-login: + +``` +Your OpenStack credentials have expired. Please run: ewc login --profile +``` + IMPORTANT: - EWC CLI uses the following order of importance: @@ -192,62 +207,77 @@ All your profiles are saved under `~/.ewccli/profiles` You can manually add profiles in the same file and the ewccli can use them already. -Info required for a profile: +Info stored in a profile (no OIDC tokens): ``` [my-profile] federee = EUMETSAT or ECMWF +region = WAW3-1 tenant_name = eumetsat-ewc-communityhub -application_credential_id = -application_credential_secret = +application_credential_id = +application_credential_secret = ssh_public_key_path = ssh_private_key_path = +kubeconfig_path = /home/user/.ewccli/kubeconfigs/my-profile.yaml ``` -### Login with Keycloak (OIDC) +### Multi-tenancy with `--profile` + +The `--profile` flag lets you manage multiple tenancy profiles. Each profile stores its own federee, region, tenant name, SSH keys, application credentials, and kubeconfig path. + +```bash +ewc login --profile my-profile +``` + +If `--profile` is omitted, the default profile named `default` is used. + +When you log in with a profile that already exists, the CLI skips the federee/region/tenant_name prompts and simply refreshes your downstream credentials from OpenBao. When the profile does not exist, you will be prompted to choose a federee and region interactively. + +If you do not provide `--profile` during login but supply `--federee`, `--region`, and `--tenant-name`, the profile name is auto-generated as `federee-region-tenant_name` (e.g. `eumetsat-ecis-r1-demo-user-eu`). -Instead of manually entering OpenStack application credentials, you can authenticate via Keycloak: +All commands that access cloud resources accept `--profile`: ```bash -ewc login --keycloak +ewc infra list --profile my-profile +ewc hub deploy ITEM --profile my-profile ``` -This will: -1. Open a browser window for Keycloak authentication -2. After successful login, fetch OpenStack credentials from the EWC portal -3. Save everything to your profile +### Headless / SSH sessions -If you're on a headless machine or SSH session, use `--no-browser` to print the URL instead: +If you're on a headless machine or SSH session, use `--no-browser` to print the login URL instead of opening a browser: ```bash -ewc login --keycloak --no-browser +ewc login --no-browser ``` -You can still combine with other flags: +You can also combine with other flags: ```bash -ewc login --keycloak --federee EUMETSAT --region ECIS-R1 +ewc login --profile my-profile --no-browser ``` **Configuration:** -The Keycloak settings can be overridden via environment variables: +The Keycloak and OpenBao settings can be overridden via environment variables: | Variable | Default | Description | |---|---|---| -| `EWC_CLI_KEYCLOAK_URL` | `https://auth.europeanweather.cloud` | Keycloak server URL | -| `EWC_CLI_KEYCLOAK_REALM` | `ewc` | Keycloak realm | +| `EWC_CLI_KEYCLOAK_URL` | `https://iam.europeanweather.cloud` | Keycloak server URL | +| `EWC_CLI_KEYCLOAK_REALM` | `ewc-login-broker` | Keycloak realm | | `EWC_CLI_KEYCLOAK_CLIENT_ID` | `ewccli` | OIDC client ID | | `EWC_CLI_KEYCLOAK_SCOPE` | `openid profile email` | OIDC scopes | -| `EWC_CLI_PORTAL_API_URL` | _(empty — portal disabled)_ | EWC portal API URL. When set, the CLI fetches OpenStack credentials automatically after Keycloak auth. When empty, the CLI stores OIDC tokens and falls through to the existing credential path (cloud.yaml, env vars, or manual prompt). | | `EWC_CLI_OIDC_CALLBACK_TIMEOUT` | `300` | Callback wait timeout (seconds) | -| `EWC_CLI_KEYCLOAK_LOGIN` | `0` | Set to `1` to enable Keycloak login by default | +| `EWC_CLI_OIDC_CALLBACK_PORT` | `11325` | Loopback port for the OIDC callback server | +| `EWC_CLI_OPENBAO_URL` | `https://secrets-val.internal.eumetsat.europeanweather.cloud` | OpenBao API base URL | +| `EWC_CLI_OPENBAO_OIDC_ROLE` | `default` | OpenBao OIDC auth role name | +| `EWC_CLI_OPENBAO_KV_MOUNT` | `secret` | KV2 engine mount path | +| `EWC_CLI_OPENBAO_NAMESPACE` | `openbao-users` | OpenBao namespace (sent as `X-Vault-Namespace`) | -**Token refresh:** +**Credential expiry:** -The CLI stores OIDC tokens (access + refresh) in your profile and silently refreshes them when they expire. With refresh token rotation enabled on the Keycloak realm, each refresh invalidates the old refresh token for security. When the refresh token itself expires (default: 7 days), you will see: +The CLI does **not** store Keycloak or OpenBao tokens. Instead it stores the downstream credentials (OpenStack application credentials and kubeconfig) retrieved from OpenBao. When those credentials expire or are revoked, cloud operations will fail with a 401/403 and the CLI will prompt you to re-login: ``` -Your EWC session has expired. Please run: ewc login --keycloak +Your OpenStack credentials have expired. Please run: ewc login --profile ``` ## List Items in the catalog diff --git a/ewccli/backends/keycloak/callback_server.py b/ewccli/backends/keycloak/callback_server.py index 1c62da6..9aa0648 100644 --- a/ewccli/backends/keycloak/callback_server.py +++ b/ewccli/backends/keycloak/callback_server.py @@ -1,6 +1,6 @@ """Lightweight HTTP server to receive the OIDC authorization code callback.""" -import os +import time import threading from http.server import BaseHTTPRequestHandler, HTTPServer from typing import Optional @@ -33,19 +33,19 @@ class CallbackServer: server.stop() """ - def __init__(self, expected_state: str): + def __init__(self, expected_state: str, port: int = 0): self._expected_state = expected_state self._result: Optional[tuple[str, str]] = None self._error: Optional[str] = None self._httpd: Optional[HTTPServer] = None self._thread: Optional[threading.Thread] = None + self._requested_port = port self.port: int = 0 def start(self) -> None: """Start the server on a loopback port.""" handler = self._make_handler() - port = int(os.getenv("EWC_CLI_OIDC_CALLBACK_PORT", "0")) - self._httpd = HTTPServer(("127.0.0.1", port), handler) + self._httpd = HTTPServer(("127.0.0.1", self._requested_port), handler) self.port = self._httpd.server_address[1] self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) self._thread.start() @@ -62,12 +62,21 @@ def wait_for_callback(self, timeout: float = 300) -> Optional[tuple[str, str]]: """Block until the callback is received or timeout. Returns (code, state) on success, or None on timeout/error. + + Uses a polling loop instead of a blocking thread.join() so that + SIGINT (Ctrl+C) can interrupt the wait on the main thread. """ if self._thread is None: return None - self._thread.join(timeout=timeout) - if self._thread.is_alive(): - return None # timed out + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if self._result is not None or self._error is not None: + break + if not self._thread.is_alive(): + break + time.sleep(0.2) + return self._result @property diff --git a/ewccli/backends/keycloak/keycloak_backend.py b/ewccli/backends/keycloak/keycloak_backend.py index 7227625..a313c06 100644 --- a/ewccli/backends/keycloak/keycloak_backend.py +++ b/ewccli/backends/keycloak/keycloak_backend.py @@ -1,10 +1,12 @@ -"""Keycloak login orchestrator — ties PKCE, callback, OIDC, and portal together.""" +"""Keycloak login orchestrator — OIDC auth code + PKCE flow. + +Returns an ephemeral access token. No tokens are persisted here; the caller +decides what to do with the access token (e.g. exchange it for OpenBao +credentials). +""" -import os import webbrowser -from contextlib import contextmanager from dataclasses import dataclass -from datetime import datetime, timezone, timedelta from typing import Optional from click import ClickException @@ -13,58 +15,41 @@ from ewccli.backends.keycloak.callback_server import CallbackServer from ewccli.backends.keycloak.oidc_client import OIDCClient from ewccli.backends.keycloak.pkce import generate_pkce_pair, generate_state -from ewccli.backends.keycloak.portal_client import PortalClient from ewccli.logger import get_logger _LOGGER = get_logger(__name__) _console = Console() -def _compute_expires_at(expires_in: int) -> str: - """Compute the absolute expiry timestamp from an expires_in value.""" - expiry = datetime.now(timezone.utc) + timedelta(seconds=expires_in) - return expiry.isoformat() - - @dataclass class KeycloakLoginResult: """Result of a successful Keycloak login.""" - application_credential_id: str - application_credential_secret: str - auth_url: str access_token: str - refresh_token: Optional[str] - id_token: Optional[str] - token_expires_at: str - federee: Optional[str] = None - region: Optional[str] = None - tenant_name: Optional[str] = None def keycloak_login( config, open_browser: bool = True, federee: Optional[str] = None, - region: Optional[str] = None, ) -> KeycloakLoginResult: """Run the full Keycloak OIDC login flow. - 1. Start a local callback server - 2. Build the authorization URL (PKCE) - 3. Print URL and optionally open browser - 4. Wait for callback - 5. Exchange code for tokens - 6. Call portal API for OpenStack credentials + 1. Generate PKCE pair and state + 2. Start a local callback server + 3. Build the authorization URL (PKCE) + 4. Print URL and optionally open browser + 5. Wait for callback + 6. Exchange code for tokens Args: config: EWCCLIConfiguration instance with Keycloak settings. open_browser: If True, attempt to open the browser automatically. - federee: Optional federee to pass to the portal API. - region: Optional region to pass to the portal API. + federee: Optional federee (unused by the OIDC flow itself, kept for + API compatibility). Returns: - KeycloakLoginResult with app creds and OIDC tokens. + KeycloakLoginResult with the ephemeral access token. Raises: ClickException: On timeout, state mismatch, or API errors. @@ -76,7 +61,10 @@ def keycloak_login( state = generate_state() # 2. Start callback server - server = CallbackServer(expected_state=state) + server = CallbackServer( + expected_state=state, + port=config.EWC_CLI_OIDC_CALLBACK_PORT, + ) server.start() _LOGGER.debug(f"Callback server listening on port {server.port}") @@ -118,7 +106,11 @@ def keycloak_login( _console.print(f"\nWaiting for authentication (timeout: {timeout}s)...") # 5. Wait for callback - callback_result = server.wait_for_callback(timeout=timeout) + try: + callback_result = server.wait_for_callback(timeout=timeout) + except KeyboardInterrupt: + server.stop() + raise ClickException("Authentication cancelled by user.") redirect_uri = server.redirect_uri server.stop() @@ -148,57 +140,4 @@ def keycloak_login( _console.print("[green]Authentication successful![/green]") - # 7. Fetch OpenStack credentials from portal (if configured) - expires_in = tokens.get("expires_in", 300) - token_expires_at = _compute_expires_at(expires_in) - - portal_url = getattr(config, "EWC_CLI_PORTAL_API_URL", "") - if not portal_url: - # Portal not configured — store OIDC tokens only, fall through to - # the existing credential path (cloud.yaml, env vars, or manual prompt). - _console.print( - "[yellow]Portal API not configured — skipping OpenStack credential fetch. " - "Set EWC_CLI_PORTAL_API_URL to enable automatic credential retrieval.[/yellow]" - ) - return KeycloakLoginResult( - application_credential_id="", - application_credential_secret="", - auth_url="", - access_token=tokens["access_token"], - refresh_token=tokens.get("refresh_token"), - id_token=tokens.get("id_token"), - token_expires_at=token_expires_at, - federee=federee, - region=region, - tenant_name=None, - ) - - portal_client = PortalClient( - portal_api_url=portal_url, - ) - - try: - creds = portal_client.fetch_openstack_credentials( - access_token=tokens["access_token"], - federee=federee, - region=region, - ) - except Exception as e: - raise ClickException( - f"Failed to fetch OpenStack credentials from EWC portal: {e}" - ) - - _console.print("[green]OpenStack credentials obtained![/green]") - - return KeycloakLoginResult( - application_credential_id=creds.application_credential_id, - application_credential_secret=creds.application_credential_secret, - auth_url=creds.auth_url, - access_token=tokens["access_token"], - refresh_token=tokens.get("refresh_token"), - id_token=tokens.get("id_token"), - token_expires_at=token_expires_at, - federee=creds.federee, - region=creds.region, - tenant_name=creds.tenant_name, - ) + return KeycloakLoginResult(access_token=tokens["access_token"]) diff --git a/ewccli/backends/keycloak/portal_client.py b/ewccli/backends/keycloak/portal_client.py deleted file mode 100644 index 4d0a1d3..0000000 --- a/ewccli/backends/keycloak/portal_client.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Portal API client — exchanges OIDC tokens for OpenStack application credentials.""" - -from dataclasses import dataclass -from typing import Optional - -import requests - -from ewccli.logger import get_logger - -_LOGGER = get_logger(__name__) - - -@dataclass -class PortalCredentials: - """OpenStack application credentials returned by the EWC portal.""" - - application_credential_id: str - application_credential_secret: str - auth_url: str - federee: Optional[str] = None - region: Optional[str] = None - tenant_name: Optional[str] = None - - -class PortalClient: - """Calls the EWC portal API to obtain OpenStack application credentials.""" - - def __init__(self, portal_api_url: str): - self._portal_api_url = portal_api_url.rstrip("/") - - @property - def credentials_endpoint(self) -> str: - return f"{self._portal_api_url}/api/v1/credentials/openstack" - - def fetch_openstack_credentials( - self, - access_token: str, - federee: Optional[str] = None, - region: Optional[str] = None, - ) -> PortalCredentials: - """Fetch OpenStack application credentials from the portal API. - - Args: - access_token: The OIDC access token from Keycloak. - federee: Optional federee to request credentials for. - region: Optional region to request credentials for. - - Returns: - PortalCredentials dataclass with the app cred id/secret and auth_url. - - Raises: - requests.HTTPError: If the API call fails. - """ - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - } - body: dict = {} - if federee: - body["federee"] = federee - if region: - body["region"] = region - - _LOGGER.info("Fetching OpenStack credentials from EWC portal") - response = requests.post( - self.credentials_endpoint, - headers=headers, - json=body if body else None, - timeout=30, - ) - response.raise_for_status() - data = response.json() - - return PortalCredentials( - application_credential_id=data["application_credential_id"], - application_credential_secret=data["application_credential_secret"], - auth_url=data["auth_url"], - federee=data.get("federee"), - region=data.get("region"), - tenant_name=data.get("tenant_name"), - ) diff --git a/ewccli/backends/openbao/__init__.py b/ewccli/backends/openbao/__init__.py new file mode 100644 index 0000000..b4a1423 --- /dev/null +++ b/ewccli/backends/openbao/__init__.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python +# +# Package Name: ewccli +# License: GPL-3.0-or-later +# Copyright (c) 2025 EUMETSAT, ECMWF for European Weather Cloud +# See the LICENSE file for more details + +"""Openbao backend for EWC CLI.""" diff --git a/ewccli/commands/commons_infra.py b/ewccli/commands/commons_infra.py index fea10eb..245ef7f 100644 --- a/ewccli/commands/commons_infra.py +++ b/ewccli/commands/commons_infra.py @@ -20,8 +20,9 @@ from click import ClickException from openstack import connection +from openstack.exceptions import HttpException, ForbiddenException -from ewccli.utils import save_encoded_ssh_keys, check_ssh_keys_match +from ewccli.utils import save_encoded_ssh_keys, check_ssh_keys_match, CredentialExpiredError from ewccli.backends.openstack.backend_ostack import OpenstackBackend from ewccli.enums import Federee, Region from ewccli.configuration import config as ewc_hub_config @@ -1151,8 +1152,41 @@ def connect_to_openstack_backend( application_credential_id=application_credential_id, application_credential_secret=application_credential_secret, ) + except (ForbiddenException, HttpException) as op_error: + status_code = getattr(op_error, "status_code", None) + if status_code in (401, 403): + raise CredentialExpiredError( + f"OpenStack authentication failed ({status_code}): {op_error}" + ) from op_error + raise ClickException( + f"Could not connect to Openstack due to the following error: {op_error}" + ) from op_error except Exception as op_error: raise ClickException( f"Could not connect to Openstack due to the following error: {op_error}" - ) + ) from op_error return openstack_api + + +def handle_credential_expiry(profile: str): + """Context manager that catches CredentialExpiredError and exits. + + Wraps OpenStack operations so that expired/revoked credentials produce + a clear "please re-login" message instead of a raw stack trace. + """ + from contextlib import contextmanager + + @contextmanager + def _cm(): + try: + yield + except CredentialExpiredError: + click.secho( + "Your OpenStack credentials have expired. " + f"Please run: ewc login --profile {profile}", + fg="red", + bold=True, + ) + sys.exit(1) + + return _cm() diff --git a/ewccli/commands/hub/hub_command.py b/ewccli/commands/hub/hub_command.py index fd7e63f..819d644 100644 --- a/ewccli/commands/hub/hub_command.py +++ b/ewccli/commands/hub/hub_command.py @@ -40,6 +40,7 @@ from ewccli.commands.commons import load_hub_items from ewccli.commands.commons_infra import create_server_command from ewccli.commands.commons_infra import check_user_ssh_keys +from ewccli.commands.commons_infra import handle_credential_expiry from ewccli.commands.hub.hub_backends import git_clone_item from ewccli.commands.hub.hub_backends import run_ansible_playbook_item from ewccli.commands.hub.hub_backends import get_hub_item_env_variable_value @@ -363,17 +364,13 @@ def deploy_cmd( # noqa: CFQ002, CFQ001, CCR001, C901 """ if dry_run: _LOGGER.info("Dry run enabled...") - - if profile: cli_profile = load_cli_profile( - profile=profile, + profile=profile or ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME, dry_run=dry_run ) else: - # Use default profile if exists cli_profile = load_cli_profile( - profile=ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME, - dry_run=dry_run + profile=profile or ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME ) _LOGGER.info(f"Using `{cli_profile.get('profile')}` profile.") @@ -558,92 +555,102 @@ def deploy_cmd( # noqa: CFQ002, CFQ001, CCR001, C901 # Authenticate to Openstack ##################################################################################### - try: - # Step 1: Authenticate and initialize the OpenStack connection - openstack_api = openstack_backend.connect( - auth_url=auth_url, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, - ) - except Exception as op_error: - raise ClickException( - f"Could not connect to Openstack due to the following error: {op_error}" - ) - - ########################################## - # Validate inputs - ########################################### - # R = required - # D = default - # catalog -> D (yaml inputs) - # user -> R or D (overwrite) (bash inputs) - ########################################### - - # Prepare default parameters - for d_item in default_item_inputs: - default_item_input_name = d_item.get("name") - - # If default value is not provided by the user. - if default_item_input_name not in item_inputs: - # TODO: Improve this logic with new parameter in the catalog - # Take the default from the EWC values if they exist - if default_item_input_name in HUB_ENV_VARIABLES_MAP: - item_inputs[default_item_input_name] = ( - get_hub_item_env_variable_value( - hub_item_env_variables_map=HUB_ENV_VARIABLES_MAP, - federee=federee, - tenancy_name=tenancy_name, - variable_name=default_item_input_name, - openstack_api=openstack_api, - ) - ) - else: - # Take the default from the catalog - item_inputs[default_item_input_name] = d_item.get("default") - - # Validate all input parameters (R + D) - # (R) Validate required inputs - # (D) Validate default inputs provided by user (overwritten) or from default section of the catalog - validation_message = validate_item_input_types( - parsed_inputs=item_inputs, - item_info_inputs=item_info_inputs, - ) + with handle_credential_expiry(cli_profile.get("profile")): + try: + # Step 1: Authenticate and initialize the OpenStack connection + openstack_api = openstack_backend.connect( + auth_url=auth_url, + application_credential_id=application_credential_id, + application_credential_secret=application_credential_secret, + ) + except Exception as op_error: + from openstack.exceptions import HttpException, ForbiddenException - if validation_message: - raise click.UsageError(validation_message) + status_code = getattr(op_error, "status_code", None) + if isinstance(op_error, (HttpException, ForbiddenException)) and status_code in (401, 403): + from ewccli.utils import CredentialExpiredError - ##################################################################################### - # Deploy Server (Openstack) - ##################################################################################### + raise CredentialExpiredError( + f"OpenStack authentication failed ({status_code}): {op_error}" + ) from op_error + raise ClickException( + f"Could not connect to Openstack due to the following error: {op_error}" + ) from op_error + + ########################################## + # Validate inputs + ########################################### + # R = required + # D = default + # catalog -> D (yaml inputs) + # user -> R or D (overwrite) (bash inputs) + ########################################### + + # Prepare default parameters + for d_item in default_item_inputs: + default_item_input_name = d_item.get("name") + + # If default value is not provided by the user. + if default_item_input_name not in item_inputs: + # TODO: Improve this logic with new parameter in the catalog + # Take the default from the EWC values if they exist + if default_item_input_name in HUB_ENV_VARIABLES_MAP: + item_inputs[default_item_input_name] = ( + get_hub_item_env_variable_value( + hub_item_env_variables_map=HUB_ENV_VARIABLES_MAP, + federee=federee, + tenancy_name=tenancy_name, + variable_name=default_item_input_name, + openstack_api=openstack_api, + ) + ) + else: + # Take the default from the catalog + item_inputs[default_item_input_name] = d_item.get("default") + + # Validate all input parameters (R + D) + # (R) Validate required inputs + # (D) Validate default inputs provided by user (overwritten) or from default section of the catalog + validation_message = validate_item_input_types( + parsed_inputs=item_inputs, + item_info_inputs=item_info_inputs, + ) - server_inputs = { - "server_name": server_name, - "is_gpu": is_gpu, - "image_name": item_info_ewccli.get(HubItemCLIKeys.DEFAULT_IMAGE_NAME.value) if not image_name else image_name, - "keypair_name": keypair_name, - "flavour_name": flavour_name, - "external_ip": external_ip - or item_info_ewccli.get(HubItemCLIKeys.EXTERNAL_IP.value), - "networks": networks, - "security_groups": security_groups, - "item_default_security_groups": item_info_ewccli.get( - HubItemCLIKeys.DEFAULT_SECURITY_GROUPS.value + if validation_message: + raise click.UsageError(validation_message) + + ##################################################################################### + # Deploy Server (Openstack) + ##################################################################################### + + server_inputs = { + "server_name": server_name, + "is_gpu": is_gpu, + "image_name": item_info_ewccli.get(HubItemCLIKeys.DEFAULT_IMAGE_NAME.value) if not image_name else image_name, + "keypair_name": keypair_name, + "flavour_name": flavour_name, + "external_ip": external_ip + or item_info_ewccli.get(HubItemCLIKeys.EXTERNAL_IP.value), + "networks": networks, + "security_groups": security_groups, + "item_default_security_groups": item_info_ewccli.get( + HubItemCLIKeys.DEFAULT_SECURITY_GROUPS.value + ) + } + + os_status_code, os_message, outputs = create_server_command( + openstack_backend=openstack_backend, + openstack_api=openstack_api, + federee=federee, + region=region, + server_inputs=server_inputs, + ssh_private_encoded=ssh_private_encoded, + ssh_public_encoded=ssh_public_encoded, + ssh_public_key_path=ssh_public_key_path, + ssh_private_key_path=ssh_private_key_path, + dry_run=dry_run, + force=force, ) - } - - os_status_code, os_message, outputs = create_server_command( - openstack_backend=openstack_backend, - openstack_api=openstack_api, - federee=federee, - region=region, - server_inputs=server_inputs, - ssh_private_encoded=ssh_private_encoded, - ssh_public_encoded=ssh_public_encoded, - ssh_public_key_path=ssh_public_key_path, - ssh_private_key_path=ssh_private_key_path, - dry_run=dry_run, - force=force, - ) internal_ip_machine = outputs["internal_ip_machine"] external_ip_machine = outputs["external_ip_machine"] diff --git a/ewccli/commands/infra_command.py b/ewccli/commands/infra_command.py index 0b95ce7..c4f6a33 100644 --- a/ewccli/commands/infra_command.py +++ b/ewccli/commands/infra_command.py @@ -29,8 +29,8 @@ from ewccli.commands.commons import login_options from ewccli.commands.commons_infra import check_user_ssh_keys, connect_to_openstack_backend from ewccli.commands.commons_infra import get_deployed_server_info, list_server_details -from ewccli.commands.commons_infra import create_server_command -from ewccli.utils import load_cli_profile +from ewccli.commands.commons_infra import create_server_command, handle_credential_expiry +from ewccli.utils import load_cli_profile, CredentialExpiredError from ewccli.logger import get_logger _LOGGER = get_logger(__name__) @@ -46,14 +46,10 @@ @login_options def ewc_infra_command(ctx, profile): """EWC Infrastructure commands group.""" - if profile: - ctx.cli_profile = load_cli_profile(profile=profile) - _LOGGER.info(f"Using `{profile}` profile.") - else: - ctx.cli_profile = load_cli_profile( - profile=ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME - ) - _LOGGER.info(f"Using `{ctx.cli_profile.get('profile')}` profile.") + ctx.cli_profile = load_cli_profile( + profile=profile or ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME + ) + _LOGGER.info(f"Using `{ctx.cli_profile.get('profile')}` profile.") federee = ctx.cli_profile.get("federee") region = ctx.cli_profile.get("region") @@ -164,38 +160,39 @@ def create_cmd( _LOGGER.info(f"The server will be deployed on {federee} side of the EWC.") - openstack_api = connect_to_openstack_backend( - ctx=ctx, - auth_url=auth_url, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret - ) + with handle_credential_expiry(ctx.cli_profile["profile"]): + openstack_api = connect_to_openstack_backend( + ctx=ctx, + auth_url=auth_url, + application_credential_id=application_credential_id, + application_credential_secret=application_credential_secret + ) - server_inputs = { - "server_name": server_name, - "is_gpu": None, - "image_name": image_name, - "keypair_name": keypair_name, - "flavour_name": flavour_name, - "external_ip": external_ip, - "networks": networks, - "security_groups": security_groups, - "item_default_security_groups": ewc_hub_config.DEFAULT_SECURITY_GROUP_MAP[federee] - } - - os_status_code, os_message, outputs = create_server_command( - openstack_backend=ctx.openstack_backend, - openstack_api=openstack_api, - federee=federee, - region=region, - server_inputs=server_inputs, - ssh_private_encoded=ssh_private_encoded, - ssh_public_encoded=ssh_public_encoded, - ssh_public_key_path=ssh_public_key_path, - ssh_private_key_path=ssh_private_key_path, - dry_run=dry_run, - force=force, - ) + server_inputs = { + "server_name": server_name, + "is_gpu": None, + "image_name": image_name, + "keypair_name": keypair_name, + "flavour_name": flavour_name, + "external_ip": external_ip, + "networks": networks, + "security_groups": security_groups, + "item_default_security_groups": ewc_hub_config.DEFAULT_SECURITY_GROUP_MAP[federee] + } + + os_status_code, os_message, outputs = create_server_command( + openstack_backend=ctx.openstack_backend, + openstack_api=openstack_api, + federee=federee, + region=region, + server_inputs=server_inputs, + ssh_private_encoded=ssh_private_encoded, + ssh_public_encoded=ssh_public_encoded, + ssh_public_key_path=ssh_public_key_path, + ssh_private_key_path=ssh_private_key_path, + dry_run=dry_run, + force=force, + ) internal_ip_machine = outputs["internal_ip_machine"] external_ip_machine = outputs["external_ip_machine"] normalized_image_name = outputs.get("normalized_image_name") @@ -261,37 +258,38 @@ def show_cmd( """Show Server from Openstack.""" federee = federee or ctx.cli_profile["federee"] - openstack_api = connect_to_openstack_backend( - ctx=ctx, - auth_url=auth_url, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret - ) - - try: - # Find the server info by name - server_info = openstack_api.get_server(name_or_id=server_name) - except Exception as e: - raise ClickException( - f"Could not retrieve server {server_name} from Openstack due to: {e}" + with handle_credential_expiry(ctx.cli_profile["profile"]): + openstack_api = connect_to_openstack_backend( + ctx=ctx, + auth_url=auth_url, + application_credential_id=application_credential_id, + application_credential_secret=application_credential_secret ) - if not server_info: - click.echo(f"Server '{server_name}' not found.") - return + try: + # Find the server info by name + server_info = openstack_api.get_server(name_or_id=server_name) + except Exception as e: + raise ClickException( + f"Could not retrieve server {server_name} from Openstack due to: {e}" + ) - image_id = server_info.get("image", "").get("id") - image_info = openstack_api.image.find_image(image_id) + if not server_info: + click.echo(f"Server '{server_name}' not found.") + return - image_name = image_info.get("name") + image_id = server_info.get("image", "").get("id") + image_info = openstack_api.image.find_image(image_id) - vm_info = get_deployed_server_info( - federee=federee, - server_info=server_info, - image_name=image_name, - ) + image_name = image_info.get("name") + + vm_info = get_deployed_server_info( + federee=federee, + server_info=server_info, + image_name=image_name, + ) - list_server_details(vm_info) + list_server_details(vm_info) @ewc_infra_command.command(name="list", help="List servers in Openstack.") @@ -316,22 +314,23 @@ def list_cmd( """List Servers from Openstack.""" federee = federee or ctx.cli_profile["federee"] - openstack_api = connect_to_openstack_backend( - ctx=ctx, - auth_url=auth_url, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret - ) - - try: - servers = ctx.openstack_backend.list_servers( - conn=openstack_api, show_all=show_all, federee=federee - ) - except Exception as e: - raise ClickException( - f"Could not retrieve server list from Openstack due to: {e}" + with handle_credential_expiry(ctx.cli_profile["profile"]): + openstack_api = connect_to_openstack_backend( + ctx=ctx, + auth_url=auth_url, + application_credential_id=application_credential_id, + application_credential_secret=application_credential_secret ) - list_server_table(servers=servers) + + try: + servers = ctx.openstack_backend.list_servers( + conn=openstack_api, show_all=show_all, federee=federee + ) + except Exception as e: + raise ClickException( + f"Could not retrieve server list from Openstack due to: {e}" + ) + list_server_table(servers=servers) @ewc_infra_command.command(name="delete", help="Delete server in Openstack.") @@ -366,23 +365,24 @@ def delete_cmd( ): """Delete VM from Openstack.""" - openstack_api = connect_to_openstack_backend( - ctx=ctx, - auth_url=auth_url, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret - ) + with handle_credential_expiry(ctx.cli_profile["profile"]): + openstack_api = connect_to_openstack_backend( + ctx=ctx, + auth_url=auth_url, + application_credential_id=application_credential_id, + application_credential_secret=application_credential_secret + ) - server_name = os.getenv("EWC_CLI_OS_SERVER_NAME") or server_name + server_name = os.getenv("EWC_CLI_OS_SERVER_NAME") or server_name - try: - ctx.openstack_backend.delete_server( - conn=openstack_api, server_name=server_name, force=force, dry_run=dry_run - ) - except Exception as e: - raise ClickException( - f"Could not delete server {server_name} from Openstack due to: {e}" - ) + try: + ctx.openstack_backend.delete_server( + conn=openstack_api, server_name=server_name, force=force, dry_run=dry_run + ) + except Exception as e: + raise ClickException( + f"Could not delete server {server_name} from Openstack due to: {e}" + ) # def remove_server_external_ip( diff --git a/ewccli/commands/login_command.py b/ewccli/commands/login_command.py index eb0417b..60b0552 100644 --- a/ewccli/commands/login_command.py +++ b/ewccli/commands/login_command.py @@ -8,7 +8,8 @@ """CLI EWC Login: EWC Login interaction.""" -import os +import base64 +import json import re from typing import Optional from pathlib import Path @@ -17,26 +18,15 @@ from rich.console import Console from click import ClickException -from configparser import ConfigParser - from prompt_toolkit.application import Application from prompt_toolkit.widgets import RadioList, Box, Frame from prompt_toolkit.layout import Layout from prompt_toolkit.styles import Style -from kubernetes import config -from kubernetes.config.config_exception import ( # noqa: N813 - ConfigException as kubernetes_config_exception, -) -from openstack.config import OpenStackConfig -from openstack.exceptions import ( # noqa: N813 - ConfigException as openstack_config_exception, -) - from ewccli.configuration import config as ewc_hub_config -from ewccli.utils import save_cli_profile, _resolve_profile +from ewccli.utils import save_cli_profile +from ewccli.utils import profile_exists, update_cli_profile_credentials from ewccli.utils import generate_ssh_keypair, check_ssh_keys_match -from ewccli.utils import save_default_login_profile from ewccli.enums import Federee, Region from ewccli.logger import get_logger @@ -46,10 +36,33 @@ console = Console() +def _decode_jwt_subject(token: str) -> Optional[str]: + """Extract the ``sub`` claim from a JWT without verifying its signature. + + Used to derive the OpenBao secret path (keyed by the Keycloak user id). + Returns ``None`` if the token is malformed or has no ``sub`` claim. + """ + try: + payload_b64 = token.split(".")[1] + # Add padding required by base64.urlsafe_b64decode + payload_b64 += "=" * (-len(payload_b64) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_b64).decode("utf-8")) + except (IndexError, ValueError, UnicodeDecodeError): + return None + + sub = payload.get("sub") + return sub if isinstance(sub, str) and sub else None + + def kubeconfig_available(): """Verify if kubeconfig is available.""" try: - config.load_kube_config() + from kubernetes import config as k8s_config + from kubernetes.config.config_exception import ( # noqa: N813 + ConfigException as kubernetes_config_exception, + ) + + k8s_config.load_kube_config() return True except kubernetes_config_exception as e: _LOGGER.warning( @@ -57,43 +70,8 @@ def kubeconfig_available(): "You could set KUBECONFIG=/path/to/your/kubeconfig or continue below using the token" ) return False - - -def cloud_yaml_exists(): - """Check if OpenStack clouds.yaml file exists.""" - # Default OpenStack config paths (can vary by environment) - default_paths = [ - Path( - os.getenv("OS_CLIENT_CONFIG_FILE", "~/.config/openstack/clouds.yaml") - ).expanduser(), - Path("/etc/openstack/clouds.yaml"), - ] - - return any(p.exists() for p in default_paths) - - -def openstack_config_available(): - """Verify if OpenStack cloud config is available.""" - try: - os_config = OpenStackConfig() - if cloud_yaml_exists(): - os_config.get_one_cloud() - else: - _LOGGER.warning( - "⚠️ OpenStack cloud config not found at '~/.config/openstack/cloud.yaml'\n" - "You can set the config path with the environment variable:\n" - " OS_CLIENT_CONFIG_FILE=/path/to/clouds.yaml\n" - "Alternatively, provide your credentials using:\n" - " OS_APPLICATION_CREDENTIAL_ID and OS_APPLICATION_CREDENTIAL_SECRET\n" - "Or continue below to enter them manually." - ) - return False - return True - except openstack_config_exception as e: - _LOGGER.warning( - f"⚠️ OpenStack cloud config not found: {e}\n" - "You can also set the config path with `OS_CLIENT_CONFIG_FILE=/path/to/clouds.yaml` or continue below" - ) + except Exception as e: + _LOGGER.warning(f"⚠️ Kubeconfig not found: {e}") return False @@ -133,8 +111,7 @@ def init_options(func): required=False, callback=validate_tenant_name, help=( - "Name of your tenancy in EWC, used to identify cloud configurations. " - "Required when not using --keycloak.\n" + "Name of your tenancy in EWC, used to identify cloud configurations.\n" "Must follow the format: 'part1-part2-part3' (e.g. 'demo-user-eu'), " "where each part is alphanumeric and separated by dashes.\n" "Can also be set via the EWC_CLI_LOGIN_TENANT_NAME environment variable." @@ -163,40 +140,6 @@ def init_options(func): "If not provided, you'll be prompted to choose." ), )(func) - func = click.option( - "--application-credential-id", - required=False, - hide_input=True, - help=( - "OpenStack Application Credential ID. " - "Ignored if environment variable OS_APPLICATION_CREDENTIAL_ID is set, " - "or if a cloud.yaml config is found at '~/.config/openstack/cloud.yaml' " - "or at the path specified by OS_CLIENT_CONFIG_FILE." - ), - )(func) - func = click.option( - "--application-credential-secret", - required=False, - hide_input=True, - help=( - "OpenStack Application Credential Secret. " - "Ignored if environment variable OS_APPLICATION_CREDENTIAL_SECRET is set, " - "or if a cloud.yaml config is found at '~/.config/openstack/cloud.yaml' " - "or at the path specified by OS_CLIENT_CONFIG_FILE." - ), - )(func) - # func = click.option( - # "--token", - # hide_input=True, - # required=False, - # default="", - # help=( - # "Kubernetes token (leave blank if not needed).\n" - # "Provide this only if you plan to use Kubernetes services and " - # "do not have a kubeconfig file available " - # "(e.g. ~/.kube/config or via the KUBECONFIG environment variable)." - # ), - # )(func) func = click.option( "--ssh-public-key-path", required=False, @@ -219,18 +162,6 @@ def init_options(func): required=False, help="EWC CLI profile name", )(func) - func = click.option( - "--keycloak", - is_flag=True, - default=False, - envvar="EWC_CLI_KEYCLOAK_LOGIN", - help=( - "Login via Keycloak OIDC (browser-based). " - "Opens a browser for authentication and fetches " - "OpenStack credentials automatically from the EWC portal. " - "Can also be set via EWC_CLI_KEYCLOAK_LOGIN=1." - ), - )(func) func = click.option( "--no-browser", is_flag=True, @@ -365,7 +296,7 @@ def check_and_generate_ssh_keys( ) else: click.secho("SSH private and public keys are matching! Continuing...", fg="green") - + return ssh_private_key_path, ssh_public_key_path elif not private_exists and not public_exists: @@ -406,163 +337,168 @@ def check_and_generate_ssh_keys( ) +def _fetch_openbao_credentials(access_token: str, profile_name: str): + """Login to OpenBao with the Keycloak access token and read the user secret. + + Returns a dict with keys: ``kubeconfig``, ``application_credential_id``, + ``application_credential_secret``. The kubeconfig (if any) is written to + ``~/.ewccli/kubeconfigs/.yaml`` and its path is returned in + place of the raw content. + """ + from ewccli.backends.openbao.openbao_client import OpenBaoClient, OpenBaoError + + user_id = _decode_jwt_subject(access_token) + if not user_id: + raise ClickException( + "Could not extract user id (sub claim) from the Keycloak access token. " + "Please run 'ewc login' again." + ) + + client = OpenBaoClient( + url=ewc_hub_config.EWC_CLI_OPENBAO_URL, + namespace=ewc_hub_config.EWC_CLI_OPENBAO_NAMESPACE, + role=ewc_hub_config.EWC_CLI_OPENBAO_OIDC_ROLE, + kv_mount=ewc_hub_config.EWC_CLI_OPENBAO_KV_MOUNT, + access_token=access_token, + ) + + try: + client.login() + secret_data = client.read_secret(user_id) + except OpenBaoError as e: + raise ClickException( + f"Failed to retrieve credentials from OpenBao: {e}" + ) from e + + kubeconfig_content = secret_data.get("kubeconfig") + kubeconfig_path = None + if kubeconfig_content: + kubeconfig_dir = ewc_hub_config.EWC_CLI_KUBECONFIG_PATH + kubeconfig_dir.mkdir(parents=True, exist_ok=True) + kubeconfig_path = kubeconfig_dir / f"{profile_name}.yaml" + kubeconfig_path.write_text(kubeconfig_content) + _LOGGER.info(f"Kubeconfig saved to {kubeconfig_path}") + + return { + "kubeconfig_path": str(kubeconfig_path) if kubeconfig_path else None, + "application_credential_id": secret_data.get("application_credential_id"), + "application_credential_secret": secret_data.get("application_credential_secret"), + } + + def init_command( - application_credential_id: str, - application_credential_secret: str, ssh_public_key_path: str, ssh_private_key_path: str, tenant_name: str, federee: str, region: str, profile: str = None, - keycloak: bool = False, no_browser: bool = False, - # token: str, ): - """EWC CLI Login.""" - # --- Keycloak OIDC login path (run first; may fill federee/region/tenant_name) --- - if keycloak: - from ewccli.backends.keycloak.keycloak_backend import keycloak_login - - kc_result = keycloak_login( - config=ewc_hub_config, - open_browser=not no_browser, - federee=federee, - region=region, - ) - - if kc_result.application_credential_id: - application_credential_id = kc_result.application_credential_id - application_credential_secret = kc_result.application_credential_secret + """EWC CLI Login — Keycloak OIDC → OpenBao → downstream credentials. - if kc_result.federee: - federee = kc_result.federee - if kc_result.region: - region = kc_result.region - if kc_result.tenant_name: - tenant_name = kc_result.tenant_name + No Keycloak tokens are persisted. The profile stores only the downstream + credentials (app creds, kubeconfig path, federee, region, tenant_name, + SSH keys). + """ + from ewccli.backends.keycloak.keycloak_backend import keycloak_login + from ewccli.utils import load_cli_profile - # --- Interactive prompts for whatever is still missing --- - if not federee: - federee = select_federee() - if not federee: - console.print("No federee selection made. Exiting.") - return + # 1. Resolve profile name (use default if not given) + resolved_profile = profile or ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME + profiles_file_path = ewc_hub_config.EWC_CLI_PROFILES_PATH - console.print(f"Considering federee: {federee}") + # 2. Check if the profile already exists (re-login) + profile_already_exists = profile_exists(resolved_profile, profiles_file_path) - if not region: - region = select_region(federee=federee) + if profile_already_exists: + existing_profile = load_cli_profile( + profile=resolved_profile, profiles_file_path=profiles_file_path + ) + if not federee: + federee = existing_profile.get("federee") if not region: - console.print("No region selection made. Exiting.") - return - - allowed_regions = ewc_hub_config.allowed_regions(federee) - if region not in allowed_regions: - raise click.BadParameter( - f"Region '{region}' is not valid for federee '{federee}'. " - f"Allowed: {', '.join(allowed_regions)}" + region = existing_profile.get("region") + if not tenant_name: + tenant_name = existing_profile.get("tenant_name") + console.print( + f"Using existing profile '[bold cyan]{resolved_profile}[/bold cyan]' — refreshing credentials." ) - if not tenant_name: - tenant_name = click.prompt("Tenant name") + # 3. Keycloak OIDC login → ephemeral access token + kc_result = keycloak_login( + config=ewc_hub_config, + open_browser=not no_browser, + federee=federee, + ) - resolved_profile = _resolve_profile(profile, federee, region, tenant_name) + # 4. Decode JWT to extract user_id (sub claim) — needed for OpenBao path + # 5. OpenBao OIDC login + read secret + # 6. Extract kubeconfig → save to ~/.ewccli/kubeconfigs/.yaml + # Extract app credential id + secret + bao_creds = _fetch_openbao_credentials( + access_token=kc_result.access_token, + profile_name=resolved_profile, + ) + application_credential_id = bao_creds.get("application_credential_id") or "" + application_credential_secret = bao_creds.get("application_credential_secret") or "" + kubeconfig_path = bao_creds.get("kubeconfig_path") - profiles_file_path = ewc_hub_config.EWC_CLI_PROFILES_PATH - cfg = ConfigParser() - cfg.read(profiles_file_path) + # 7. Interactive prompts for federee/region/tenant_name (new profiles only) + if not profile_already_exists: + if not federee: + federee = select_federee() + if not federee: + console.print("No federee selection made. Exiting.") + return - if not os.path.exists(profiles_file_path) or not cfg.sections(): - pass - else: - if resolved_profile in cfg: - click.secho( - f"❌ Profile '{resolved_profile}' already exists in {ewc_hub_config.EWC_CLI_PROFILES_PATH}", - fg="red", - bold=True, - ) - click.secho( - "Use a different profile name or delete the existing profile first.", - fg="yellow", + console.print(f"Considering federee: {federee}") + + if not region: + region = select_region(federee=federee) + if not region: + console.print("No region selection made. Exiting.") + return + + allowed_regions = ewc_hub_config.allowed_regions(federee) + if region not in allowed_regions: + raise click.BadParameter( + f"Region '{region}' is not valid for federee '{federee}'. " + f"Allowed: {', '.join(allowed_regions)}" ) - raise click.Abort() + if not tenant_name: + tenant_name = click.prompt("Tenant name") + + # 8. SSH keys (unchanged) ssh_private_key_path_to_save, ssh_public_key_path_to_save = check_and_generate_ssh_keys( ssh_public_key_path=ssh_public_key_path, ssh_private_key_path=ssh_private_key_path, resolved_profile=resolved_profile, ) - - - if not keycloak or not application_credential_id: - if openstack_config_available(): - console.print( - "🔑 [bold green]Openstack cloud.yaml found at ~/.config/openstack/clouds.yaml[/bold green]" - " – skipping Openstack ID and secret requirements." - ) - application_credential_id = "" - application_credential_secret = "" - - elif not application_credential_id or not application_credential_secret: - if not application_credential_id: - # Handle OpenStack credential ID - application_credential_id = ( - application_credential_id - or os.getenv("OS_APPLICATION_CREDENTIAL_ID") - or click.prompt( - "Enter OpenStack Application Credential ID", hide_input=True - ) - ) - - if not application_credential_secret: - # Handle OpenStack credential secret - application_credential_secret = ( - application_credential_secret - or os.getenv("OS_APPLICATION_CREDENTIAL_SECRET") - or click.prompt( - "Enter OpenStack Application Credential Secret", hide_input=True - ) - ) - - # if kubeconfig_available(): - # click.echo("🔑 kubeconfig found – skipping token requirement.") - # token = None - # elif not token: - # token = click.prompt( - # "Enter Kubernetes token (leave blank if not needed)", - # hide_input=True, - # default="", - # show_default=False, - # prompt_suffix=": ", - # ) - # if token == "": - # token = None - - # - save_default_login_profile( - federee=federee, - region=region, - tenant_name=tenant_name, - ssh_private_key_path_to_save=ssh_private_key_path_to_save, - ssh_public_key_path_to_save=ssh_public_key_path_to_save, - # token=token, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, - ) - # Save config - save_cli_profile( - federee=federee, - region=region, - tenant_name=tenant_name, - ssh_private_key_path_to_save=ssh_private_key_path_to_save, - ssh_public_key_path_to_save=ssh_public_key_path_to_save, - profile=profile, - # token=token, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, - ) + # 9. Save profile + if profile_already_exists: + update_cli_profile_credentials( + profile=resolved_profile, + application_credential_id=application_credential_id or None, + application_credential_secret=application_credential_secret or None, + kubeconfig_path=kubeconfig_path, + profiles_file_path=profiles_file_path, + ) + else: + save_cli_profile( + federee=federee, + region=region, + tenant_name=tenant_name, + ssh_private_key_path_to_save=ssh_private_key_path_to_save, + ssh_public_key_path_to_save=ssh_public_key_path_to_save, + profile=resolved_profile, + application_credential_id=application_credential_id, + application_credential_secret=application_credential_secret, + kubeconfig_path=kubeconfig_path, + profiles_file_path=profiles_file_path, + ) console.print( f"✅ Profile '[bold cyan]{resolved_profile}[/bold cyan]' saved " diff --git a/ewccli/configuration.py b/ewccli/configuration.py index 0d5fc1f..c5c3dc3 100644 --- a/ewccli/configuration.py +++ b/ewccli/configuration.py @@ -33,17 +33,29 @@ class EWCCLIConfiguration: # Keycloak / OIDC configuration EWC_CLI_KEYCLOAK_URL = os.getenv( - "EWC_CLI_KEYCLOAK_URL", "https://auth.europeanweather.cloud" + "EWC_CLI_KEYCLOAK_URL", "https://iam.europeanweather.cloud" ) - EWC_CLI_KEYCLOAK_REALM = os.getenv("EWC_CLI_KEYCLOAK_REALM", "ewc") + EWC_CLI_KEYCLOAK_REALM = os.getenv("EWC_CLI_KEYCLOAK_REALM", "ewc-login-broker") EWC_CLI_KEYCLOAK_CLIENT_ID = os.getenv("EWC_CLI_KEYCLOAK_CLIENT_ID", "ewccli") EWC_CLI_KEYCLOAK_SCOPE = os.getenv("EWC_CLI_KEYCLOAK_SCOPE", "openid profile email") - EWC_CLI_PORTAL_API_URL = os.getenv( - "EWC_CLI_PORTAL_API_URL", "" - ) EWC_CLI_OIDC_CALLBACK_TIMEOUT = int( os.getenv("EWC_CLI_OIDC_CALLBACK_TIMEOUT", "300") ) + EWC_CLI_OIDC_CALLBACK_PORT = int( + os.getenv("EWC_CLI_OIDC_CALLBACK_PORT", "11325") + ) + + # OpenBao (secrets) configuration + EWC_CLI_OPENBAO_URL = os.getenv( + "EWC_CLI_OPENBAO_URL", + "https://secrets-val.internal.eumetsat.europeanweather.cloud", + ) + EWC_CLI_OPENBAO_OIDC_ROLE = os.getenv("EWC_CLI_OPENBAO_OIDC_ROLE", "default") + EWC_CLI_OPENBAO_KV_MOUNT = os.getenv("EWC_CLI_OPENBAO_KV_MOUNT", "secret") + EWC_CLI_OPENBAO_NAMESPACE = os.getenv("EWC_CLI_OPENBAO_NAMESPACE", "openbao-users") + + # Per-profile kubeconfig directory + EWC_CLI_KUBECONFIG_PATH = EWC_CLI_BASE_PATH / "kubeconfigs" # EWC_CLI_HUB_ITEMS_PATH = files("ewccli.data").joinpath("items.yaml") EWC_CLI_HUB_ITEMS_PATH = EWC_CLI_BASE_PATH / "items.yaml" diff --git a/ewccli/ewccli.py b/ewccli/ewccli.py index 0bee63e..fac13a9 100644 --- a/ewccli/ewccli.py +++ b/ewccli/ewccli.py @@ -41,31 +41,23 @@ def cli(): @cli.command(name="login", help="Initialize configuration for EWC CLI.") @init_options def init( - application_credential_id: str, - application_credential_secret: str, ssh_public_key_path: str, ssh_private_key_path: str, tenant_name: str, federee: str, region: str, profile: Optional[str] = None, - keycloak: bool = False, no_browser: bool = False, - # token: str, ): """Login command.""" init_command( - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, ssh_public_key_path=ssh_public_key_path, ssh_private_key_path=ssh_private_key_path, tenant_name=tenant_name, federee=federee, profile=profile, region=region, - keycloak=keycloak, no_browser=no_browser, - # token=token, ) diff --git a/ewccli/tests/ewccli_config_test.py b/ewccli/tests/ewccli_config_test.py index 76262cf..1046adb 100644 --- a/ewccli/tests/ewccli_config_test.py +++ b/ewccli/tests/ewccli_config_test.py @@ -16,6 +16,9 @@ save_cli_profile, load_cli_profile, _resolve_profile, + profile_exists, + update_cli_profile_credentials, + CredentialExpiredError, ) @@ -41,10 +44,9 @@ def test_save_and_load_profile(profile_file_path, ssh_paths): federee = "EUMETSAT" region = "WAW3-1" tenant_name = "TeamA" - token = "tok1" app_id = "ID1" app_secret = "SECRET1" - region = "us-east-1" + kubeconfig_path = "/home/user/.ewccli/kubeconfigs/default.yaml" ssh_private, ssh_public = ssh_paths @@ -54,9 +56,9 @@ def test_save_and_load_profile(profile_file_path, ssh_paths): tenant_name=tenant_name, ssh_private_key_path_to_save=ssh_private, ssh_public_key_path_to_save=ssh_public, - token=token, application_credential_id=app_id, application_credential_secret=app_secret, + kubeconfig_path=kubeconfig_path, profiles_file_path=str(profile_file_path), ) @@ -70,12 +72,17 @@ def test_save_and_load_profile(profile_file_path, ssh_paths): assert data["profile"] == profile_name assert data["federee"] == federee assert data["tenant_name"] == tenant_name - assert data["token"] == token assert data["application_credential_id"] == app_id assert data["application_credential_secret"] == app_secret assert data["region"] == region assert data["ssh_private_key_path"] == ssh_private assert data["ssh_public_key_path"] == ssh_public + assert data["kubeconfig_path"] == kubeconfig_path + # No OIDC tokens are persisted + assert "access_token" not in data + assert "refresh_token" not in data + assert "id_token" not in data + assert "token_expires_at" not in data def test_save_existing_profile_fails(profile_file_path, ssh_paths): @@ -143,3 +150,69 @@ def test_overwrite_profile_not_allowed(profile_file_path, ssh_paths): ) +def test_profile_exists_true(profile_file_path, ssh_paths): + ssh_private, ssh_public = ssh_paths + save_cli_profile( + federee="EUMETSAT", + region="WAW3-1", + tenant_name="TeamA", + ssh_private_key_path_to_save=ssh_private, + ssh_public_key_path_to_save=ssh_public, + profiles_file_path=str(profile_file_path), + ) + profile_name = _resolve_profile(None, "EUMETSAT", "WAW3-1", "TeamA") + assert profile_exists(profile_name, str(profile_file_path)) is True + + +def test_profile_exists_false(profile_file_path): + assert profile_exists("nonexistent", str(profile_file_path)) is False + + +def test_update_cli_profile_credentials(profile_file_path, ssh_paths): + ssh_private, ssh_public = ssh_paths + save_cli_profile( + federee="EUMETSAT", + region="WAW3-1", + tenant_name="TeamA", + ssh_private_key_path_to_save=ssh_private, + ssh_public_key_path_to_save=ssh_public, + profiles_file_path=str(profile_file_path), + ) + profile_name = _resolve_profile(None, "EUMETSAT", "WAW3-1", "TeamA") + + update_cli_profile_credentials( + profile=profile_name, + application_credential_id="new-app-id", + application_credential_secret="new-app-secret", + kubeconfig_path="/home/user/.ewccli/kubeconfigs/default.yaml", + profiles_file_path=str(profile_file_path), + ) + + data = load_cli_profile( + profile=profile_name, + profiles_file_path=str(profile_file_path), + ) + assert data["application_credential_id"] == "new-app-id" + assert data["application_credential_secret"] == "new-app-secret" + assert data["kubeconfig_path"] == "/home/user/.ewccli/kubeconfigs/default.yaml" + # Original fields preserved + assert data["federee"] == "EUMETSAT" + assert data["tenant_name"] == "TeamA" + + +def test_update_cli_profile_credentials_missing_profile_raises(profile_file_path): + from click import ClickException + + with pytest.raises(ClickException): + update_cli_profile_credentials( + profile="nonexistent", + application_credential_id="new-app-id", + profiles_file_path=str(profile_file_path), + ) + + +def test_credential_expired_error_is_exception(): + """CredentialExpiredError should be a catchable Exception.""" + assert issubclass(CredentialExpiredError, Exception) + err = CredentialExpiredError("expired") + assert str(err) == "expired" diff --git a/ewccli/tests/ewccli_login_test.py b/ewccli/tests/ewccli_login_test.py index 4f577c9..e240515 100644 --- a/ewccli/tests/ewccli_login_test.py +++ b/ewccli/tests/ewccli_login_test.py @@ -9,11 +9,21 @@ """Tests for EWC login command.""" +import base64 +import json import pytest from pathlib import Path +from unittest.mock import patch, MagicMock from click import ClickException -from ewccli.commands.login_command import check_and_generate_ssh_keys +from ewccli.commands.login_command import check_and_generate_ssh_keys, init_command + + +def _make_jwt(sub: str = "user-123") -> str: + """Build a fake (unsigned) JWT with the given sub claim.""" + header = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).rstrip(b"=").decode() + payload = base64.urlsafe_b64encode(json.dumps({"sub": sub}).encode()).rstrip(b"=").decode() + return f"{header}.{payload}.sig" # ----------------------------- @@ -148,3 +158,195 @@ def test_only_public_key_exists(tmp_path): ssh_private_key_path=str(priv), resolved_profile="profile", ) + + +# ----------------------------- +# Login: profile exists -> skip prompts, refresh credentials via OpenBao +# ----------------------------- + +def test_login_existing_profile_skips_prompts(tmp_path, monkeypatch): + """When the profile already exists, federee/region/tenant_name prompts are skipped.""" + profiles_file = tmp_path / "profiles" + + # Pre-create a profile section + from configparser import ConfigParser + cfg = ConfigParser() + cfg["my-profile"] = { + "federee": "EUMETSAT", + "region": "WAW3-1", + "tenant_name": "my-tenant", + "ssh_public_key_path": str(tmp_path / "id_rsa.pub"), + "ssh_private_key_path": str(tmp_path / "id_rsa"), + } + profiles_file.write_text("") + with open(profiles_file, "w") as f: + cfg.write(f) + + # Write SSH key files so check_and_generate_ssh_keys finds them + priv = tmp_path / "id_rsa" + pub = tmp_path / "id_rsa.pub" + priv.write_text("private") + pub.write_text("public") + + monkeypatch.setattr( + "ewccli.commands.login_command.check_ssh_keys_match", + lambda ssh_private_key_path, ssh_public_key_path: True, + ) + monkeypatch.setattr( + "ewccli.commands.login_command.ewc_hub_config.EWC_CLI_PROFILES_PATH", + profiles_file, + ) + monkeypatch.setattr( + "ewccli.commands.login_command.ewc_hub_config.EWC_CLI_KUBECONFIG_PATH", + tmp_path / "kubeconfigs", + ) + + # Mock keycloak_login — only access_token now + access_token = _make_jwt("user-123") + mock_kc_result = MagicMock() + mock_kc_result.access_token = access_token + + # Mock OpenBao credential fetch + mock_bao_creds = { + "kubeconfig_path": str(tmp_path / "kubeconfigs" / "my-profile.yaml"), + "application_credential_id": "bao-app-id", + "application_credential_secret": "bao-app-secret", + } + + select_federee_called = False + select_region_called = False + + def fake_select_federee(): + nonlocal select_federee_called + select_federee_called = True + return "EUMETSAT" + + def fake_select_region(federee): + nonlocal select_region_called + select_region_called = True + return "WAW3-1" + + with patch( + "ewccli.backends.keycloak.keycloak_backend.keycloak_login", + return_value=mock_kc_result, + ), patch( + "ewccli.commands.login_command.select_federee", side_effect=fake_select_federee + ), patch( + "ewccli.commands.login_command.select_region", side_effect=fake_select_region + ), patch( + "ewccli.commands.login_command._fetch_openbao_credentials", + return_value=mock_bao_creds, + ): + init_command( + ssh_public_key_path=str(pub), + ssh_private_key_path=str(priv), + tenant_name="", + federee="", + region="", + profile="my-profile", + no_browser=True, + ) + + # Prompts should NOT have been called + assert not select_federee_called, "select_federee should not be called for existing profile" + assert not select_region_called, "select_region should not be called for existing profile" + + # Credentials should be updated in the profile (no OIDC tokens) + cfg2 = ConfigParser() + cfg2.read(profiles_file) + assert cfg2["my-profile"]["application_credential_id"] == "bao-app-id" + assert cfg2["my-profile"]["application_credential_secret"] == "bao-app-secret" + assert cfg2["my-profile"]["kubeconfig_path"] == str(tmp_path / "kubeconfigs" / "my-profile.yaml") + # No OIDC tokens are stored + assert "access_token" not in cfg2["my-profile"] + assert "refresh_token" not in cfg2["my-profile"] + + +# ----------------------------- +# Login: profile does not exist -> ask for federee/region/tenant_name +# ----------------------------- + +def test_login_new_profile_prompts_for_federee_region(tmp_path, monkeypatch): + """When the profile does not exist, interactive prompts are shown.""" + profiles_file = tmp_path / "profiles" + + priv = tmp_path / "id_rsa" + pub = tmp_path / "id_rsa.pub" + priv.write_text("private") + pub.write_text("public") + + monkeypatch.setattr( + "ewccli.commands.login_command.check_ssh_keys_match", + lambda ssh_private_key_path, ssh_public_key_path: True, + ) + monkeypatch.setattr( + "ewccli.commands.login_command.ewc_hub_config.EWC_CLI_PROFILES_PATH", + profiles_file, + ) + monkeypatch.setattr( + "ewccli.commands.login_command.ewc_hub_config.EWC_CLI_KUBECONFIG_PATH", + tmp_path / "kubeconfigs", + ) + + access_token = _make_jwt("user-456") + mock_kc_result = MagicMock() + mock_kc_result.access_token = access_token + + mock_bao_creds = { + "kubeconfig_path": str(tmp_path / "kubeconfigs" / "new-profile.yaml"), + "application_credential_id": "new-app-id", + "application_credential_secret": "new-app-secret", + } + + select_federee_called = False + select_region_called = False + + def fake_select_federee(): + nonlocal select_federee_called + select_federee_called = True + return "EUMETSAT" + + def fake_select_region(federee): + nonlocal select_region_called + select_region_called = True + return "WAW3-1" + + with patch( + "ewccli.backends.keycloak.keycloak_backend.keycloak_login", + return_value=mock_kc_result, + ), patch( + "ewccli.commands.login_command.select_federee", side_effect=fake_select_federee + ), patch( + "ewccli.commands.login_command.select_region", side_effect=fake_select_region + ), patch( + "ewccli.commands.login_command.click.prompt", return_value="my-tenant" + ), patch( + "ewccli.commands.login_command._fetch_openbao_credentials", + return_value=mock_bao_creds, + ): + init_command( + ssh_public_key_path=str(pub), + ssh_private_key_path=str(priv), + tenant_name="", + federee="", + region="", + profile="new-profile", + no_browser=True, + ) + + # Prompts SHOULD have been called + assert select_federee_called, "select_federee should be called for new profile" + assert select_region_called, "select_region should be called for new profile" + + # Profile should be created with credentials (no OIDC tokens) + from configparser import ConfigParser + cfg2 = ConfigParser() + cfg2.read(profiles_file) + assert "new-profile" in cfg2 + assert cfg2["new-profile"]["federee"] == "EUMETSAT" + assert cfg2["new-profile"]["application_credential_id"] == "new-app-id" + assert cfg2["new-profile"]["application_credential_secret"] == "new-app-secret" + assert cfg2["new-profile"]["kubeconfig_path"] == str(tmp_path / "kubeconfigs" / "new-profile.yaml") + # No OIDC tokens are stored + assert "access_token" not in cfg2["new-profile"] + assert "refresh_token" not in cfg2["new-profile"] diff --git a/ewccli/tests/test_keycloak_backend.py b/ewccli/tests/test_keycloak_backend.py index 9ce6b22..252057b 100644 --- a/ewccli/tests/test_keycloak_backend.py +++ b/ewccli/tests/test_keycloak_backend.py @@ -13,31 +13,17 @@ def mock_config(): config.EWC_CLI_KEYCLOAK_REALM = "ewc" config.EWC_CLI_KEYCLOAK_CLIENT_ID = "ewccli" config.EWC_CLI_KEYCLOAK_SCOPE = "openid profile" - config.EWC_CLI_PORTAL_API_URL = "https://portal.example.com" - config.EWC_CLI_OIDC_CALLBACK_TIMEOUT = 10 - return config - - -@pytest.fixture -def mock_config_no_portal(): - config = MagicMock() - config.EWC_CLI_KEYCLOAK_URL = "https://auth.example.com" - config.EWC_CLI_KEYCLOAK_REALM = "ewc" - config.EWC_CLI_KEYCLOAK_CLIENT_ID = "ewccli" - config.EWC_CLI_KEYCLOAK_SCOPE = "openid profile" - config.EWC_CLI_PORTAL_API_URL = "" config.EWC_CLI_OIDC_CALLBACK_TIMEOUT = 10 + config.EWC_CLI_OIDC_CALLBACK_PORT = 0 return config @patch("ewccli.backends.keycloak.keycloak_backend.webbrowser") -@patch("ewccli.backends.keycloak.keycloak_backend.PortalClient") @patch("ewccli.backends.keycloak.keycloak_backend.OIDCClient") @patch("ewccli.backends.keycloak.keycloak_backend.CallbackServer") def test_keycloak_login_success( mock_cb_server_cls, mock_oidc_cls, - mock_portal_cls, mock_webbrowser, mock_config, ): @@ -60,33 +46,15 @@ def test_keycloak_login_success( } mock_oidc_cls.return_value = mock_oidc - # Portal client - mock_portal = MagicMock() - mock_creds = MagicMock() - mock_creds.application_credential_id = "app-id" - mock_creds.application_credential_secret = "app-secret" - mock_creds.auth_url = "https://keystone.example.com" - mock_creds.federee = "EUMETSAT" - mock_creds.region = "ECIS-R1" - mock_creds.tenant_name = "tenant" - mock_portal.fetch_openstack_credentials.return_value = mock_creds - mock_portal_cls.return_value = mock_portal - result = keycloak_login( config=mock_config, open_browser=True, federee="EUMETSAT", - region="ECIS-R1", ) assert isinstance(result, KeycloakLoginResult) - assert result.application_credential_id == "app-id" - assert result.application_credential_secret == "app-secret" - assert result.auth_url == "https://keystone.example.com" + # Only the access token is returned (ephemeral, not stored) assert result.access_token == "access123" - assert result.refresh_token == "refresh456" - assert result.federee == "EUMETSAT" - assert result.region == "ECIS-R1" # Browser was opened via webbrowser.open mock_webbrowser.open.assert_called_once() @@ -138,13 +106,11 @@ def test_keycloak_login_state_mismatch( ) -@patch("ewccli.backends.keycloak.keycloak_backend.PortalClient") @patch("ewccli.backends.keycloak.keycloak_backend.OIDCClient") @patch("ewccli.backends.keycloak.keycloak_backend.CallbackServer") def test_keycloak_login_token_exchange_failure( mock_cb_server_cls, mock_oidc_cls, - mock_portal_cls, mock_config, ): mock_server = MagicMock() @@ -162,79 +128,26 @@ def test_keycloak_login_token_exchange_failure( keycloak_login(config=mock_config, open_browser=False) -@patch("ewccli.backends.keycloak.keycloak_backend.PortalClient") -@patch("ewccli.backends.keycloak.keycloak_backend.OIDCClient") +@patch("ewccli.backends.keycloak.keycloak_backend.webbrowser") @patch("ewccli.backends.keycloak.keycloak_backend.CallbackServer") -def test_keycloak_login_portal_failure( +def test_keycloak_login_keyboard_interrupt( mock_cb_server_cls, - mock_oidc_cls, - mock_portal_cls, + mock_webbrowser, mock_config, ): + """Ctrl+C during callback wait should stop the server and raise ClickException.""" mock_server = MagicMock() mock_server.port = 12345 mock_server.redirect_uri = "http://127.0.0.1:12345/callback" - mock_server.wait_for_callback.return_value = ("code", "state") + mock_server.wait_for_callback.side_effect = KeyboardInterrupt() mock_server.error = None mock_cb_server_cls.return_value = mock_server - mock_oidc = MagicMock() - mock_oidc.exchange_code_for_tokens.return_value = { - "access_token": "token", - "refresh_token": "refresh", - "expires_in": 300, - } - mock_oidc_cls.return_value = mock_oidc - - mock_portal = MagicMock() - mock_portal.fetch_openstack_credentials.side_effect = Exception("403 Forbidden") - mock_portal_cls.return_value = mock_portal - - with pytest.raises(ClickException, match="Failed to fetch OpenStack credentials"): - keycloak_login(config=mock_config, open_browser=False) - - -@patch("ewccli.backends.keycloak.keycloak_backend.PortalClient") -@patch("ewccli.backends.keycloak.keycloak_backend.OIDCClient") -@patch("ewccli.backends.keycloak.keycloak_backend.CallbackServer") -def test_keycloak_login_no_portal_returns_empty_creds( - mock_cb_server_cls, - mock_oidc_cls, - mock_portal_cls, - mock_config_no_portal, -): - """When portal is not configured, return empty app creds but keep OIDC tokens.""" - mock_server = MagicMock() - mock_server.port = 12345 - mock_server.redirect_uri = "http://127.0.0.1:12345/callback" - mock_server.wait_for_callback.return_value = ("code", "state") - mock_server.error = None - mock_cb_server_cls.return_value = mock_server - - mock_oidc = MagicMock() - mock_oidc.exchange_code_for_tokens.return_value = { - "access_token": "access123", - "refresh_token": "refresh456", - "id_token": "id789", - "expires_in": 300, - } - mock_oidc_cls.return_value = mock_oidc - - result = keycloak_login( - config=mock_config_no_portal, - open_browser=False, - federee="EUMETSAT", - region="ECIS-R1", - ) + with pytest.raises(ClickException, match="Authentication cancelled by user"): + keycloak_login( + config=mock_config, + open_browser=False, + ) - assert isinstance(result, KeycloakLoginResult) - # App creds are empty — fall through to existing credential path - assert result.application_credential_id == "" - assert result.application_credential_secret == "" - assert result.auth_url == "" - # OIDC tokens are still stored for refresh - assert result.access_token == "access123" - assert result.refresh_token == "refresh456" - assert result.id_token == "id789" - # Portal client was never called - mock_portal_cls.assert_not_called() + # Server must be stopped even on KeyboardInterrupt + mock_server.stop.assert_called_once() diff --git a/ewccli/tests/test_keycloak_callback_server.py b/ewccli/tests/test_keycloak_callback_server.py index 46d202e..6acaf25 100644 --- a/ewccli/tests/test_keycloak_callback_server.py +++ b/ewccli/tests/test_keycloak_callback_server.py @@ -2,6 +2,9 @@ import urllib.request import urllib.error +import pytest +from unittest.mock import patch + from ewccli.backends.keycloak.callback_server import CallbackServer @@ -60,3 +63,33 @@ def test_callback_server_redirect_uri(): server.start() assert server.redirect_uri == f"http://127.0.0.1:{server.port}/callback" server.stop() + + +def test_callback_server_interruptible_by_keyboard_interrupt(): + """Ctrl+C (KeyboardInterrupt) should interrupt wait_for_callback.""" + server = CallbackServer(expected_state="mystate") + server.start() + + # Patch time.sleep inside the callback_server module to raise KeyboardInterrupt + with patch( + "ewccli.backends.keycloak.callback_server.time.sleep", + side_effect=KeyboardInterrupt(), + ): + with pytest.raises(KeyboardInterrupt): + server.wait_for_callback(timeout=10) + + server.stop() + + +def test_callback_server_wait_returns_after_result_set(): + """wait_for_callback should return immediately when result is already set.""" + server = CallbackServer(expected_state="mystate") + server.start() + + # Simulate a callback that already arrived + server._result = ("mycode", "mystate") + + result = server.wait_for_callback(timeout=5) + server.stop() + + assert result == ("mycode", "mystate") diff --git a/ewccli/tests/test_keycloak_portal_client.py b/ewccli/tests/test_keycloak_portal_client.py deleted file mode 100644 index 1319ca0..0000000 --- a/ewccli/tests/test_keycloak_portal_client.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Tests for the portal API client.""" -import pytest -from unittest.mock import patch, MagicMock - -from ewccli.backends.keycloak.portal_client import PortalClient, PortalCredentials - - -@pytest.fixture -def portal_client(): - return PortalClient( - portal_api_url="https://europeanweather.cloud", - ) - - -def test_credentials_endpoint(portal_client): - assert portal_client.credentials_endpoint == ( - "https://europeanweather.cloud/api/v1/credentials/openstack" - ) - - -def test_credentials_endpoint_strips_trailing_slash(): - client = PortalClient(portal_api_url="https://example.com/") - assert client.credentials_endpoint == "https://example.com/api/v1/credentials/openstack" - - -@patch("ewccli.backends.keycloak.portal_client.requests.post") -def test_fetch_openstack_credentials(mock_post, portal_client): - mock_response = MagicMock() - mock_response.json.return_value = { - "application_credential_id": "app-id-123", - "application_credential_secret": "app-secret-456", - "auth_url": "https://keystone.api.r1.cloud.eumetsat.int", - "federee": "EUMETSAT", - "region": "ECIS-R1", - "tenant_name": "my-tenant", - } - mock_response.raise_for_status = MagicMock() - mock_post.return_value = mock_response - - creds = portal_client.fetch_openstack_credentials( - access_token="oidc-token-789", - ) - - assert isinstance(creds, PortalCredentials) - assert creds.application_credential_id == "app-id-123" - assert creds.application_credential_secret == "app-secret-456" - assert creds.auth_url == "https://keystone.api.r1.cloud.eumetsat.int" - assert creds.federee == "EUMETSAT" - assert creds.region == "ECIS-R1" - assert creds.tenant_name == "my-tenant" - - call_args = mock_post.call_args - assert "Bearer oidc-token-789" in call_args[1]["headers"]["Authorization"] - # No body when federee/region not provided - assert call_args[1]["json"] is None - - -@patch("ewccli.backends.keycloak.portal_client.requests.post") -def test_fetch_openstack_credentials_with_federee_region(mock_post, portal_client): - mock_response = MagicMock() - mock_response.json.return_value = { - "application_credential_id": "id", - "application_credential_secret": "secret", - "auth_url": "https://keystone.example.com", - "federee": "ECMWF", - "region": "CC1", - "tenant_name": "tenant", - } - mock_response.raise_for_status = MagicMock() - mock_post.return_value = mock_response - - portal_client.fetch_openstack_credentials( - access_token="token", - federee="ECMWF", - region="CC1", - ) - - call_args = mock_post.call_args - assert call_args[1]["json"]["federee"] == "ECMWF" - assert call_args[1]["json"]["region"] == "CC1" - - -@patch("ewccli.backends.keycloak.portal_client.requests.post") -def test_fetch_openstack_credentials_http_error(mock_post, portal_client): - import requests as req - - mock_response = MagicMock() - mock_response.raise_for_status.side_effect = req.exceptions.HTTPError("403 Forbidden") - mock_post.return_value = mock_response - - with pytest.raises(req.exceptions.HTTPError): - portal_client.fetch_openstack_credentials(access_token="bad-token") - - -@patch("ewccli.backends.keycloak.portal_client.requests.post") -def test_fetch_openstack_credentials_missing_fields(mock_post, portal_client): - """Portal returns only required fields, optionals are None.""" - mock_response = MagicMock() - mock_response.json.return_value = { - "application_credential_id": "id", - "application_credential_secret": "secret", - "auth_url": "https://keystone.example.com", - } - mock_response.raise_for_status = MagicMock() - mock_post.return_value = mock_response - - creds = portal_client.fetch_openstack_credentials(access_token="token") - - assert creds.application_credential_id == "id" - assert creds.application_credential_secret == "secret" - assert creds.auth_url == "https://keystone.example.com" - assert creds.federee is None - assert creds.region is None - assert creds.tenant_name is None diff --git a/ewccli/utils.py b/ewccli/utils.py index f4990bd..ba21ed7 100644 --- a/ewccli/utils.py +++ b/ewccli/utils.py @@ -58,49 +58,6 @@ def _resolve_profile( return f"{federee.lower()}-{region.lower()}-{tenant_name.lower()}" -def save_default_login_profile( - federee: str, - region: str, - tenant_name: str, - ssh_private_key_path_to_save: str, - ssh_public_key_path_to_save: str, - application_credential_id: Optional[str] = None, - application_credential_secret: Optional[str] = None, - token: Optional[str] = None, - profiles_file_path: Path = ewc_hub_config.EWC_CLI_PROFILES_PATH, -) -> None: - """ - Save the default login profile to EWC_CLI_PROFILES_PATH only if it does not exist. - If it already exists, do nothing (skip). - - Uses ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME as the profile name. - """ - resolved_profile = _resolve_profile( - profile=ewc_hub_config.EWC_CLI_DEFAULT_PROFILE_NAME, - ) - - cfg = ConfigParser() - cfg.read(profiles_file_path) - - # Skip saving if the default profile already exists - if resolved_profile in cfg: - return - - # Save profile (reusing the unified save_cli_profile logic) - - save_cli_profile( - federee=federee, - region=region, - tenant_name=tenant_name, - ssh_private_key_path_to_save=ssh_private_key_path_to_save, - ssh_public_key_path_to_save=ssh_public_key_path_to_save, - profile=resolved_profile, - token=token, - application_credential_id=application_credential_id, - application_credential_secret=application_credential_secret, - ) - - def save_cli_profile( federee: str, region: str, @@ -108,14 +65,18 @@ def save_cli_profile( ssh_private_key_path_to_save: str, ssh_public_key_path_to_save: str, profile: Optional[str] = None, - token: Optional[str] = None, application_credential_id: Optional[str] = None, application_credential_secret: Optional[str] = None, + kubeconfig_path: Optional[str] = None, profiles_file_path: Path = ewc_hub_config.EWC_CLI_PROFILES_PATH, ) -> None: """ Save all profile data (config + credentials) into a single profiles file. + No Keycloak/OIDC tokens are persisted. The profile stores only the + downstream credentials (app creds, kubeconfig path, federee, region, + tenant_name, SSH keys). + Parameters ---------- federee : str @@ -130,12 +91,12 @@ def save_cli_profile( SSH public key path profile : str, optional Explicit profile name. If None, auto-generated using federee-tenant. - token : str, optional - Authentication token. application_credential_id : str, optional Application credential ID. application_credential_secret : str, optional Application credential secret. + kubeconfig_path : str, optional + Path to the per-profile kubeconfig file. """ resolved_profile = _resolve_profile(profile, federee, region, tenant_name) cfg = ConfigParser() @@ -164,10 +125,7 @@ def save_cli_profile( cfg[resolved_profile]["ssh_public_key_path"] = ssh_public_key_path_to_save cfg[resolved_profile]["ssh_private_key_path"] = ssh_private_key_path_to_save - # Sensitive - if token: - cfg[resolved_profile]["token"] = token - + # Downstream credentials (no OIDC tokens are stored) if application_credential_id: cfg[resolved_profile]["application_credential_id"] = application_credential_id @@ -176,6 +134,9 @@ def save_cli_profile( "application_credential_secret" ] = application_credential_secret + if kubeconfig_path: + cfg[resolved_profile]["kubeconfig_path"] = kubeconfig_path + os.makedirs(os.path.dirname(profiles_file_path), exist_ok=True) with open(profiles_file_path, "w") as f: cfg.write(f) @@ -220,9 +181,9 @@ def load_cli_profile( "tenant_name": "internal-ewc-admins", "ssh_public_key_path": "/tmp/id_rsa.pub", "ssh_private_key_path": "/tmp/id_rsa", - "token": None, "application_credential_id": "", "application_credential_secret": "", + "kubeconfig_path": "", } if profile is None: @@ -366,12 +327,66 @@ def load_cli_profile( "tenant_name": section.get("tenant_name"), "ssh_public_key_path": ssh_public_key_path, "ssh_private_key_path": ssh_private_key_path, - "token": section.get("token"), "application_credential_id": section.get("application_credential_id"), "application_credential_secret": section.get("application_credential_secret"), + "kubeconfig_path": section.get("kubeconfig_path"), } +def profile_exists( + profile: str, + profiles_file_path: Path = ewc_hub_config.EWC_CLI_PROFILES_PATH, +) -> bool: + """Check whether a profile section already exists in the profiles file.""" + cfg = ConfigParser() + cfg.read(profiles_file_path) + return profile in cfg + + +class CredentialExpiredError(Exception): + """Raised when downstream credentials (OpenStack/k8s) have expired. + + Typically triggered by a 401/403 from the cloud API. Commands catch + this to tell the user to re-run ``ewc login``. + """ + + +def update_cli_profile_credentials( + profile: str, + application_credential_id: Optional[str] = None, + application_credential_secret: Optional[str] = None, + kubeconfig_path: Optional[str] = None, + profiles_file_path: Path = ewc_hub_config.EWC_CLI_PROFILES_PATH, +) -> None: + """Update downstream credential fields in an existing profile section. + + Unlike ``save_cli_profile``, this does NOT fail if the section already + exists — it updates it in place. Used by the login command (re-login to + refresh credentials fetched from OpenBao). + + No OIDC tokens are touched (none are persisted). + """ + cfg = ConfigParser() + cfg.read(profiles_file_path) + + if profile not in cfg: + raise ClickException( + f"Profile '{profile}' not found in {profiles_file_path}. " + "Please run 'ewc login' first." + ) + + if application_credential_id is not None: + cfg[profile]["application_credential_id"] = application_credential_id + if application_credential_secret is not None: + cfg[profile]["application_credential_secret"] = application_credential_secret + if kubeconfig_path is not None: + cfg[profile]["kubeconfig_path"] = kubeconfig_path + + os.makedirs(os.path.dirname(profiles_file_path), exist_ok=True) + with open(profiles_file_path, "w") as f: + cfg.write(f) + + def generate_random_id(length: int = 10): """Generate random ID.""" characters = string.ascii_letters + string.digits From 869f7ad668ad0dd488bbecc2b5ab25cab837c154 Mon Sep 17 00:00:00 2001 From: Kamil Rajtar Date: Thu, 25 Jun 2026 11:32:22 +0200 Subject: [PATCH 11/12] feat(ewc-login): Remove gating, use KeyCloak to get credentials from openbao p.2 --- ewccli/backends/openbao/openbao_client.py | 150 ++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 ewccli/backends/openbao/openbao_client.py diff --git a/ewccli/backends/openbao/openbao_client.py b/ewccli/backends/openbao/openbao_client.py new file mode 100644 index 0000000..744e18d --- /dev/null +++ b/ewccli/backends/openbao/openbao_client.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python +# +# Package Name: ewccli +# License: GPL-3.0-or-later +# Copyright (c) 2025 EUMETSAT, ECMWF for European Weather Cloud +# See the LICENSE file for more details + +"""OpenBao client — OIDC login and KV2 secret reads.""" + +from typing import Optional + +import requests + +from ewccli.logger import get_logger + +_LOGGER = get_logger(__name__) + + +class OpenBaoError(Exception): + """Raised when an OpenBao API call fails. + + Carries the HTTP status code (when available) and the response body + so callers can produce clear error messages. + """ + + def __init__(self, message: str, status_code: Optional[int] = None, body: str = ""): + super().__init__(message) + self.status_code = status_code + self.body = body + + +class OpenBaoClient: + """Client for OpenBao OIDC auth and KV2 secret reads. + + The Keycloak access token is exchanged for an ephemeral OpenBao client + token via the OIDC auth method. That client token is then used to read + KV2 secrets. Neither token is persisted by this client. + """ + + def __init__( + self, + url: str, + namespace: str, + role: str, + kv_mount: str, + access_token: str, + ): + self._url = url.rstrip("/") + self._namespace = namespace + self._role = role + self._kv_mount = kv_mount + self._access_token = access_token + self._client_token: Optional[str] = None + + def _namespace_headers(self) -> dict: + headers = {} + if self._namespace: + headers["X-Vault-Namespace"] = self._namespace + return headers + + def login(self) -> str: + """Authenticate to OpenBao using the OIDC (JWT) auth method. + + POSTs the Keycloak access token to + ``/v1/auth/oidc/login/{role}`` and returns ``auth.client_token``. + + Returns: + The OpenBao client token. + + Raises: + OpenBaoError: On HTTP errors or malformed responses. + """ + endpoint = f"{self._url}/v1/auth/oidc/login/{self._role}" + headers = self._namespace_headers() + headers["Content-Type"] = "application/json" + body = {"jwt": self._access_token} + + _LOGGER.debug("OpenBao OIDC login to %s", endpoint) + + try: + response = requests.post(endpoint, headers=headers, json=body, timeout=30) + except requests.RequestException as e: + raise OpenBaoError(f"OpenBao login request failed: {e}") from e + + if response.status_code != 200: + raise OpenBaoError( + f"OpenBao OIDC login failed with status {response.status_code}", + status_code=response.status_code, + body=response.text, + ) + + try: + data = response.json() + except ValueError as e: + raise OpenBaoError(f"OpenBao login returned invalid JSON: {e}") from e + + client_token = (data.get("auth") or {}).get("client_token") + if not client_token: + raise OpenBaoError("OpenBao login response missing auth.client_token") + + self._client_token = client_token + return client_token + + def read_secret(self, path: str) -> dict: + """Read a KV2 secret at ``secret/data/{path}``. + + Requires :meth:`login` to have been called (or a client token + to have been obtained otherwise). + + Returns: + The inner ``data.data`` dict of the KV2 secret. + + Raises: + OpenBaoError: On HTTP errors or malformed responses. + """ + if not self._client_token: + raise OpenBaoError( + "No OpenBao client token available. Call login() first." + ) + + endpoint = f"{self._url}/v1/{self._kv_mount}/data/{path}" + headers = self._namespace_headers() + headers["Authorization"] = f"Bearer {self._client_token}" + + _LOGGER.debug("OpenBao KV2 read from %s", endpoint) + + try: + response = requests.get(endpoint, headers=headers, timeout=30) + except requests.RequestException as e: + raise OpenBaoError(f"OpenBao secret read request failed: {e}") from e + + if response.status_code not in (200, 204): + raise OpenBaoError( + f"OpenBao secret read failed with status {response.status_code}", + status_code=response.status_code, + body=response.text, + ) + + try: + data = response.json() + except ValueError as e: + raise OpenBaoError( + f"OpenBao secret read returned invalid JSON: {e}" + ) from e + + inner = (data.get("data") or {}).get("data") + if inner is None: + raise OpenBaoError("OpenBao secret response missing data.data") + + return inner From 770bafb88b923d3e151e52b0814c84d1dd01ee44 Mon Sep 17 00:00:00 2001 From: Kamil Rajtar Date: Thu, 25 Jun 2026 11:32:32 +0200 Subject: [PATCH 12/12] feat(ewc-login): Remove gating, use KeyCloak to get credentials from openbao p.2.5 --- ewccli/tests/test_openbao_client.py | 151 ++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 ewccli/tests/test_openbao_client.py diff --git a/ewccli/tests/test_openbao_client.py b/ewccli/tests/test_openbao_client.py new file mode 100644 index 0000000..1d1fd89 --- /dev/null +++ b/ewccli/tests/test_openbao_client.py @@ -0,0 +1,151 @@ +"""Tests for the OpenBao client (OIDC login + KV2 secret reads).""" + +import pytest +from unittest.mock import patch, MagicMock + +from ewccli.backends.openbao.openbao_client import OpenBaoClient, OpenBaoError + + +@pytest.fixture +def client(): + return OpenBaoClient( + url="https://secrets.example.com", + namespace="openbao-users", + role="default", + kv_mount="secret", + access_token="kc-access-token", + ) + + +@patch("ewccli.backends.openbao.openbao_client.requests.post") +def test_login_success_returns_client_token(mock_post, client): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "auth": {"client_token": "bao-token-123"}, + } + mock_post.return_value = mock_response + + token = client.login() + + assert token == "bao-token-123" + # Verify the request was made to the OIDC login endpoint + call_args = mock_post.call_args + assert call_args[0][0] == "https://secrets.example.com/v1/auth/oidc/login/default" + assert call_args[1]["headers"]["X-Vault-Namespace"] == "openbao-users" + assert call_args[1]["json"] == {"jwt": "kc-access-token"} + + +@patch("ewccli.backends.openbao.openbao_client.requests.post") +def test_login_failure_401_raises_openbao_error(mock_post, client): + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.text = "unauthorized" + mock_post.return_value = mock_response + + with pytest.raises(OpenBaoError) as exc_info: + client.login() + + assert exc_info.value.status_code == 401 + assert "401" in str(exc_info.value) + + +@patch("ewccli.backends.openbao.openbao_client.requests.post") +def test_login_missing_client_token_raises(mock_post, client): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"auth": {}} + mock_post.return_value = mock_response + + with pytest.raises(OpenBaoError, match="client_token"): + client.login() + + +@patch("ewccli.backends.openbao.openbao_client.requests.get") +def test_read_secret_success_returns_data_dict(mock_get, client): + # First login so the client has a token + client._client_token = "bao-token-123" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": { + "data": { + "kubeconfig": "apiVersion: v1", + "application_credential_id": "app-id", + "application_credential_secret": "app-secret", + } + } + } + mock_get.return_value = mock_response + + data = client.read_secret("user-123") + + assert data["application_credential_id"] == "app-id" + assert data["application_credential_secret"] == "app-secret" + assert data["kubeconfig"] == "apiVersion: v1" + + call_args = mock_get.call_args + assert call_args[0][0] == "https://secrets.example.com/v1/secret/data/user-123" + assert call_args[1]["headers"]["X-Vault-Namespace"] == "openbao-users" + assert call_args[1]["headers"]["Authorization"] == "Bearer bao-token-123" + + +@patch("ewccli.backends.openbao.openbao_client.requests.get") +def test_read_secret_failure_403_raises_openbao_error(mock_get, client): + client._client_token = "bao-token-123" + + mock_response = MagicMock() + mock_response.status_code = 403 + mock_response.text = "forbidden" + mock_get.return_value = mock_response + + with pytest.raises(OpenBaoError) as exc_info: + client.read_secret("user-123") + + assert exc_info.value.status_code == 403 + + +@patch("ewccli.backends.openbao.openbao_client.requests.get") +def test_read_secret_failure_404_raises_openbao_error(mock_get, client): + client._client_token = "bao-token-123" + + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.text = "not found" + mock_get.return_value = mock_response + + with pytest.raises(OpenBaoError) as exc_info: + client.read_secret("missing-user") + + assert exc_info.value.status_code == 404 + + +def test_read_secret_without_login_raises(client): + with pytest.raises(OpenBaoError, match="login"): + client.read_secret("user-123") + + +@patch("ewccli.backends.openbao.openbao_client.requests.post") +def test_login_namespace_header_is_sent(mock_post, client): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"auth": {"client_token": "t"}} + mock_post.return_value = mock_response + + client.login() + + call_args = mock_post.call_args + assert call_args[1]["headers"]["X-Vault-Namespace"] == "openbao-users" + + +def test_openbao_client_without_namespace_omits_header(): + client = OpenBaoClient( + url="https://secrets.example.com", + namespace="", + role="default", + kv_mount="secret", + access_token="tok", + ) + headers = client._namespace_headers() + assert "X-Vault-Namespace" not in headers