From a5c283f293185db25713f98a4ced682994772044 Mon Sep 17 00:00:00 2001 From: Werner Dijkerman Date: Sat, 30 May 2026 21:28:40 +0200 Subject: [PATCH 1/3] Usage of multiple backends --- charts/promanomaly/templates/configmap.yaml | 42 +- charts/promanomaly/values.schema.json | 41 +- charts/promanomaly/values.yaml | 30 +- detector/pyproject.toml | 20 + detector/src/promanomaly/cli/__init__.py | 64 +- detector/src/promanomaly/config.py | 212 +++- detector/src/promanomaly/httpauth.py | 234 ++++- detector/src/promanomaly/main.py | 28 +- detector/src/promanomaly/migrate.py | 152 +++ detector/src/promanomaly/runner.py | 63 +- detector/src/promanomaly/source.py | 19 +- .../src/promanomaly/stratified_fetcher.py | 10 +- detector/tests/test_v16_enterprise.py | 946 ++++++++++++++++++ detector/uv.lock | 259 ++++- docs/cli.md | 20 +- docs/config-schema.md | 10 + docs/operations.md | 145 +++ 17 files changed, 2267 insertions(+), 28 deletions(-) create mode 100644 detector/src/promanomaly/migrate.py create mode 100644 detector/tests/test_v16_enterprise.py diff --git a/charts/promanomaly/templates/configmap.yaml b/charts/promanomaly/templates/configmap.yaml index aff7253..8073dd9 100644 --- a/charts/promanomaly/templates/configmap.yaml +++ b/charts/promanomaly/templates/configmap.yaml @@ -25,7 +25,8 @@ data: /etc/promanomaly-datasource-auth and referenced by file, so they never land in this ConfigMap. Expected Secret keys per type: bearer -> token; basic -> password (+ auth.username here); - mtls -> ca.crt / tls.crt / tls.key. + mtls -> ca.crt / tls.crt / tls.key; + oauth2 -> client_secret; azure -> client_secret. */}} {{- if eq .Values.datasource.auth.type "bearer" }} token_file: /etc/promanomaly-datasource-auth/token @@ -38,6 +39,45 @@ data: key_file: /etc/promanomaly-datasource-auth/tls.key {{- end }} {{- end }} + {{- if eq .Values.datasource.auth.type "sigv4" }} + sigv4: + region: {{ .Values.datasource.auth.sigv4.region | quote }} + {{- with .Values.datasource.auth.sigv4.profile }} + profile: {{ . | quote }} + {{- end }} + {{- else if eq .Values.datasource.auth.type "gcp" }} + gcp: + {{- with .Values.datasource.auth.gcp.credentials_file }} + credentials_file: {{ . | quote }} + {{- end }} + {{- else if eq .Values.datasource.auth.type "azure" }} + azure: + {{- with .Values.datasource.auth.azure.client_id }} + client_id: {{ . | quote }} + {{- end }} + {{- with .Values.datasource.auth.azure.tenant_id }} + tenant_id: {{ . | quote }} + {{- end }} + {{- if .Values.datasource.auth.azure.existingSecret }} + client_secret_file: /etc/promanomaly-datasource-auth/client_secret + {{- end }} + {{- else if eq .Values.datasource.auth.type "oauth2" }} + oauth2: + token_url: {{ .Values.datasource.auth.oauth2.token_url | quote }} + client_id: {{ .Values.datasource.auth.oauth2.client_id | quote }} + {{- if .Values.datasource.auth.oauth2.existingSecret }} + client_secret_file: /etc/promanomaly-datasource-auth/client_secret + {{- end }} + {{- with .Values.datasource.auth.oauth2.scopes }} + scopes: +{{ toYaml . | indent 12 }} + {{- end }} + {{- end }} + {{- if .Values.datasource.tenant.id }} + tenant: + id: {{ .Values.datasource.tenant.id | quote }} + header: {{ .Values.datasource.tenant.header | quote }} + {{- end }} server: listen: {{ .Values.server.listen | quote }} refresh_interval: {{ .Values.server.refresh_interval | quote }} diff --git a/charts/promanomaly/values.schema.json b/charts/promanomaly/values.schema.json index 2f36c1f..e4bd76c 100644 --- a/charts/promanomaly/values.schema.json +++ b/charts/promanomaly/values.schema.json @@ -43,9 +43,46 @@ "auth": { "type": "object", "properties": { - "type": {"type": "string", "enum": ["none", "bearer", "basic", "mtls"]}, + "type": {"type": "string", "enum": ["none", "bearer", "basic", "mtls", "sigv4", "gcp", "azure", "oauth2"]}, "username": {"type": "string"}, - "existingSecret": {"type": "string"} + "existingSecret": {"type": "string"}, + "sigv4": { + "type": "object", + "properties": { + "region": {"type": "string"}, + "profile": {"type": "string"} + } + }, + "gcp": { + "type": "object", + "properties": { + "credentials_file": {"type": "string"} + } + }, + "azure": { + "type": "object", + "properties": { + "client_id": {"type": "string"}, + "tenant_id": {"type": "string"}, + "existingSecret": {"type": "string"} + } + }, + "oauth2": { + "type": "object", + "properties": { + "token_url": {"type": "string"}, + "client_id": {"type": "string"}, + "existingSecret": {"type": "string"}, + "scopes": {"type": "array", "items": {"type": "string"}} + } + } + } + }, + "tenant": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "header": {"type": "string"} } } } diff --git a/charts/promanomaly/values.yaml b/charts/promanomaly/values.yaml index a0e9cc5..38e1046 100644 --- a/charts/promanomaly/values.yaml +++ b/charts/promanomaly/values.yaml @@ -30,7 +30,7 @@ datasource: url: http://victoria-metrics-single-server:8428/ timeout: 10s auth: - type: none # none | bearer | basic | mtls + type: none # none | bearer | basic | mtls | sigv4 | gcp | azure | oauth2 username: "" # basic-auth username (not sensitive; the password # comes from existingSecret) # Secret carrying the credential, mounted as files and referenced by @@ -38,7 +38,35 @@ datasource: # bearer -> token # basic -> password (username above) # mtls -> ca.crt, tls.crt, tls.key + # oauth2 -> client_secret existingSecret: "" + # AWS SigV4 signing for Amazon Managed Service for Prometheus. + # Only consulted when type: sigv4. + sigv4: + region: "" + profile: "" # optional AWS CLI profile name + # Google Cloud auth for Google Managed Prometheus. + # Only consulted when type: gcp. + gcp: + credentials_file: "" # path to service-account JSON key; empty = ADC + # Azure auth for Azure Monitor managed Prometheus. + # Only consulted when type: azure. + azure: + client_id: "" + tenant_id: "" + existingSecret: "" # Secret with a `client_secret` key + # Generic OAuth2 client-credentials grant. + # Only consulted when type: oauth2. + oauth2: + token_url: "" + client_id: "" + existingSecret: "" # Secret with a `client_secret` key + scopes: [] + # Multi-tenant datasource support (Mimir / Cortex). Sets a tenant + # header on every PromQL request. Overridable per group. + tenant: + id: "" # tenant ID (e.g. "team-a"); empty = disabled + header: X-Scope-OrgID # Reload endpoint protection. The shipped NetworkPolicy denies ingress # to /-/reload and the debug endpoints by default; auth is defence in diff --git a/detector/pyproject.toml b/detector/pyproject.toml index 0a8da06..1a27d2f 100644 --- a/detector/pyproject.toml +++ b/detector/pyproject.toml @@ -78,6 +78,23 @@ seasonal = [ matrixprofile = [ "stumpy>=1.12.0", ] +# AWS SigV4 request signing for Amazon Managed Service for Prometheus. +# Uses the standard AWS credential chain. Install with +# ``pip install promanomaly[aws]`` when pointing at an AMP endpoint. +aws = [ + "botocore>=1.35.0", +] +# Google Cloud auth for Google Managed Prometheus / Cloud Monitoring. +# Uses Application Default Credentials or Workload Identity. Install +# with ``pip install promanomaly[gcp]``. +gcp = [ + "google-auth>=2.35.0", +] +# Azure auth for Azure Monitor managed Prometheus. Uses +# DefaultAzureCredential. Install with ``pip install promanomaly[azure]``. +azure = [ + "azure-identity>=1.19.0", +] dev = [ "pytest>=8.3.0", "pytest-asyncio>=0.24.0", @@ -168,6 +185,9 @@ module = [ "statsmodels.*", "stumpy.*", "scipy.*", + "botocore.*", + "google.*", + "azure.*", ] ignore_missing_imports = true diff --git a/detector/src/promanomaly/cli/__init__.py b/detector/src/promanomaly/cli/__init__.py index 983c606..7bfdcda 100644 --- a/detector/src/promanomaly/cli/__init__.py +++ b/detector/src/promanomaly/cli/__init__.py @@ -88,9 +88,9 @@ @click.option( "--config", "config_path", - type=click.Path(dir_okay=False), + type=click.Path(), default=None, - help="Path to the YAML configuration file.", + help="Path to the YAML configuration file or directory of fragments.", ) @click.option( "--log-level", @@ -116,7 +116,7 @@ def cli(ctx: click.Context, config_path: str | None, log_level: str) -> None: @click.option( "--config", "config_path", - type=click.Path(exists=True, dir_okay=False), + type=click.Path(exists=True), required=True, ) @click.option( @@ -284,7 +284,7 @@ def generate_rules(config_path: str, output_path: str | None, rule_name: str) -> @click.option( "--config", "config_path", - type=click.Path(exists=True, dir_okay=False), + type=click.Path(exists=True), required=True, ) def dry_run(config_path: str) -> None: @@ -296,6 +296,7 @@ async def _run() -> int: cfg.datasource.url, cfg.datasource.timeout_seconds, auth=cfg.datasource.auth, + tenant=cfg.datasource.tenant, ) store = SnapshotStore() ops = OperationalMetrics() @@ -316,6 +317,7 @@ async def _run() -> int: ) ) finally: + await runner.close_tenant_sources() await source.close() return 0 @@ -1160,6 +1162,60 @@ def diagnose_cmd( click.echo(render_report(report), nl=False) +@cli.command(name="migrate-config") +@click.option( + "--config", + "config_path", + type=click.Path(exists=True), + required=True, + help="Path to the YAML config file or directory to migrate.", +) +@click.option( + "--dry-run", + "dry_run", + is_flag=True, + default=False, + help="Print a unified diff of the changes without writing.", +) +def migrate_config(config_path: str, dry_run: bool) -> None: + """Upgrade a config across schema/apiVersion changes in place. + + Reads a config at any supported ``apiVersion``, applies the + registered forward-migrations, and writes the upgraded document. + Idempotent: running it on an already-current config is a no-op. + With ``--dry-run``, prints a unified diff instead of writing. + """ + from ..migrate import migrate_directory, migrate_file + + p = Path(config_path) + if p.is_dir(): + results = migrate_directory(p, dry_run=dry_run) + if not results: + click.echo("all configs are already at the current version") + return + for fpath, output, changes in results: + click.echo(f"--- {fpath} ---") + for change in changes: + click.echo(f" {change}") + if dry_run and output: + click.echo(output) + else: + try: + output, changes = migrate_file(p, dry_run=dry_run) + except (ValueError, OSError) as exc: + click.echo(f"error: {exc}", err=True) + sys.exit(1) + if not changes: + click.echo("config is already at the current version") + return + for change in changes: + click.echo(f" {change}") + if dry_run and output: + click.echo(output) + elif not dry_run: + click.echo(f"migrated {config_path}") + + # Register the backtest command imported from promanomaly.backtest so # operators can invoke it as ``promanomaly backtest`` rather than the # longer ``python -m promanomaly.backtest`` (which still works because diff --git a/detector/src/promanomaly/config.py b/detector/src/promanomaly/config.py index fce7956..7cf3cca 100644 --- a/detector/src/promanomaly/config.py +++ b/detector/src/promanomaly/config.py @@ -133,8 +133,71 @@ class _ModelBase(BaseModel): model_config = ConfigDict(extra="allow", str_strip_whitespace=True) +class OAuth2Config(_ModelBase): + """Generic OAuth2 client-credentials grant for token-based auth. + + Performs a ``client_credentials`` grant against ``token_url``, caches + the resulting access token, and transparently refreshes it before + expiry. ``client_secret`` should come from a Secret ref (mounted + file via ``client_secret_file``), never inlined in the ConfigMap. + """ + + token_url: str + client_id: str + client_secret: str | None = None + client_secret_file: str | None = None + scopes: list[str] = Field(default_factory=list) + + def resolved_client_secret(self) -> str | None: + """Client secret, read from ``client_secret_file`` when set, else inline.""" + if self.client_secret_file: + return _read_secret_file(self.client_secret_file) + return self.client_secret + + +class SigV4Config(_ModelBase): + """AWS SigV4 request signing for Amazon Managed Service for Prometheus. + + Uses the standard AWS credential chain (instance/pod role, env vars, + shared config). The ``region`` field is required because AMP endpoints + are regional. + """ + + region: str = Field(min_length=1) + # Optional explicit profile; omit to use the default credential chain. + profile: str | None = Field(default=None, min_length=1) + + +class GCPAuthConfig(_ModelBase): + """Google Cloud auth for Google Managed Prometheus / Cloud Monitoring. + + Mints and refreshes a Google access token from Application Default + Credentials or Workload Identity. No configuration beyond enabling + ``type: gcp`` is needed when running on GKE with Workload Identity. + """ + + # Optional path to a service-account JSON key file. When unset, + # Application Default Credentials are used (the recommended path on + # GKE with Workload Identity). + credentials_file: str | None = Field(default=None, min_length=1) + + +class AzureAuthConfig(_ModelBase): + """Azure auth for Azure Monitor managed Prometheus. + + Uses ``azure-identity`` ``DefaultAzureCredential`` by default, which + covers Managed Identity, Workload Identity, and az-cli fallback. + """ + + # Optional explicit client/tenant for service-principal auth. + # When unset, ``DefaultAzureCredential`` is used. + client_id: str | None = None + tenant_id: str | None = None + client_secret_file: str | None = None + + class AuthConfig(_ModelBase): - type: Literal["none", "bearer", "basic", "mtls"] = "none" + type: Literal["none", "bearer", "basic", "mtls", "sigv4", "gcp", "azure", "oauth2"] = "none" token: str | None = None username: str | None = None password: str | None = None @@ -149,6 +212,27 @@ class AuthConfig(_ModelBase): ca_file: str | None = None cert_file: str | None = None key_file: str | None = None + # Cloud-managed backend auth (v1.6). Each cloud SDK ships as an + # optional extra ([aws], [gcp], [azure]) so the base image stays + # slim and a missing SDK surfaces an actionable error rather than + # an ImportError from the source layer. + sigv4: SigV4Config | None = None + gcp: GCPAuthConfig | None = None + azure: AzureAuthConfig | None = None + oauth2: OAuth2Config | None = None + + @model_validator(mode="after") + def _validate_cloud_auth_block(self) -> AuthConfig: + """Require the matching nested block for cloud auth types.""" + block_map: dict[str, object | None] = { + "sigv4": self.sigv4, + "gcp": self.gcp, + "azure": self.azure, + "oauth2": self.oauth2, + } + if self.type in block_map and block_map[self.type] is None: + raise ValueError(f"auth.type={self.type!r} requires a matching auth.{self.type} block") + return self def resolved_token(self) -> str | None: """Bearer token, read from ``token_file`` when set, else inline.""" @@ -169,10 +253,25 @@ def _read_secret_file(path: str) -> str: return Path(path).read_text().strip() +class TenantConfig(_ModelBase): + """Multi-tenant datasource support for Mimir / Cortex backends. + + Injects a tenant header (``X-Scope-OrgID`` by default) on every + PromQL request for the group. The ``header`` name is configurable + for backends that use a non-standard header. + """ + + id: str = Field(min_length=1) + header: str = Field(default="X-Scope-OrgID", min_length=1) + + class DatasourceConfig(_ModelBase): url: str timeout: Duration = "10s" auth: AuthConfig = Field(default_factory=AuthConfig) + # Default tenant for multi-tenant Mimir / Cortex backends. + # Overridable per group via ``GroupConfig.tenant``. + tenant: TenantConfig | None = None @property def timeout_seconds(self) -> float: @@ -811,6 +910,12 @@ class GroupConfig(_ModelBase): auto_select: AutoSelect = False auto_select_interval: Duration = "24h" recurrence: RecurrenceConfig = Field(default_factory=RecurrenceConfig) + # Per-group tenant override. When set, this tenant header is injected + # on every PromQL request for this group instead of the datasource-level + # default. The tenant value is NOT emitted as an output label by default + # (it's a query-side concern, not a signal-side one), keeping the + # cross-tool ``(id, group)`` join contract intact. + tenant: TenantConfig | None = None @property def auto_select_interval_seconds(self) -> float: @@ -994,8 +1099,31 @@ def aggressive_refresh(self) -> bool: def load_config(path: str | Path) -> Config: - """Parse a YAML config file into a validated :class:`Config`.""" - raw = yaml.safe_load(Path(path).read_text()) + """Parse a YAML config file or directory into a validated :class:`Config`. + + When ``path`` is a directory, the loader reads a base file + (``_defaults.yaml`` if present) for ``datasource`` / ``server`` / + ``safety`` / ``defaults`` / ``exporter`` settings, then merges every + other ``*.yaml`` / ``*.yml`` fragment's ``groups:`` list into one + config in deterministic filename-sorted order. Duplicate group names + across fragments are a hard validation error. The merged result is + validated against the same pydantic schema as a single file. + """ + p = Path(path) + if p.is_dir(): + raw = _load_config_dir(p) + elif "*" in str(path) or "?" in str(path): + import glob as _glob + + matches = sorted(_glob.glob(str(path))) + if not matches: + raise ValueError(f"config glob {path!r}: no files matched") + if len(matches) == 1 and Path(matches[0]).is_file(): + raw = yaml.safe_load(Path(matches[0]).read_text()) + else: + raw = _load_config_from_files(matches) + else: + raw = yaml.safe_load(p.read_text()) if not isinstance(raw, dict): raise ValueError(f"config {path}: top level must be a mapping") cfg = Config.model_validate(raw) @@ -1004,6 +1132,79 @@ def load_config(path: str | Path) -> Config: return cfg +def _load_config_dir(directory: Path) -> dict[str, Any]: + """Merge a directory of YAML fragments into a single config dict.""" + yaml_files = sorted( + f for f in directory.iterdir() if f.is_file() and f.suffix in (".yaml", ".yml") + ) + if not yaml_files: + raise ValueError(f"config directory {directory}: no YAML files found") + return _load_config_from_files([str(f) for f in yaml_files]) + + +def _load_config_from_files(files: list[str]) -> dict[str, Any]: + """Merge multiple YAML files: base settings from _defaults, groups merged.""" + base: dict[str, Any] = {} + all_groups: list[dict[str, Any]] = [] + seen_group_names: dict[str, str] = {} # name -> source file + + defaults_files = [f for f in files if Path(f).stem == "_defaults"] + fragment_files = [f for f in files if Path(f).stem != "_defaults"] + + # Load base settings from _defaults.yaml if present. + for df in defaults_files: + content = yaml.safe_load(Path(df).read_text()) + if isinstance(content, dict): + base.update(content) + + # Merge groups from all fragment files. + for fpath in sorted(fragment_files): + content = yaml.safe_load(Path(fpath).read_text()) + if not isinstance(content, dict): + continue + # If the fragment has top-level keys other than ``groups``, merge + # them into the base (a fragment may carry its own datasource + # override, etc.). ``groups`` is always merged into the list. + fragment_groups = content.pop("groups", None) + if content: + # Non-group keys from fragments merge into base; _defaults + # has lowest precedence, then alphabetical fragment order. + for key, value in content.items(): + if key == "groups": + continue + base[key] = value + if isinstance(fragment_groups, list): + for g in fragment_groups: + if isinstance(g, dict): + gname = g.get("name", "") + if gname in seen_group_names: + raise ValueError( + f"duplicate group name {gname!r} across config " + f"fragments: first in {seen_group_names[gname]!r}, " + f"again in {fpath!r}" + ) + seen_group_names[gname] = fpath + all_groups.append(g) + + # If _defaults had groups, prepend them (already deduplicated above + # because _defaults_files were loaded separately). + base_groups = base.pop("groups", None) + if isinstance(base_groups, list): + for g in base_groups: + if isinstance(g, dict): + gname = g.get("name", "") + if gname in seen_group_names: + raise ValueError( + f"duplicate group name {gname!r}: defined in both " + f"_defaults and {seen_group_names[gname]!r}" + ) + seen_group_names[gname] = "_defaults" + all_groups.insert(0, g) + + base["groups"] = all_groups + return base + + def _validate_selftest_detector(cfg: Config) -> None: """Reject a self-test detector that can't work on one short window. @@ -1084,6 +1285,7 @@ def _validate_detector_params(cfg: Config) -> None: "SUPPORTED_API_VERSIONS", "AlertThresholds", "AuthConfig", + "AzureAuthConfig", "Config", "DatasourceConfig", "DefaultsConfig", @@ -1093,9 +1295,11 @@ def _validate_detector_params(cfg: Config) -> None: "EnsembleConfig", "ExporterConfig", "ExporterLabelsConfig", + "GCPAuthConfig", "GrafanaAnnotationsSinkConfig", "GroupConfig", "HighAvailabilityConfig", + "OAuth2Config", "OTLPConfig", "QueryCacheConfig", "QueryConfig", @@ -1106,8 +1310,10 @@ def _validate_detector_params(cfg: Config) -> None: "SafetyConfig", "SelfTestConfig", "ServerConfig", + "SigV4Config", "SinkConfig", "TelemetryConfig", + "TenantConfig", "load_config", "parse_duration", ] diff --git a/detector/src/promanomaly/httpauth.py b/detector/src/promanomaly/httpauth.py index 39905c9..033a8a0 100644 --- a/detector/src/promanomaly/httpauth.py +++ b/detector/src/promanomaly/httpauth.py @@ -1,13 +1,16 @@ """Shared httpx client kwargs for the :class:`~promanomaly.config.AuthConfig` -auth shapes (bearer / basic / mTLS). +auth shapes (bearer / basic / mTLS / sigv4 / gcp / azure / oauth2). -Used by the remote-write sink and the metrics adapter so the four auth +Used by the remote-write sink and the metrics adapter so the auth shapes are wired identically wherever promanomaly talks to an authenticated HTTP endpoint. """ from __future__ import annotations +import asyncio +import threading +import time from typing import Any import httpx @@ -15,15 +18,231 @@ from .config import AuthConfig +class _OAuth2TokenCache: + """Thread-safe cache for an OAuth2 access token with auto-refresh. + + Performs a ``client_credentials`` grant against ``token_url`` and + caches the resulting token. Refreshes transparently when the token + is within 60 seconds of expiry. + """ + + def __init__( + self, + token_url: str, + client_id: str, + client_secret: str, + scopes: list[str], + ) -> None: + self._token_url = token_url + self._client_id = client_id + self._client_secret = client_secret + self._scopes = scopes + self._lock = threading.Lock() + self._token: str | None = None + self._expires_at: float = 0.0 + + def get_token(self) -> str: + """Return a valid access token, refreshing if needed.""" + with self._lock: + if self._token and time.monotonic() < self._expires_at - 60: + return self._token + self._refresh() + assert self._token is not None + return self._token + + def _refresh(self) -> None: + data: dict[str, str] = { + "grant_type": "client_credentials", + "client_id": self._client_id, + "client_secret": self._client_secret, + } + if self._scopes: + data["scope"] = " ".join(self._scopes) + try: + resp = httpx.post(self._token_url, data=data, timeout=10.0) + resp.raise_for_status() + except httpx.HTTPError as exc: + raise RuntimeError( + f"OAuth2 token refresh failed against {self._token_url}: {exc}" + ) from exc + try: + payload = resp.json() + except ValueError as exc: + raise RuntimeError( + f"OAuth2 token endpoint returned non-JSON response: {resp.text[:200]}" + ) from exc + if "access_token" not in payload: + raise RuntimeError( + f"OAuth2 token response missing 'access_token' key: {sorted(payload.keys())}" + ) + self._token = str(payload["access_token"]) + expires_in = float(payload.get("expires_in", 3600)) + self._expires_at = time.monotonic() + expires_in + + +class _CloudAuthEventHook: + """Async httpx event hook that injects cloud-provider auth headers. + + httpx ``AsyncClient`` awaits request hooks, so this hook is async. + Token refresh and SigV4 signing involve blocking I/O (HTTP calls to + token endpoints, credential chain resolution); these are offloaded + to a worker thread via ``asyncio.to_thread`` so the event loop is + never blocked. + """ + + def __init__(self, auth: AuthConfig) -> None: + self._auth = auth + self._type = auth.type + # Lazily initialised on first use so missing SDKs surface an + # actionable error rather than a boot-time ImportError. + self._initialised = False + self._lock = threading.Lock() + self._oauth2_cache: _OAuth2TokenCache | None = None + self._gcp_credentials: Any = None + self._azure_credential: Any = None + self._sigv4_session: Any = None + self._sigv4_service: str = "aps" + + def _ensure_initialised(self) -> None: + if self._initialised: + return + with self._lock: + if self._initialised: + return + self._do_init() + self._initialised = True + + def _do_init(self) -> None: + if self._type == "oauth2": + assert self._auth.oauth2 is not None + secret = self._auth.oauth2.resolved_client_secret() or "" + self._oauth2_cache = _OAuth2TokenCache( + token_url=self._auth.oauth2.token_url, + client_id=self._auth.oauth2.client_id, + client_secret=secret, + scopes=list(self._auth.oauth2.scopes), + ) + elif self._type == "gcp": + try: + import google.auth + import google.auth.transport.requests + except ImportError as exc: + raise ImportError( + "auth.type=gcp requires the [gcp] extra: pip install promanomaly[gcp]" + ) from exc + gcp_cfg = self._auth.gcp + if gcp_cfg and gcp_cfg.credentials_file: + self._gcp_credentials, _ = google.auth.load_credentials_from_file( + gcp_cfg.credentials_file, + scopes=["https://www.googleapis.com/auth/monitoring.read"], + ) + else: + self._gcp_credentials, _ = google.auth.default( + scopes=["https://www.googleapis.com/auth/monitoring.read"], + ) + elif self._type == "azure": + try: + from azure.identity import ( + ClientSecretCredential, + DefaultAzureCredential, + ) + except ImportError as exc: + raise ImportError( + "auth.type=azure requires the [azure] extra: pip install promanomaly[azure]" + ) from exc + az_cfg = self._auth.azure + if az_cfg and az_cfg.client_id and az_cfg.tenant_id and az_cfg.client_secret_file: + from .config import _read_secret_file + + secret = _read_secret_file(az_cfg.client_secret_file) + self._azure_credential = ClientSecretCredential( + tenant_id=az_cfg.tenant_id, + client_id=az_cfg.client_id, + client_secret=secret, + ) + else: + self._azure_credential = DefaultAzureCredential() + elif self._type == "sigv4": + try: + import botocore.session + except ImportError as exc: + raise ImportError( + "auth.type=sigv4 requires the [aws] extra: pip install promanomaly[aws]" + ) from exc + assert self._auth.sigv4 is not None + self._sigv4_session = botocore.session.get_session() + if self._auth.sigv4.profile: + self._sigv4_session.set_config_variable("profile", self._auth.sigv4.profile) + + def _inject_auth_sync(self, request: httpx.Request) -> None: + """Synchronous auth injection — runs in a worker thread.""" + self._ensure_initialised() + + if self._type == "oauth2": + assert self._oauth2_cache is not None + token = self._oauth2_cache.get_token() + request.headers["Authorization"] = f"Bearer {token}" + elif self._type == "gcp": + import google.auth.transport.requests + + if not self._gcp_credentials.valid: + self._gcp_credentials.refresh(google.auth.transport.requests.Request()) + request.headers["Authorization"] = f"Bearer {self._gcp_credentials.token}" + elif self._type == "azure": + token = self._azure_credential.get_token( + "https://prometheus.monitor.azure.com/.default" + ) + request.headers["Authorization"] = f"Bearer {token.token}" + elif self._type == "sigv4": + self._sign_sigv4(request) + + async def inject_auth(self, request: httpx.Request) -> None: + """Async event hook for httpx ``AsyncClient``. + + Offloads the blocking auth work (token refresh, credential + resolution, SigV4 signing) to a worker thread so the event + loop is never blocked. + """ + await asyncio.to_thread(self._inject_auth_sync, request) + + def _sign_sigv4(self, request: httpx.Request) -> None: + """Sign a request with AWS SigV4 for Amazon Managed Prometheus.""" + import botocore.auth + from botocore.awsrequest import AWSRequest + + assert self._auth.sigv4 is not None + credentials = self._sigv4_session.get_credentials() + if credentials is None: + raise RuntimeError("no AWS credentials found for SigV4 signing") + resolved = credentials.get_frozen_credentials() + signer = botocore.auth.SigV4Auth( + resolved, + self._sigv4_service, + self._auth.sigv4.region, + ) + aws_request = AWSRequest( + method=str(request.method), + url=str(request.url), + headers=dict(request.headers), + data=request.content, + ) + signer.add_auth(aws_request) + # Copy the signed headers back. + for key, value in aws_request.headers.items(): + request.headers[key] = value + + def build_httpx_auth(auth: AuthConfig) -> dict[str, Any]: """Return httpx ``AsyncClient`` kwargs implementing ``auth``. Returns an empty dict for ``type: none``. Bearer sets the Authorization header; basic sets httpx BasicAuth; mTLS sets the client - cert pair and (optionally) the CA bundle to verify against. Bearer - tokens and basic passwords are resolved from their ``*_file`` Secret - mount when configured (the path the Helm charts wire up), else the - inline value. + cert pair and (optionally) the CA bundle to verify against. Cloud auth + types (sigv4, gcp, azure, oauth2) install an async event hook that + offloads blocking token refresh to a worker thread so the event loop + is never blocked. Bearer tokens and basic passwords are resolved from + their ``*_file`` Secret mount when configured (the path the Helm + charts wire up), else the inline value. """ kwargs: dict[str, Any] = {} if auth.type == "bearer": @@ -37,6 +256,9 @@ def build_httpx_auth(auth: AuthConfig) -> dict[str, Any]: kwargs["cert"] = (auth.cert_file, auth.key_file) if auth.ca_file: kwargs["verify"] = auth.ca_file + elif auth.type in ("sigv4", "gcp", "azure", "oauth2"): + hook = _CloudAuthEventHook(auth) + kwargs["event_hooks"] = {"request": [hook.inject_auth]} return kwargs diff --git a/detector/src/promanomaly/main.py b/detector/src/promanomaly/main.py index 1d6c585..921fbce 100644 --- a/detector/src/promanomaly/main.py +++ b/detector/src/promanomaly/main.py @@ -67,6 +67,7 @@ def __init__(self, config: Config, config_path: Path) -> None: timeout=config.datasource.timeout_seconds, auth=config.datasource.auth, cache=_build_query_cache(config, self._redis), + tenant=config.datasource.tenant, ) # OpenTelemetry tracing — built from config exactly once at boot. # Reload swaps in a fresh Telemetry via ``Runner._telemetry`` only @@ -158,6 +159,7 @@ async def shutdown(self) -> None: for sink in self._sinks: await sink.aclose() await self._source.close() + await self._runner.close_tenant_sources() # Shut down the runner's thread pool so pending detector # invocations don't outlive the process. wait=False mirrors the # scheduler behaviour: in-flight detection runs are abandoned @@ -201,7 +203,31 @@ async def reload(self) -> tuple[bool, str]: return False, f"validation failed: {exc}" self._runner.replace_config(new_config) - self._source.replace_cache(_build_query_cache(new_config, self._redis)) + # Close stale tenant sources — the runner will lazily create + # new ones on the next group run with the new config's tenants. + await self._runner.close_tenant_sources() + # Rebuild the main source if the datasource config changed + # (url, auth, timeout, or tenant). A simple cache swap is + # not enough: the tenant header, auth hooks, and timeout are + # baked into the httpx client at construction time. Comparing + # the full model catches credential rotations, timeout + # changes, and any other field change. + old_ds = self._config.datasource + new_ds = new_config.datasource + if old_ds != new_ds: + await self._source.close() + self._source = PromQLSource( + base_url=new_ds.url, + timeout=new_ds.timeout_seconds, + auth=new_ds.auth, + cache=_build_query_cache(new_config, self._redis), + tenant=new_ds.tenant, + ) + await self._source.start() + self._runner._source = self._source + self._runner._stratified.replace_source(self._source) + else: + self._source.replace_cache(_build_query_cache(new_config, self._redis)) await self._swap_sinks(new_config) self._config = new_config self._stamp_config_hash(new_config) diff --git a/detector/src/promanomaly/migrate.py b/detector/src/promanomaly/migrate.py new file mode 100644 index 0000000..475733e --- /dev/null +++ b/detector/src/promanomaly/migrate.py @@ -0,0 +1,152 @@ +"""Config schema migration for promanomaly. + +Reads a config at any supported ``apiVersion``, applies the registered +forward-migrations (field renames, default relocations, ``apiVersion`` +rewrite), and writes the upgraded document. Idempotent: running it on an +already-current config is a no-op. + +Migrations are registered per schema revision so the path composes +across multiple versions. Output preserves key order and comments where +the round-trip YAML library supports it (falls back to ``PyYAML`` +``safe_dump`` otherwise). +""" + +from __future__ import annotations + +import difflib +import logging +from pathlib import Path +from typing import Any + +import yaml + +from .config import CURRENT_API_VERSION, SUPPORTED_API_VERSIONS + +logger = logging.getLogger(__name__) + + +def _migrate_v1alpha1_to_v1(raw: dict[str, Any]) -> dict[str, Any]: + """Migrate from ``promanomaly.io/v1alpha1`` to ``promanomaly.io/v1``. + + The two schemas are byte-identical, so this is purely an + ``apiVersion`` rewrite. + """ + raw["apiVersion"] = CURRENT_API_VERSION + return raw + + +# Registry of forward migrations. Each key is a source apiVersion; +# the value is ``(target_version, migration_fn)``. The migration +# function receives the raw YAML dict and returns the modified dict. +_MIGRATION_MAP: dict[str, tuple[str, Any]] = { + "promanomaly.io/v1alpha1": (CURRENT_API_VERSION, _migrate_v1alpha1_to_v1), +} + + +def migrate_raw(raw: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: + """Apply all applicable forward-migrations to a raw config dict. + + Returns ``(migrated_dict, changes)`` where ``changes`` is a list of + human-readable descriptions of what was changed. An empty ``changes`` + list means the config was already at the current version. + """ + changes: list[str] = [] + api_version = raw.get("apiVersion", "") + + if api_version not in SUPPORTED_API_VERSIONS: + raise ValueError( + f"unsupported apiVersion {api_version!r}; cannot migrate. " + f"Supported versions: {sorted(SUPPORTED_API_VERSIONS)}" + ) + + # Apply migrations in chain order. + while api_version in _MIGRATION_MAP: + target_version, migration_fn = _MIGRATION_MAP[api_version] + raw = migration_fn(raw) + changes.append(f"apiVersion: {api_version!r} -> {target_version!r}") + api_version = target_version + + return raw, changes + + +def migrate_file( + path: str | Path, + *, + dry_run: bool = False, +) -> tuple[str, list[str]]: + """Migrate a YAML config file. + + When ``dry_run`` is True, returns a unified diff of the changes + without writing. When False, writes the migrated config in place. + + Returns ``(output, changes)`` where ``output`` is the unified diff + (dry_run) or the migrated YAML text (write mode). + + Raises ``ValueError`` for invalid configs and ``OSError`` for + file I/O failures. + """ + p = Path(path) + original_text = p.read_text() + raw = yaml.safe_load(original_text) + if not isinstance(raw, dict): + raise ValueError(f"config {path}: top level must be a mapping") + + migrated, changes = migrate_raw(raw) + + if not changes: + return "", changes + + migrated_text = yaml.dump( + migrated, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + ) + + if dry_run: + diff = difflib.unified_diff( + original_text.splitlines(keepends=True), + migrated_text.splitlines(keepends=True), + fromfile=str(path), + tofile=str(path), + ) + return "".join(diff), changes + + p.write_text(migrated_text) + return migrated_text, changes + + +def migrate_directory( + directory: str | Path, + *, + dry_run: bool = False, +) -> list[tuple[str, str, list[str]]]: + """Migrate all YAML files in a directory. + + Returns a list of ``(file_path, output, changes)`` tuples. + Files that fail to parse or migrate are logged and skipped so a + single malformed fragment doesn't block the rest of the directory. + """ + d = Path(directory) + results: list[tuple[str, str, list[str]]] = [] + for f in sorted(d.iterdir()): + if not f.is_file() or f.suffix not in (".yaml", ".yml"): + continue + try: + output, changes = migrate_file(f, dry_run=dry_run) + if changes: + results.append((str(f), output, changes)) + except (ValueError, yaml.YAMLError) as exc: + logger.warning("skipping %s: %s", f, exc) + continue + except OSError as exc: + logger.warning("write failed for %s: %s", f, exc) + continue + return results + + +__all__ = [ + "migrate_directory", + "migrate_file", + "migrate_raw", +] diff --git a/detector/src/promanomaly/runner.py b/detector/src/promanomaly/runner.py index da0ae16..37a672a 100644 --- a/detector/src/promanomaly/runner.py +++ b/detector/src/promanomaly/runner.py @@ -37,6 +37,7 @@ DetectorEntry, GroupConfig, QueryConfig, + TenantConfig, ) from .detectors import Detector, UnknownDetectorError, get from .detectors.cohort import ( @@ -249,6 +250,12 @@ def __init__( # this facade; the runner delegates to it rather than owning # the heavy cache state directly. self._stratified = StratifiedFetcher(source=source, config=config) + # Per-tenant source instances. When groups carry a ``tenant`` + # override that differs from the datasource-level default, we + # create a dedicated PromQLSource for that tenant so the + # ``X-Scope-OrgID`` header is set on every request. Instances + # are lazily created and cached by tenant id. + self._tenant_sources: dict[str, PromQLSource] = {} # Tracks the last time recurrence analysis ran per group so it # doesn't execute on every group refresh (the 4-week lookback # makes frequent re-queries wasteful). Between refreshes, the @@ -268,6 +275,51 @@ def __init__( def config(self) -> Config: return self._config + def _effective_tenant(self, group: GroupConfig) -> TenantConfig | None: + """Resolve the effective tenant for a group. + + Group-level tenant overrides the datasource-level default. + """ + return group.tenant or self._config.datasource.tenant + + async def _source_for_group(self, group: GroupConfig) -> PromQLSource: + """Return the PromQLSource for a group, respecting tenant overrides. + + Groups without a tenant override use the default source. Groups + with a per-group tenant get a dedicated source instance so the + tenant header is set on every request. + """ + tenant = self._effective_tenant(group) + default_tenant = self._config.datasource.tenant + # If the group's effective tenant matches the default source's + # tenant (or both are None), use the shared source. + if tenant == default_tenant: + return self._source + if tenant is None: + return self._source + # Create a per-tenant source on demand. + cache_key = f"{tenant.header}:{tenant.id}" + if cache_key not in self._tenant_sources: + source = PromQLSource( + base_url=self._config.datasource.url, + timeout=self._config.datasource.timeout_seconds, + auth=self._config.datasource.auth, + tenant=tenant, + ) + await source.start() + self._tenant_sources[cache_key] = source + return self._tenant_sources[cache_key] + + async def close_tenant_sources(self) -> None: + """Close all per-tenant source instances. + + Called from ``Application.shutdown()`` and internally from + ``replace_config()`` so stale tenant connections don't leak. + """ + for source in self._tenant_sources.values(): + await source.close() + self._tenant_sources.clear() + # ------------------------------------------------------------------ # Test-friendly accessors. The underscored attributes are # implementation detail; these properties exist so tests can @@ -779,9 +831,10 @@ async def _probe_discovery_variables( (the runner treats that like an ``expect:`` empty result). """ defaults = self._config.defaults + source = await self._source_for_group(group) async def _probe(promql: str) -> QueryResult: - return await self._source.range_query( + return await source.range_query( promql=promql, end=time.time(), window_seconds=defaults.window_seconds, @@ -1012,6 +1065,7 @@ async def _fetch_and_plan( # with different baseline refresh intervals — fresher data # for everyone, at the cost of more TSDB load. baseline_refresh = min(refresh for _, refresh in stratified_meta) + group_source = await self._source_for_group(group) result = await self._stratified.fetch( group=group, query=query, @@ -1020,9 +1074,11 @@ async def _fetch_and_plan( baseline_window_seconds=baseline_window, baseline_refresh_seconds=baseline_refresh, template_query=template_query or query, + source=group_source, ) else: - result = await self._source.range_query( + source = await self._source_for_group(group) + result = await source.range_query( promql=query.promql, end=end, window_seconds=short_window_seconds, @@ -1921,7 +1977,8 @@ async def _recurrence_samples(self, group: GroupConfig) -> list[Sample]: # multi-detector inflation where N detectors scoring the same id # would otherwise contribute N observations per time step. promql = f'max by (id, group) (anomaly_outside_threshold{{group="{group.name}"}})' - result = await self._source.range_query( + source = await self._source_for_group(group) + result = await source.range_query( promql=promql, end=time.time(), window_seconds=group.recurrence.lookback_seconds, diff --git a/detector/src/promanomaly/source.py b/detector/src/promanomaly/source.py index 51793fe..484565a 100644 --- a/detector/src/promanomaly/source.py +++ b/detector/src/promanomaly/source.py @@ -19,7 +19,7 @@ import pandas as pd from .cache import TTLCache -from .config import AuthConfig +from .config import AuthConfig, TenantConfig from .httpauth import build_httpx_auth # Shared connection pool sized for the typical 10k-series workload. The @@ -89,12 +89,14 @@ def __init__( timeout: float, auth: AuthConfig | None = None, cache: TTLCache[tuple[str, float, float, int, int], QueryResult] | None = None, + tenant: TenantConfig | None = None, ) -> None: self._base_url = base_url.rstrip("/") self._timeout = timeout self._auth = auth or AuthConfig() self._client: httpx.AsyncClient | None = None self._cache = cache + self._tenant = tenant @property def cache(self) -> TTLCache[tuple[str, float, float, int, int], QueryResult] | None: @@ -122,7 +124,13 @@ async def start(self) -> None: "base_url": self._base_url, "limits": _HTTP_LIMITS, } - kwargs.update(build_httpx_auth(self._auth)) + auth_kwargs = build_httpx_auth(self._auth) + # Merge tenant header into any existing headers from auth. + if self._tenant: + existing_headers = auth_kwargs.get("headers", {}) + existing_headers[self._tenant.header] = self._tenant.id + auth_kwargs["headers"] = existing_headers + kwargs.update(auth_kwargs) self._client = httpx.AsyncClient(**kwargs) async def close(self) -> None: @@ -165,8 +173,13 @@ async def range_query( # ZeroDivisionError. step = float(step_seconds) if step_seconds > 0 else 1e-6 bucket = int(end // step) + # Include tenant in the cache key so two sources with different + # tenants sharing the same cache backend (e.g. Redis) never + # collide. The tenant prefix is prepended to the promql string + # to keep the key tuple shape unchanged. + tenant_prefix = f"[{self._tenant.header}:{self._tenant.id}]" if self._tenant else "" cache_key = ( - promql, + tenant_prefix + promql, float(window_seconds), float(step_seconds), int(max_series or 0), diff --git a/detector/src/promanomaly/stratified_fetcher.py b/detector/src/promanomaly/stratified_fetcher.py index 627066e..f6aa9a2 100644 --- a/detector/src/promanomaly/stratified_fetcher.py +++ b/detector/src/promanomaly/stratified_fetcher.py @@ -144,6 +144,10 @@ def __init__(self, *, source: PromQLSource, config: Config) -> None: # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ + def replace_source(self, new_source: PromQLSource) -> None: + """Swap the underlying PromQL source (e.g. on datasource reload).""" + self._source = new_source + def replace_config(self, new_config: Config) -> None: """Update the config and prune state the new shape no longer owns.""" self._config = new_config @@ -188,6 +192,7 @@ async def fetch( baseline_window_seconds: float, baseline_refresh_seconds: float, template_query: QueryConfig | None = None, + source: PromQLSource | None = None, ) -> QueryResult: """Sliding-window fetch for queries with a stratified detector. @@ -212,13 +217,14 @@ async def fetch( wipe every discover-derived cache entry (the template id never equals the rendered id). """ + effective_source = source or self._source defaults = self._config.defaults max_series = self._config.safety.max_series_per_query cache_key = (group.name, query.id) template = template_query or query try: - short_result = await self._source.range_query( + short_result = await effective_source.range_query( promql=query.promql, end=end, window_seconds=short_window_seconds, @@ -250,7 +256,7 @@ async def fetch( ) if baseline_stale: try: - baseline_result = await self._source.range_query( + baseline_result = await effective_source.range_query( promql=query.promql, end=end, window_seconds=baseline_window_seconds, diff --git a/detector/tests/test_v16_enterprise.py b/detector/tests/test_v16_enterprise.py new file mode 100644 index 0000000..0de753f --- /dev/null +++ b/detector/tests/test_v16_enterprise.py @@ -0,0 +1,946 @@ +"""Tests for v1.6 — Enterprise integration: managed backends and multi-team config.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from promanomaly.config import ( + CURRENT_API_VERSION, + AuthConfig, + Config, + TenantConfig, + load_config, +) +from promanomaly.httpauth import build_httpx_auth +from promanomaly.migrate import migrate_file, migrate_raw + +MIN_CONFIG: dict[str, object] = { + "apiVersion": CURRENT_API_VERSION, + "datasource": {"url": "http://vm:8428/"}, + "groups": [ + { + "name": "g1", + "queries": [ + { + "id": "metric_a", + "promql": "up", + "detectors": [{"name": "MAD"}], + } + ], + } + ], +} + + +# ── Cloud-managed datasource authentication ────────────────────────── + + +class TestCloudAuth: + def test_sigv4_type_requires_sigv4_block(self) -> None: + raw = { + **MIN_CONFIG, + "datasource": {"url": "http://amp:8428/", "auth": {"type": "sigv4"}}, + } + with pytest.raises(Exception, match=r"requires a matching auth\.sigv4 block"): + Config.model_validate(raw) + + def test_sigv4_with_block_validates(self) -> None: + raw = { + **MIN_CONFIG, + "datasource": { + "url": "http://amp:8428/", + "auth": { + "type": "sigv4", + "sigv4": {"region": "us-east-1"}, + }, + }, + } + cfg = Config.model_validate(raw) + assert cfg.datasource.auth.type == "sigv4" + assert cfg.datasource.auth.sigv4 is not None + assert cfg.datasource.auth.sigv4.region == "us-east-1" + + def test_gcp_type_requires_gcp_block(self) -> None: + raw = { + **MIN_CONFIG, + "datasource": {"url": "http://gmp:8428/", "auth": {"type": "gcp"}}, + } + with pytest.raises(Exception, match=r"requires a matching auth\.gcp block"): + Config.model_validate(raw) + + def test_gcp_with_empty_block_validates(self) -> None: + raw = { + **MIN_CONFIG, + "datasource": { + "url": "http://gmp:8428/", + "auth": {"type": "gcp", "gcp": {}}, + }, + } + cfg = Config.model_validate(raw) + assert cfg.datasource.auth.type == "gcp" + assert cfg.datasource.auth.gcp is not None + + def test_azure_type_requires_azure_block(self) -> None: + raw = { + **MIN_CONFIG, + "datasource": {"url": "http://az:8428/", "auth": {"type": "azure"}}, + } + with pytest.raises(Exception, match=r"requires a matching auth\.azure block"): + Config.model_validate(raw) + + def test_azure_with_block_validates(self) -> None: + raw = { + **MIN_CONFIG, + "datasource": { + "url": "http://az:8428/", + "auth": { + "type": "azure", + "azure": {"client_id": "abc", "tenant_id": "def"}, + }, + }, + } + cfg = Config.model_validate(raw) + assert cfg.datasource.auth.type == "azure" + assert cfg.datasource.auth.azure is not None + assert cfg.datasource.auth.azure.client_id == "abc" + + def test_oauth2_type_requires_oauth2_block(self) -> None: + raw = { + **MIN_CONFIG, + "datasource": {"url": "http://idp:8428/", "auth": {"type": "oauth2"}}, + } + with pytest.raises(Exception, match=r"requires a matching auth\.oauth2 block"): + Config.model_validate(raw) + + def test_oauth2_with_block_validates(self) -> None: + raw = { + **MIN_CONFIG, + "datasource": { + "url": "http://idp:8428/", + "auth": { + "type": "oauth2", + "oauth2": { + "token_url": "https://auth.example.com/token", + "client_id": "my-client", + "client_secret": "s3cret", + "scopes": ["read", "write"], + }, + }, + }, + } + cfg = Config.model_validate(raw) + assert cfg.datasource.auth.type == "oauth2" + assert cfg.datasource.auth.oauth2 is not None + assert cfg.datasource.auth.oauth2.token_url == "https://auth.example.com/token" + assert cfg.datasource.auth.oauth2.scopes == ["read", "write"] + + def test_oauth2_resolved_client_secret_inline(self) -> None: + auth = AuthConfig( + type="oauth2", + oauth2={ + "token_url": "https://auth/token", + "client_id": "c", + "client_secret": "inline_secret", + }, + ) + assert auth.oauth2 is not None + assert auth.oauth2.resolved_client_secret() == "inline_secret" + + def test_oauth2_resolved_client_secret_file(self, tmp_path: Path) -> None: + secret_file = tmp_path / "secret" + secret_file.write_text("file_secret\n") + auth = AuthConfig( + type="oauth2", + oauth2={ + "token_url": "https://auth/token", + "client_id": "c", + "client_secret_file": str(secret_file), + }, + ) + assert auth.oauth2 is not None + assert auth.oauth2.resolved_client_secret() == "file_secret" + + def test_none_type_ignores_cloud_blocks(self) -> None: + """Cloud blocks present with type=none are ignored (forward compat).""" + raw = { + **MIN_CONFIG, + "datasource": { + "url": "http://vm:8428/", + "auth": { + "type": "none", + "sigv4": {"region": "us-east-1"}, + }, + }, + } + cfg = Config.model_validate(raw) + assert cfg.datasource.auth.type == "none" + + def test_build_httpx_auth_bearer(self) -> None: + auth = AuthConfig(type="bearer", token="tok") + kwargs = build_httpx_auth(auth) + assert kwargs["headers"]["Authorization"] == "Bearer tok" + + def test_build_httpx_auth_cloud_installs_event_hook(self) -> None: + import asyncio + import inspect + + auth = AuthConfig( + type="oauth2", + oauth2={ + "token_url": "https://auth/token", + "client_id": "c", + "client_secret": "s", + }, + ) + kwargs = build_httpx_auth(auth) + assert "event_hooks" in kwargs + assert "request" in kwargs["event_hooks"] + assert len(kwargs["event_hooks"]["request"]) == 1 + # httpx AsyncClient requires async event hooks. + hook = kwargs["event_hooks"]["request"][0] + assert asyncio.iscoroutinefunction(hook) or inspect.iscoroutinefunction(hook) + + +# ── Multi-tenant datasource support ────────────────────────────────── + + +class TestMultiTenant: + def test_datasource_tenant_validates(self) -> None: + raw = { + **MIN_CONFIG, + "datasource": { + "url": "http://mimir:8428/", + "tenant": {"id": "team-a"}, + }, + } + cfg = Config.model_validate(raw) + assert cfg.datasource.tenant is not None + assert cfg.datasource.tenant.id == "team-a" + assert cfg.datasource.tenant.header == "X-Scope-OrgID" + + def test_datasource_tenant_custom_header(self) -> None: + raw = { + **MIN_CONFIG, + "datasource": { + "url": "http://cortex:8428/", + "tenant": {"id": "team-b", "header": "X-Custom-Tenant"}, + }, + } + cfg = Config.model_validate(raw) + assert cfg.datasource.tenant is not None + assert cfg.datasource.tenant.header == "X-Custom-Tenant" + + def test_empty_tenant_id_rejected(self) -> None: + raw = { + **MIN_CONFIG, + "datasource": { + "url": "http://mimir:8428/", + "tenant": {"id": ""}, + }, + } + with pytest.raises(Exception, match=r"at least 1 character"): + Config.model_validate(raw) + + def test_empty_sigv4_region_rejected(self) -> None: + raw = { + **MIN_CONFIG, + "datasource": { + "url": "http://amp:8428/", + "auth": {"type": "sigv4", "sigv4": {"region": ""}}, + }, + } + with pytest.raises(Exception, match=r"at least 1 character"): + Config.model_validate(raw) + + def test_group_tenant_override(self) -> None: + raw = { + **MIN_CONFIG, + "datasource": { + "url": "http://mimir:8428/", + "tenant": {"id": "default-tenant"}, + }, + "groups": [ + { + "name": "g1", + "tenant": {"id": "team-specific"}, + "queries": [ + { + "id": "metric_a", + "promql": "up", + "detectors": [{"name": "MAD"}], + } + ], + } + ], + } + cfg = Config.model_validate(raw) + assert cfg.groups[0].tenant is not None + assert cfg.groups[0].tenant.id == "team-specific" + assert cfg.datasource.tenant is not None + assert cfg.datasource.tenant.id == "default-tenant" + + def test_no_tenant_is_default(self) -> None: + cfg = Config.model_validate(MIN_CONFIG) + assert cfg.datasource.tenant is None + assert cfg.groups[0].tenant is None + + +# ── Config composition for multi-team ownership ───────────────────── + + +class TestConfigComposition: + def test_load_single_file(self, tmp_path: Path) -> None: + config = { + "apiVersion": CURRENT_API_VERSION, + "datasource": {"url": "http://vm:8428/"}, + "groups": [ + { + "name": "g1", + "queries": [ + {"id": "m1", "promql": "up", "detectors": ["MAD"]}, + ], + } + ], + } + f = tmp_path / "config.yaml" + f.write_text(yaml.dump(config)) + cfg = load_config(str(f)) + assert len(cfg.groups) == 1 + + def test_load_directory_with_defaults(self, tmp_path: Path) -> None: + defaults = { + "apiVersion": CURRENT_API_VERSION, + "datasource": {"url": "http://vm:8428/"}, + "defaults": {"window": "30m"}, + } + team_a = { + "groups": [ + { + "name": "team_a", + "queries": [ + {"id": "m1", "promql": "up", "detectors": ["MAD"]}, + ], + } + ], + } + team_b = { + "groups": [ + { + "name": "team_b", + "queries": [ + {"id": "m2", "promql": "up{job='b'}", "detectors": ["MAD"]}, + ], + } + ], + } + (tmp_path / "_defaults.yaml").write_text(yaml.dump(defaults)) + (tmp_path / "team_a.yaml").write_text(yaml.dump(team_a)) + (tmp_path / "team_b.yaml").write_text(yaml.dump(team_b)) + + cfg = load_config(tmp_path) + assert len(cfg.groups) == 2 + assert cfg.groups[0].name == "team_a" + assert cfg.groups[1].name == "team_b" + assert cfg.defaults.window == "30m" + + def test_load_directory_rejects_duplicate_group_names(self, tmp_path: Path) -> None: + defaults = { + "apiVersion": CURRENT_API_VERSION, + "datasource": {"url": "http://vm:8428/"}, + } + frag_a = { + "groups": [ + { + "name": "overlap", + "queries": [ + {"id": "m1", "promql": "up", "detectors": ["MAD"]}, + ], + } + ], + } + frag_b = { + "groups": [ + { + "name": "overlap", + "queries": [ + {"id": "m2", "promql": "up", "detectors": ["MAD"]}, + ], + } + ], + } + (tmp_path / "_defaults.yaml").write_text(yaml.dump(defaults)) + (tmp_path / "a.yaml").write_text(yaml.dump(frag_a)) + (tmp_path / "b.yaml").write_text(yaml.dump(frag_b)) + + with pytest.raises(ValueError, match="duplicate group name"): + load_config(tmp_path) + + def test_load_directory_no_yaml_files(self, tmp_path: Path) -> None: + (tmp_path / "readme.txt").write_text("not yaml") + with pytest.raises(ValueError, match="no YAML files found"): + load_config(tmp_path) + + def test_load_directory_fragment_without_defaults(self, tmp_path: Path) -> None: + """A directory without _defaults.yaml uses fragment keys directly.""" + config = { + "apiVersion": CURRENT_API_VERSION, + "datasource": {"url": "http://vm:8428/"}, + "groups": [ + { + "name": "only_group", + "queries": [ + {"id": "m1", "promql": "up", "detectors": ["MAD"]}, + ], + } + ], + } + (tmp_path / "single.yaml").write_text(yaml.dump(config)) + cfg = load_config(tmp_path) + assert len(cfg.groups) == 1 + assert cfg.groups[0].name == "only_group" + + def test_load_glob_pattern(self, tmp_path: Path) -> None: + defaults = { + "apiVersion": CURRENT_API_VERSION, + "datasource": {"url": "http://vm:8428/"}, + } + frag = { + "groups": [ + { + "name": "glob_g", + "queries": [ + {"id": "m1", "promql": "up", "detectors": ["MAD"]}, + ], + } + ], + } + (tmp_path / "_defaults.yaml").write_text(yaml.dump(defaults)) + (tmp_path / "team.yaml").write_text(yaml.dump(frag)) + + cfg = load_config(str(tmp_path / "*.yaml")) + assert len(cfg.groups) == 1 + assert cfg.groups[0].name == "glob_g" + + +# ── Config schema migration CLI ────────────────────────────────────── + + +class TestMigrateConfig: + def test_migrate_v1alpha1_to_v1(self) -> None: + raw = { + "apiVersion": "promanomaly.io/v1alpha1", + "datasource": {"url": "http://vm:8428/"}, + "groups": [], + } + migrated, changes = migrate_raw(raw) + assert migrated["apiVersion"] == CURRENT_API_VERSION + assert len(changes) == 1 + assert "v1alpha1" in changes[0] + + def test_migrate_already_current_is_noop(self) -> None: + raw = { + "apiVersion": CURRENT_API_VERSION, + "datasource": {"url": "http://vm:8428/"}, + "groups": [], + } + migrated, changes = migrate_raw(raw) + assert not changes + assert migrated["apiVersion"] == CURRENT_API_VERSION + + def test_migrate_unsupported_version_raises(self) -> None: + raw = {"apiVersion": "promanomaly.io/v999"} + with pytest.raises(ValueError, match="unsupported apiVersion"): + migrate_raw(raw) + + def test_migrate_file_dry_run(self, tmp_path: Path) -> None: + config = { + "apiVersion": "promanomaly.io/v1alpha1", + "datasource": {"url": "http://vm:8428/"}, + "groups": [], + } + f = tmp_path / "config.yaml" + f.write_text(yaml.dump(config)) + + output, changes = migrate_file(f, dry_run=True) + assert changes + # Original file untouched. + raw = yaml.safe_load(f.read_text()) + assert raw["apiVersion"] == "promanomaly.io/v1alpha1" + # Diff output present. + assert "---" in output or "+++" in output + + def test_migrate_file_write(self, tmp_path: Path) -> None: + config = { + "apiVersion": "promanomaly.io/v1alpha1", + "datasource": {"url": "http://vm:8428/"}, + "groups": [], + } + f = tmp_path / "config.yaml" + f.write_text(yaml.dump(config)) + + _, changes = migrate_file(f, dry_run=False) + assert changes + # File updated. + raw = yaml.safe_load(f.read_text()) + assert raw["apiVersion"] == CURRENT_API_VERSION + + def test_migrate_file_noop(self, tmp_path: Path) -> None: + config = { + "apiVersion": CURRENT_API_VERSION, + "datasource": {"url": "http://vm:8428/"}, + "groups": [], + } + f = tmp_path / "config.yaml" + f.write_text(yaml.dump(config)) + + output, changes = migrate_file(f, dry_run=True) + assert not changes + assert output == "" + + def test_migrate_idempotent(self, tmp_path: Path) -> None: + config = { + "apiVersion": "promanomaly.io/v1alpha1", + "datasource": {"url": "http://vm:8428/"}, + "groups": [], + } + f = tmp_path / "config.yaml" + f.write_text(yaml.dump(config)) + + migrate_file(f, dry_run=False) + # Run again. + _, changes = migrate_file(f, dry_run=False) + assert not changes + + +# ── TenantConfig in PromQLSource ───────────────────────────────────── + + +class TestTenantSourceIntegration: + def test_source_with_tenant_injects_header(self) -> None: + from promanomaly.source import PromQLSource + + tenant = TenantConfig(id="team-x", header="X-Scope-OrgID") + source = PromQLSource( + base_url="http://localhost:8428", + timeout=5.0, + tenant=tenant, + ) + # After start, the client should have the tenant header. + import asyncio + + asyncio.run(source.start()) + assert source._client is not None + assert source._client.headers.get("X-Scope-OrgID") == "team-x" + asyncio.run(source.close()) + + def test_source_without_tenant_no_header(self) -> None: + from promanomaly.source import PromQLSource + + source = PromQLSource( + base_url="http://localhost:8428", + timeout=5.0, + ) + import asyncio + + asyncio.run(source.start()) + assert source._client is not None + assert "X-Scope-OrgID" not in source._client.headers + asyncio.run(source.close()) + + def test_source_tenant_with_bearer_auth(self) -> None: + """Tenant header coexists with bearer auth header.""" + from promanomaly.source import PromQLSource + + tenant = TenantConfig(id="team-z", header="X-Scope-OrgID") + auth = AuthConfig(type="bearer", token="my-token") + source = PromQLSource( + base_url="http://localhost:8428", + timeout=5.0, + auth=auth, + tenant=tenant, + ) + import asyncio + + asyncio.run(source.start()) + assert source._client is not None + assert source._client.headers.get("X-Scope-OrgID") == "team-z" + assert source._client.headers.get("Authorization") == "Bearer my-token" + asyncio.run(source.close()) + + def test_source_tenant_with_cloud_auth_event_hook(self) -> None: + """Tenant header set alongside cloud auth event hooks.""" + auth = AuthConfig( + type="oauth2", + oauth2={ + "token_url": "https://auth/token", + "client_id": "c", + "client_secret": "s", + }, + ) + tenant = TenantConfig(id="team-cloud", header="X-Scope-OrgID") + + from promanomaly.source import PromQLSource + + source = PromQLSource( + base_url="http://localhost:8428", + timeout=5.0, + auth=auth, + tenant=tenant, + ) + import asyncio + + asyncio.run(source.start()) + assert source._client is not None + # Tenant header is static on the client. + assert source._client.headers.get("X-Scope-OrgID") == "team-cloud" + # OAuth2 auth is via event hooks, not static headers. + assert "Authorization" not in source._client.headers + asyncio.run(source.close()) + + +# ── Cache key tenant isolation ─────────────────────────────────────── + + +class TestCacheKeyTenantIsolation: + def test_cache_key_includes_tenant_prefix(self) -> None: + """Two sources with different tenants produce different cache keys.""" + from promanomaly.cache import TTLCache + from promanomaly.source import PromQLSource, QueryResult + + cache: TTLCache[tuple[str, float, float, int, int], QueryResult] = TTLCache( + max_entries=100, ttl_seconds=30.0 + ) + t1 = TenantConfig(id="team-a", header="X-Scope-OrgID") + t2 = TenantConfig(id="team-b", header="X-Scope-OrgID") + + s1 = PromQLSource("http://vm:8428", 5.0, tenant=t1, cache=cache) + s2 = PromQLSource("http://vm:8428", 5.0, tenant=t2, cache=cache) + + # Access the internal tenant prefix logic (without actually querying). + prefix1 = f"[{s1._tenant.header}:{s1._tenant.id}]" if s1._tenant else "" + prefix2 = f"[{s2._tenant.header}:{s2._tenant.id}]" if s2._tenant else "" + assert prefix1 != prefix2 + assert prefix1 == "[X-Scope-OrgID:team-a]" + assert prefix2 == "[X-Scope-OrgID:team-b]" + + def test_cache_key_no_tenant_no_prefix(self) -> None: + from promanomaly.source import PromQLSource + + source = PromQLSource("http://vm:8428", 5.0) + assert source._tenant is None + + +# ── OAuth2 error handling ──────────────────────────────────────────── + + +class TestOAuth2ErrorHandling: + def test_oauth2_refresh_wraps_http_error(self) -> None: + from promanomaly.httpauth import _OAuth2TokenCache + + cache = _OAuth2TokenCache( + token_url="http://localhost:1/nonexistent", + client_id="c", + client_secret="s", + scopes=[], + ) + with pytest.raises(RuntimeError, match="OAuth2 token refresh failed"): + cache.get_token() + + +# ── Tenant source lifecycle ────────────────────────────────────────── + + +class TestTenantSourceLifecycle: + def test_close_tenant_sources(self) -> None: + """Runner.close_tenant_sources() closes all per-tenant sources.""" + import asyncio + + from promanomaly.exporter import OperationalMetrics + from promanomaly.runner import Runner + from promanomaly.source import PromQLSource + from promanomaly.state import SnapshotStore + + cfg = Config.model_validate( + { + **MIN_CONFIG, + "datasource": { + "url": "http://vm:8428/", + "tenant": {"id": "default"}, + }, + "groups": [ + { + "name": "g1", + "tenant": {"id": "override"}, + "queries": [ + {"id": "m1", "promql": "up", "detectors": ["MAD"]}, + ], + } + ], + } + ) + source = PromQLSource("http://vm:8428", 5.0, tenant=cfg.datasource.tenant) + runner = Runner(cfg, source, SnapshotStore(), OperationalMetrics()) + + # Simulate creating a tenant source. + async def _setup_and_close() -> None: + ts = PromQLSource("http://vm:8428", 5.0, tenant=TenantConfig(id="override")) + await ts.start() + runner._tenant_sources["X-Scope-OrgID:override"] = ts + assert len(runner._tenant_sources) == 1 + + await runner.close_tenant_sources() + assert len(runner._tenant_sources) == 0 + + asyncio.run(_setup_and_close()) + + +# ── Config composition edge cases ──────────────────────────────────── + + +class TestConfigCompositionEdgeCases: + def test_empty_yaml_file_skipped(self, tmp_path: Path) -> None: + """An empty YAML file in a config directory is skipped gracefully.""" + defaults = { + "apiVersion": CURRENT_API_VERSION, + "datasource": {"url": "http://vm:8428/"}, + } + (tmp_path / "_defaults.yaml").write_text(yaml.dump(defaults)) + (tmp_path / "empty.yaml").write_text("") + (tmp_path / "team.yaml").write_text( + yaml.dump( + { + "groups": [ + { + "name": "team_g", + "queries": [ + {"id": "m1", "promql": "up", "detectors": ["MAD"]}, + ], + } + ], + } + ) + ) + cfg = load_config(tmp_path) + assert len(cfg.groups) == 1 + + def test_yml_extension_accepted(self, tmp_path: Path) -> None: + """Files with .yml extension are included alongside .yaml.""" + config = { + "apiVersion": CURRENT_API_VERSION, + "datasource": {"url": "http://vm:8428/"}, + "groups": [ + { + "name": "yml_g", + "queries": [ + {"id": "m1", "promql": "up", "detectors": ["MAD"]}, + ], + } + ], + } + (tmp_path / "config.yml").write_text(yaml.dump(config)) + cfg = load_config(tmp_path) + assert len(cfg.groups) == 1 + assert cfg.groups[0].name == "yml_g" + + def test_fragment_overrides_defaults_settings(self, tmp_path: Path) -> None: + """A fragment can override base settings like window.""" + defaults = { + "apiVersion": CURRENT_API_VERSION, + "datasource": {"url": "http://vm:8428/"}, + "defaults": {"window": "1h"}, + } + fragment = { + "defaults": {"window": "30m"}, + "groups": [ + { + "name": "g1", + "queries": [ + {"id": "m1", "promql": "up", "detectors": ["MAD"]}, + ], + } + ], + } + (tmp_path / "_defaults.yaml").write_text(yaml.dump(defaults)) + (tmp_path / "team.yaml").write_text(yaml.dump(fragment)) + cfg = load_config(tmp_path) + assert cfg.defaults.window == "30m" + + +# ── Migrate directory error handling ───────────────────────────────── + + +class TestMigrateDirectoryErrors: + def test_malformed_yaml_logged_and_skipped(self, tmp_path: Path) -> None: + """A malformed YAML file in a directory is skipped.""" + from promanomaly.migrate import migrate_directory + + good = { + "apiVersion": "promanomaly.io/v1alpha1", + "datasource": {"url": "http://vm:8428/"}, + "groups": [], + } + (tmp_path / "good.yaml").write_text(yaml.dump(good)) + (tmp_path / "bad.yaml").write_text(": invalid: yaml: [") + results = migrate_directory(tmp_path, dry_run=True) + assert len(results) == 1 + assert "good.yaml" in results[0][0] + + def test_non_dict_yaml_skipped(self, tmp_path: Path) -> None: + """A YAML file containing a list (not a dict) is skipped.""" + from promanomaly.migrate import migrate_directory + + good = { + "apiVersion": "promanomaly.io/v1alpha1", + "datasource": {"url": "http://vm:8428/"}, + "groups": [], + } + (tmp_path / "good.yaml").write_text(yaml.dump(good)) + (tmp_path / "list.yaml").write_text("- item1\n- item2\n") + results = migrate_directory(tmp_path, dry_run=True) + assert len(results) == 1 + + +# ── Runner tenant routing ──────────────────────────────────────────── + + +class TestRunnerTenantRouting: + def test_effective_tenant_returns_group_override(self) -> None: + """Group-level tenant overrides datasource-level default.""" + from promanomaly.exporter import OperationalMetrics + from promanomaly.runner import Runner + from promanomaly.source import PromQLSource + from promanomaly.state import SnapshotStore + + cfg = Config.model_validate( + { + **MIN_CONFIG, + "datasource": { + "url": "http://vm:8428/", + "tenant": {"id": "default"}, + }, + "groups": [ + { + "name": "g1", + "tenant": {"id": "override"}, + "queries": [ + {"id": "m1", "promql": "up", "detectors": ["MAD"]}, + ], + }, + { + "name": "g2", + "queries": [ + {"id": "m2", "promql": "up", "detectors": ["MAD"]}, + ], + }, + ], + } + ) + source = PromQLSource("http://vm:8428", 5.0, tenant=cfg.datasource.tenant) + runner = Runner(cfg, source, SnapshotStore(), OperationalMetrics()) + + # g1 has an override tenant. + t1 = runner._effective_tenant(cfg.groups[0]) + assert t1 is not None + assert t1.id == "override" + + # g2 falls back to datasource default. + t2 = runner._effective_tenant(cfg.groups[1]) + assert t2 is not None + assert t2.id == "default" + + def test_source_for_group_returns_main_when_tenant_matches(self) -> None: + """When group tenant matches datasource tenant, use the main source.""" + import asyncio + + from promanomaly.exporter import OperationalMetrics + from promanomaly.runner import Runner + from promanomaly.source import PromQLSource + from promanomaly.state import SnapshotStore + + cfg = Config.model_validate( + { + **MIN_CONFIG, + "datasource": { + "url": "http://vm:8428/", + "tenant": {"id": "shared"}, + }, + "groups": [ + { + "name": "g1", + "queries": [ + {"id": "m1", "promql": "up", "detectors": ["MAD"]}, + ], + }, + ], + } + ) + source = PromQLSource("http://vm:8428", 5.0, tenant=cfg.datasource.tenant) + runner = Runner(cfg, source, SnapshotStore(), OperationalMetrics()) + + async def _check() -> None: + got = await runner._source_for_group(cfg.groups[0]) + assert got is source # Same object — no extra source created. + + asyncio.run(_check()) + + def test_source_for_group_creates_new_for_override(self) -> None: + """When group tenant differs, a new PromQLSource is created.""" + import asyncio + + from promanomaly.exporter import OperationalMetrics + from promanomaly.runner import Runner + from promanomaly.source import PromQLSource + from promanomaly.state import SnapshotStore + + cfg = Config.model_validate( + { + **MIN_CONFIG, + "datasource": { + "url": "http://vm:8428/", + "tenant": {"id": "default"}, + }, + "groups": [ + { + "name": "g1", + "tenant": {"id": "other"}, + "queries": [ + {"id": "m1", "promql": "up", "detectors": ["MAD"]}, + ], + }, + ], + } + ) + source = PromQLSource("http://vm:8428", 5.0, tenant=cfg.datasource.tenant) + runner = Runner(cfg, source, SnapshotStore(), OperationalMetrics()) + + async def _check() -> None: + got = await runner._source_for_group(cfg.groups[0]) + assert got is not source + assert got._tenant is not None + assert got._tenant.id == "other" + assert len(runner._tenant_sources) == 1 + await runner.close_tenant_sources() + + asyncio.run(_check()) + + +# ── Stratified fetcher source parameter ────────────────────────────── + + +class TestStratifiedFetcherSource: + def test_replace_source(self) -> None: + """StratifiedFetcher.replace_source swaps the underlying source.""" + from promanomaly.source import PromQLSource + from promanomaly.stratified_fetcher import StratifiedFetcher + + cfg = Config.model_validate(MIN_CONFIG) + s1 = PromQLSource("http://vm:8428", 5.0) + fetcher = StratifiedFetcher(source=s1, config=cfg) + assert fetcher._source is s1 + + s2 = PromQLSource("http://vm2:8428", 5.0) + fetcher.replace_source(s2) + assert fetcher._source is s2 diff --git a/detector/uv.lock b/detector/uv.lock index 8c8541a..1e5c58f 100644 --- a/detector/uv.lock +++ b/detector/uv.lock @@ -215,6 +215,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "azure-core" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-05-07T23:30:56.357Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/6d/436d69ec484ffc43635b38d7fb7d717d38824671f10e12e77924019ca929/botocore-1.43.18.tar.gz", hash = "sha256:dc8c105351b49688c667065cd5a45fc5b9db982657cefc9e3fbfb9417a55c7df", size = 15424886, upload-time = "2026-05-29T19:33:16.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/5a/35c92c0af1514581031fe66c398b622176b3c928a6d5cf8133c7207e3bd7/botocore-1.43.18-py3-none-any.whl", hash = "sha256:e2610fce16df9f89deab5f3c163430a814e6804034eb95bef8957c8db60b7dbc", size = 15106258, upload-time = "2026-05-29T19:33:11.18Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -224,6 +267,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -470,6 +570,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/c6/c71e82e041c95ffe6a92ac707785500aa2a515a4339c2c7dd67e3c449249/cramjam-2.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:028400d699442d40dbda02f74158c73d05cb76587a12490d0bfedd958fd49188", size = 1713108, upload-time = "2025-07-27T21:24:10.147Z" }, ] +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + [[package]] name = "durationpy" version = "0.10" @@ -597,6 +750,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "google-auth" +version = "2.53.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/ad/ff781329bbbdc0974a098d996e89c9e1f7024262f9e3eec442fbb9ad1ac6/google_auth-2.53.0.tar.gz", hash = "sha256:e7e6aa16f6bee7b2b264830fd04f08087a1d5a836df516251a5d15327b246c9c", size = 335844, upload-time = "2026-05-15T20:53:07.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/c9/db44165ba7c581268c6d46017ef63339110378305062830104fc7fa144cb/google_auth-2.53.0-py3-none-any.whl", hash = "sha256:6e7449917c599b35126a99ec268ec6880301f2fea41dce198fe8fd83ff642b68", size = 246071, upload-time = "2026-05-15T20:53:05.609Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.75.0" @@ -734,6 +900,15 @@ 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 = "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 = "kubernetes" version = "36.0.1" @@ -839,6 +1014,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, ] +[[package]] +name = "msal" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-05-29T19:49:04.335Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -1297,6 +1498,12 @@ dependencies = [ analyze = [ { name = "ruptures" }, ] +aws = [ + { name = "botocore" }, +] +azure = [ + { name = "azure-identity" }, +] dev = [ { name = "cramjam" }, { name = "fakeredis" }, @@ -1311,6 +1518,9 @@ dev = [ { name = "statsmodels" }, { name = "types-pyyaml" }, ] +gcp = [ + { name = "google-auth" }, +] ha = [ { name = "kubernetes" }, ] @@ -1332,11 +1542,14 @@ sinks = [ [package.metadata] requires-dist = [ { name = "apscheduler", specifier = ">=3.10.4" }, + { name = "azure-identity", marker = "extra == 'azure'", specifier = ">=1.19.0" }, + { name = "botocore", marker = "extra == 'aws'", specifier = ">=1.35.0" }, { name = "click", specifier = ">=8.1.7" }, { name = "cramjam", marker = "extra == 'dev'", specifier = ">=2.8.0" }, { name = "cramjam", marker = "extra == 'sinks'", specifier = ">=2.8.0" }, { name = "fakeredis", marker = "extra == 'dev'", specifier = ">=2.26.0" }, { name = "fastapi", specifier = ">=0.115.0" }, + { name = "google-auth", marker = "extra == 'gcp'", specifier = ">=2.35.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "kubernetes", marker = "extra == 'dev'", specifier = ">=31.0.0" }, { name = "kubernetes", marker = "extra == 'ha'", specifier = ">=31.0.0" }, @@ -1365,7 +1578,7 @@ requires-dist = [ { name = "types-pyyaml", marker = "extra == 'dev'" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" }, ] -provides-extras = ["analyze", "ha", "otel", "sinks", "seasonal", "matrixprofile", "dev"] +provides-extras = ["analyze", "ha", "otel", "sinks", "seasonal", "matrixprofile", "aws", "gcp", "azure", "dev"] [[package]] name = "prometheus-client" @@ -1485,6 +1698,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[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.13.4" @@ -1584,6 +1827,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.0.3" diff --git a/docs/cli.md b/docs/cli.md index b84b9a7..0a82a77 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -19,10 +19,11 @@ promanomaly ships with a small, focused set of CLI commands for validation, test | `warmup` | Report which series are still warming up, with an ETA to readiness | | `diagnose` | Flag mis-tuned detectors (never-fires, fires-often, stuck warming, empty) | | `timeline` | Post-incident anomaly timeline over a historical window | +| `migrate-config` | Upgrade config across schema/apiVersion changes | | `adapter` | Run the Kubernetes external/custom metrics adapter | | `adapter-validate` | Validate an adapter config file (schema only) | -All commands accept `--log-level`. `--config` is required only for server, `validate`, `generate-rules`, and `dry-run`. +All commands accept `--log-level`. `--config` is required only for server, `validate`, `generate-rules`, `dry-run`, and `migrate-config`. ## `validate` @@ -278,6 +279,23 @@ promanomaly timeline --from -24h --output json Each event carries: `time`, `time_rfc3339`, `kind` (`anomaly` or `change_point`), `id`, `group`, `detector`, `detector_instance`, and `severity` (when available). Events are sorted by ascending time; ties break by kind then `id` for a deterministic order. +## `migrate-config` + +Upgrades a config file (or directory of fragments) across `apiVersion` changes. Idempotent: running on an already-current config is a no-op. + +```bash +promanomaly migrate-config --config config.yaml # write in place +promanomaly migrate-config --config config.yaml --dry-run # print diff +promanomaly migrate-config --config config.d/ # directory of fragments +``` + +| Flag | Default | Purpose | +|------|---------|---------| +| `--config` | (required) | Path to the YAML config file or directory to migrate | +| `--dry-run` | `false` | Print a unified diff of the changes without writing | + +Migrations are registered per schema revision. Currently supported: `promanomaly.io/v1alpha1` to `promanomaly.io/v1` (apiVersion rewrite only). A migrated config produces identical output metrics to the original. + ## `adapter` Run the Kubernetes external/custom metrics adapter — a separate, stateless process from the detector that re-serves the anomaly metrics already in the TSDB through `external.metrics.k8s.io` / `custom.metrics.k8s.io`, so HPA and KEDA can consume anomaly signal. It is normally deployed via the [`promanomaly-metrics-adapter`](../charts/promanomaly-metrics-adapter/) chart, which generates this config and wires the serving TLS + APIService registrations. See [adapter.md](adapter.md) for the full surface and the autoscaling guard-rails. diff --git a/docs/config-schema.md b/docs/config-schema.md index b3b683e..0ec1116 100644 --- a/docs/config-schema.md +++ b/docs/config-schema.md @@ -53,6 +53,16 @@ The JSON Schema `$id` is stable for the entire v1.x line. Any `# yaml-language-s promforecast follows the exact same stability rules. Shared concepts (`id`, `group`, and other label contracts) are kept in sync via `LABELS_CONTRACT.md`. +## Automated Config Migration + +`promanomaly migrate-config` upgrades config files across `apiVersion` changes mechanically. Supported migrations: + +| From | To | Changes | +|------|----|---------| +| `promanomaly.io/v1alpha1` | `promanomaly.io/v1` | `apiVersion` rewrite only (byte-identical schema) | + +Use `--dry-run` to preview the diff before writing. The migration is idempotent and never changes detection semantics: a migrated config produces identical output metrics to the original. See [operations.md](operations.md#config-schema-migration) for usage. + --- This is the complete, operator-friendly reference. Upgrading inside v1.x never requires editing your config files. \ No newline at end of file diff --git a/docs/operations.md b/docs/operations.md index d9de34c..bab9e6e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -500,6 +500,151 @@ promanomaly diagnose --target http://localhost:9092 Pure analysis, no persisted state; pairs with `backtest` and `calibrate-buckets` to close the tuning loop. See [`cli.md`](cli.md) for the full flag reference. +## Cloud-Managed Datasource Authentication + +When metrics live in a managed Prometheus-compatible backend (Amazon Managed Service for Prometheus, Google Managed Prometheus, Azure Monitor), `datasource.auth.type` supports four cloud-native auth modes alongside the existing `bearer` / `basic` / `mtls`: + +| Type | Backend | Credential source | +|------|---------|-------------------| +| `sigv4` | Amazon Managed Service for Prometheus | Standard AWS credential chain (instance/pod role, env, shared config) | +| `gcp` | Google Managed Prometheus / Cloud Monitoring | Application Default Credentials / Workload Identity | +| `azure` | Azure Monitor managed Prometheus | `DefaultAzureCredential` (Managed Identity, Workload Identity, az-cli) | +| `oauth2` | Any backend with a token endpoint | Client-credentials grant against a configurable `token_url` | + +Each cloud SDK ships as an optional extra so the base image stays slim: + +```bash +pip install promanomaly[aws] # sigv4 +pip install promanomaly[gcp] # gcp +pip install promanomaly[azure] # azure +``` + +A missing SDK surfaces an actionable `ImportError` at boot rather than a cryptic failure from the source layer. Token refresh is transparent to the retry/timeout wrapper. + +### Worked example: Amazon Managed Service for Prometheus + +```yaml +datasource: + url: https://aps-workspaces.us-east-1.amazonaws.com/workspaces/ws-xxxxx/api/v1/ + auth: + type: sigv4 + sigv4: + region: us-east-1 +``` + +### Worked example: Google Managed Prometheus + +```yaml +datasource: + url: https://monitoring.googleapis.com/v1/projects/my-project/location/global/prometheus/ + auth: + type: gcp + gcp: {} # uses Application Default Credentials +``` + +### Worked example: Azure Monitor + +```yaml +datasource: + url: https://my-monitor.eastus.prometheus.monitor.azure.com/ + auth: + type: azure + azure: + client_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + tenant_id: "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" + client_secret_file: /etc/promanomaly-datasource-auth/client_secret +``` + +### Worked example: Generic OAuth2 + +```yaml +datasource: + url: https://metrics.example.com/ + auth: + type: oauth2 + oauth2: + token_url: https://auth.example.com/oauth2/token + client_id: promanomaly + client_secret_file: /etc/promanomaly-datasource-auth/client_secret + scopes: + - metrics:read +``` + +## Multi-Tenant Datasource Support + +For multi-tenant Mimir or Cortex backends, a `tenant` field injects the `X-Scope-OrgID` header (or a custom header) on every PromQL request: + +```yaml +datasource: + url: http://mimir-query-frontend:8080/ + tenant: + id: team-platform + header: X-Scope-OrgID # default; configurable for non-standard backends +``` + +Per-group overrides let a single detector deployment query multiple tenants: + +```yaml +groups: + - name: team_a_errors + tenant: + id: team-a + queries: + - id: error_rate + promql: 'rate(http_requests_total{code=~"5.."}[5m])' + detectors: [MAD] + - name: team_b_errors + tenant: + id: team-b + queries: + - id: error_rate + promql: 'rate(http_requests_total{code=~"5.."}[5m])' + detectors: [MAD] +``` + +The tenant value is **not** emitted as an output label by default (it is a query-side concern, not a signal-side one), keeping the cross-tool `(id, group)` join contract intact. The query-cache key includes the tenant so two tenants' identical PromQL do not collide on a cached result. + +## Config Composition for Multi-Team Ownership + +When many teams contribute detector config under GitOps, splitting the monolithic YAML into per-team fragments avoids merge conflicts. `--config` accepts a directory (or a glob) in addition to a single file: + +```bash +promanomaly --config /etc/promanomaly/config.d/ +promanomaly validate --config /etc/promanomaly/config.d/ +``` + +**Directory layout:** + +``` +config.d/ + _defaults.yaml # datasource, server, safety, defaults, exporter + team_platform.yaml # groups owned by the platform team + team_payments.yaml # groups owned by the payments team +``` + +`_defaults.yaml` carries the base settings (`datasource`, `server`, `safety`, `defaults`, `exporter`). Every other `*.yaml` fragment's `groups:` are merged in deterministic filename-sorted order. Duplicate group names across fragments are a hard validation error (not a silent last-wins). + +`validate`, `validate --probe`, and `--estimate-cost` all accept the directory form so CI gates work unchanged. `anomaly_config_hash` reflects the merged document. + +The Helm chart supports this via `existingConfigMap` pointing at a ConfigMap you manage out-of-band (Argo CD app-of-apps, Flux Kustomization). + +## Config Schema Migration + +`promanomaly migrate-config` upgrades config files across schema/apiVersion changes: + +```bash +# Preview changes without writing +promanomaly migrate-config --config config.yaml --dry-run + +# Apply migration in place +promanomaly migrate-config --config config.yaml + +# Migrate all files in a directory +promanomaly migrate-config --config config.d/ +``` + +Idempotent: running on an already-current config is a no-op. Migrations are registered per schema revision so the path composes across multiple versions. + ## Metadata-Aware Validation Lint `promanomaly validate --lint-metadata` queries the datasource's `/api/v1/metadata` and warns when a query feeds a raw counter to a detector without a `rate()` / `increase()` wrapper — a monotonic counter scored directly produces nonsense. Lint only (it never rewrites the query); under `--strict` any finding fails CI, and it composes with `--probe` / `--estimate-cost`. The same hint surfaces live in `inspect` and `promanomaly top --lint`. Absent metadata is treated as "no opinion", so the lint never manufactures a false positive from a metric the TSDB carries no type for. See [`cli.md`](cli.md). From 00c11aa8fb6cd121b6eed99e2d8999a87088397d Mon Sep 17 00:00:00 2001 From: Werner Dijkerman Date: Sat, 30 May 2026 22:40:49 +0200 Subject: [PATCH 2/3] Various small improvements everywhere --- Makefile | 4 - charts/promanomaly/values.schema.json | 36 +++++ detector/src/promanomaly/anomalies.py | 73 +++++---- detector/src/promanomaly/cache.py | 8 + detector/src/promanomaly/cli/_analyze.py | 5 +- detector/src/promanomaly/cli/_calibrate.py | 16 +- detector/src/promanomaly/cli/_cost.py | 4 +- detector/src/promanomaly/cli/_top.py | 4 +- detector/src/promanomaly/cohort_context.py | 2 +- detector/src/promanomaly/config.py | 4 + detector/src/promanomaly/detectors/base.py | 1 + detector/src/promanomaly/detectors/hampel.py | 2 + .../detectors/histogram_distribution_shift.py | 10 +- detector/src/promanomaly/detectors/iqr.py | 1 + detector/src/promanomaly/detectors/mad.py | 1 + .../promanomaly/detectors/matrix_profile.py | 10 +- .../detectors/seasonal_hybrid_esd.py | 8 +- .../src/promanomaly/detectors/zscore_ewma.py | 2 + detector/src/promanomaly/discovery.py | 10 +- detector/src/promanomaly/source.py | 144 ++++++++---------- .../src/promanomaly/state/shared_snapshot.py | 7 + detector/src/promanomaly/telemetry.py | 8 +- detector/tests/test_blast_radius.py | 30 ++++ 23 files changed, 248 insertions(+), 142 deletions(-) diff --git a/Makefile b/Makefile index 87f8878..1c41883 100644 --- a/Makefile +++ b/Makefile @@ -145,10 +145,6 @@ dev-up-load: ## Start the full demo stack: minimum + http-demo + loadgen + Grafa dev-down: ## Stop the local dev stack (any profile). docker compose -f docker-compose.dev.yml --profile load down -v -.PHONY: load-test -load-test: ## Synthetic high-cardinality run for safety-control validation. - cd $(DETECTOR) && $(UV) run python -m promanomaly.scripts.load_test - # ---------- Aggregate ---------- .PHONY: ci diff --git a/charts/promanomaly/values.schema.json b/charts/promanomaly/values.schema.json index e4bd76c..263dfc7 100644 --- a/charts/promanomaly/values.schema.json +++ b/charts/promanomaly/values.schema.json @@ -92,6 +92,7 @@ "properties": { "listen": {"type": "string"}, "refresh_interval": {"type": "string"}, + "ready_endpoint": {"type": "boolean"}, "expose_warmup_endpoint": {"type": "boolean"}, "selftest": { "type": "object", @@ -100,6 +101,20 @@ "detector": {"type": "string"}, "threshold": {"type": "number", "exclusiveMinimum": 0} } + }, + "reload": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "watch_configmap": {"type": "boolean"}, + "auth": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["none", "bearer", "mtls"]}, + "existingSecret": {"type": "string"} + } + } + } } } }, @@ -109,8 +124,11 @@ "max_series_per_query": {"type": "integer", "minimum": 1}, "max_total_series": {"type": "integer", "minimum": 1}, "series_overflow": {"type": "string", "enum": ["drop_lowest_priority", "reject", "sample"]}, + "detect_timeout": {"type": "string"}, + "query_timeout": {"type": "string"}, "on_source_failure": {"type": "string", "enum": ["serve_stale", "drop_scores", "fail_ready"]}, "fail_ready_after": {"type": "integer", "minimum": 1}, + "max_stratified_cache_entries": {"type": "integer", "minimum": 1}, "query_cache": { "type": "object", "properties": { @@ -139,9 +157,27 @@ "defaults": { "type": "object", "properties": { + "window": {"type": "string"}, + "step": {"type": "string"}, + "min_points": {"type": "integer", "minimum": 1}, "warmup_policy": { "type": "string", "enum": ["emit_warming_up", "suppress", "emit_with_flag"] + }, + "min_abs_delta": {"type": "number", "minimum": 0}, + "min_relative_delta": {"type": "number", "minimum": 0}, + "emit_baseline": {"type": "boolean"}, + "emit_change_points": {"type": "boolean"}, + "emit_baseline_stability": {"type": "boolean"}, + "emit_duration": {"type": "boolean"}, + "emit_severity": {"type": "boolean"}, + "emit_anomaly_type": {"type": "boolean"}, + "density_by": {"type": "array", "items": {"type": "string"}}, + "alert_thresholds": { + "type": "object", + "properties": { + "score": {"type": "number", "exclusiveMinimum": 0} + } } } }, diff --git a/detector/src/promanomaly/anomalies.py b/detector/src/promanomaly/anomalies.py index b494aaa..dd24323 100644 --- a/detector/src/promanomaly/anomalies.py +++ b/detector/src/promanomaly/anomalies.py @@ -237,22 +237,30 @@ def collect_blast_radius(store: SnapshotStore) -> list[BlastRadiusRow]: # breach is read from the same series that is firing. durations: dict[tuple[tuple[str, str], ...], float] = {} + # Deferred outside-threshold samples: we need durations (populated in + # the same pass) before computing max breach, so these are buffered + # and replayed after the single pass over all snapshots. + deferred_outside: list[tuple[dict[str, str], float]] = [] + + # Single pass over all snapshots — collects durations, rollup gauges, + # warming-up markers, and outside-threshold samples in one traversal + # instead of three separate loops. for snap in store.all_snapshots(): for sample in snap.samples: - labels = dict(sample.labels) - grp = labels.get("group", "") metric = sample.metric if metric == "anomaly_duration_seconds": + labels = dict(sample.labels) durations[_detector_key(labels)] = sample.value - elif metric == "anomaly_active_series" and set(labels) == {"group"}: - group_active[grp] = sample.value - elif metric == "anomaly_density" and set(labels) == {"group"}: - group_density[grp] = sample.value - - for snap in store.all_snapshots(): - for sample in snap.samples: - labels = dict(sample.labels) - if sample.metric == "anomaly_warming_up" and sample.value >= 1.0: + elif metric == "anomaly_active_series": + labels = dict(sample.labels) + if set(labels) == {"group"}: + group_active[labels.get("group", "")] = sample.value + elif metric == "anomaly_density": + labels = dict(sample.labels) + if set(labels) == {"group"}: + group_density[labels.get("group", "")] = sample.value + elif metric == "anomaly_warming_up" and sample.value >= 1.0: + labels = dict(sample.labels) grp = labels.get("group", "") ident = _series_identity(labels) group_warming.setdefault(grp, set()).add(ident) @@ -260,29 +268,30 @@ def collect_blast_radius(store: SnapshotStore) -> list[BlastRadiusRow]: if axis is not None: key = cohort_key_for(labels, axis) cohort_warming.setdefault((grp, axis, key), set()).add(ident) - - for snap in store.all_snapshots(): - for sample in snap.samples: - if sample.metric != "anomaly_outside_threshold": - continue - labels = dict(sample.labels) - grp = labels.get("group", "") - ident = _series_identity(labels) - group_total.setdefault(grp, set()).add(ident) - firing_now = sample.value >= 1.0 + elif metric == "anomaly_outside_threshold": + labels = dict(sample.labels) + deferred_outside.append((labels, sample.value)) + + # Replay the buffered outside-threshold samples now that durations + # are fully populated. + for labels, value in deferred_outside: + grp = labels.get("group", "") + ident = _series_identity(labels) + group_total.setdefault(grp, set()).add(ident) + firing_now = value >= 1.0 + if firing_now: + group_firing.setdefault(grp, set()).add(ident) + breach = durations.get(_detector_key(labels), 0.0) + group_max_duration[grp] = max(group_max_duration.get(grp, 0.0), breach) + axis = labels.get("cohort_label") + if axis is not None: + key = cohort_key_for(labels, axis) + cid = (grp, axis, key) + cohort_total.setdefault(cid, set()).add(ident) if firing_now: - group_firing.setdefault(grp, set()).add(ident) + cohort_firing.setdefault(cid, set()).add(ident) breach = durations.get(_detector_key(labels), 0.0) - group_max_duration[grp] = max(group_max_duration.get(grp, 0.0), breach) - axis = labels.get("cohort_label") - if axis is not None: - key = cohort_key_for(labels, axis) - cid = (grp, axis, key) - cohort_total.setdefault(cid, set()).add(ident) - if firing_now: - cohort_firing.setdefault(cid, set()).add(ident) - breach = durations.get(_detector_key(labels), 0.0) - cohort_max_duration[cid] = max(cohort_max_duration.get(cid, 0.0), breach) + cohort_max_duration[cid] = max(cohort_max_duration.get(cid, 0.0), breach) rows: list[BlastRadiusRow] = [] diff --git a/detector/src/promanomaly/cache.py b/detector/src/promanomaly/cache.py index f9a99c0..7cc1e48 100644 --- a/detector/src/promanomaly/cache.py +++ b/detector/src/promanomaly/cache.py @@ -23,12 +23,15 @@ import contextlib import itertools +import logging import threading import time from collections import OrderedDict from dataclasses import dataclass from typing import Any +logger = logging.getLogger(__name__) + # Monotonic counter handed out to each cache instance on construction. # The exporter watches ``cache.version`` to detect reload-driven cache # swaps without relying on ``id()`` (which Python is free to recycle @@ -194,6 +197,7 @@ def __len__(self) -> int: try: count = sum(1 for _ in self._iter_keys()) except Exception: + logger.debug("redis_cache_size_scan_failed", exc_info=True) count = self._cached_size with self._lock: self._cached_size = count @@ -207,6 +211,7 @@ def clear(self) -> None: except Exception: # Best-effort. A Redis outage during clear is non-fatal — # entries TTL out anyway. + logger.debug("redis_cache_clear_failed", exc_info=True) return def get(self, key: K, now: float) -> V | None: @@ -215,6 +220,7 @@ def get(self, key: K, now: float) -> V | None: try: raw = self._client.get(redis_key) except Exception: + logger.debug("redis_cache_get_failed", exc_info=True) with self._lock: self.stats.misses += 1 return None @@ -250,10 +256,12 @@ def put(self, key: K, value: V, now: float) -> None: # Refusing to cache an un-encodable value is preferable to # poisoning Redis with garbage that every reader has to # discover and drop. + logger.debug("redis_cache_encode_failed", exc_info=True) return try: self._client.set(redis_key, payload, ex=self._ttl) except Exception: + logger.debug("redis_cache_put_failed", exc_info=True) return def _encode_key(self, key: K) -> str: diff --git a/detector/src/promanomaly/cli/_analyze.py b/detector/src/promanomaly/cli/_analyze.py index f36535b..087df12 100644 --- a/detector/src/promanomaly/cli/_analyze.py +++ b/detector/src/promanomaly/cli/_analyze.py @@ -80,9 +80,12 @@ def _run_offline_change_points( # Score: absolute mean shift across the break, normalised by # the in-segment standard deviation. Approximate but # interpretable; lets the caller rank breaks by magnitude. + # Use up to 30 samples on each side, but accept whatever is + # available near array edges so edge breakpoints still get a + # meaningful score instead of being silently zeroed out. left = values[max(0, bp - 30) : bp] right = values[bp : min(values.size, bp + 30)] - if left.size == 0 or right.size == 0: + if left.size < 2 or right.size < 2: score = 0.0 else: pooled_std = float(np.std(np.concatenate([left, right]))) diff --git a/detector/src/promanomaly/cli/_calibrate.py b/detector/src/promanomaly/cli/_calibrate.py index 48339dd..cf1fa48 100644 --- a/detector/src/promanomaly/cli/_calibrate.py +++ b/detector/src/promanomaly/cli/_calibrate.py @@ -60,12 +60,15 @@ def _analyse_bucket_structure( } centred = aggregated - float(np.mean(aggregated)) - variance = float(np.dot(centred, centred)) + # Lag-0 autocovariance (== variance * N). The ACF at lag k is the + # ratio autocovariance(k) / autocovariance(0), so dividing each + # lag's dot product by this value produces correlations in [-1, 1]. + c0 = float(np.dot(centred, centred)) # Float noise on a strictly-constant input still produces a tiny - # positive variance; treat anything below numerical noise as - # constant so the recommendation surface doesn't claim periodicity - # on a flat signal. - if variance <= 1e-9: + # positive c0; treat anything below numerical noise as constant so + # the recommendation surface doesn't claim periodicity on a flat + # signal. + if c0 <= 1e-9: return { "status": "constant_signal", "samples": int(aggregated.size), @@ -76,8 +79,9 @@ def _analyse_bucket_structure( # of one week if the window doesn't cover it. max_lag = min(aggregated.size // 2, int(8 * 86400.0 / step_seconds)) acf = np.zeros(max_lag, dtype=float) + acf[0] = 1.0 # ACF at lag 0 is always 1.0 by definition for lag in range(1, max_lag): - acf[lag] = float(np.dot(centred[lag:], centred[:-lag])) / variance + acf[lag] = float(np.dot(centred[lag:], centred[:-lag])) / c0 # Find local maxima in the ACF — those are candidate periods. Skip # the very-short-lag noise (< step minutes) which is autocorrelation diff --git a/detector/src/promanomaly/cli/_cost.py b/detector/src/promanomaly/cli/_cost.py index ee3dc6f..df014d4 100644 --- a/detector/src/promanomaly/cli/_cost.py +++ b/detector/src/promanomaly/cli/_cost.py @@ -310,8 +310,8 @@ def _scoring_window_seconds(merged: dict[str, Any], default_window: float) -> fl def _nlogn(points: int) -> float: """``n log2 n`` with a floor so tiny / zero windows don't blow up.""" - n = max(points, 1) - return n * math.log2(n + 1) + n = max(points, 2) + return n * math.log2(n) __all__ = [ diff --git a/detector/src/promanomaly/cli/_top.py b/detector/src/promanomaly/cli/_top.py index 17dd746..bd46211 100644 --- a/detector/src/promanomaly/cli/_top.py +++ b/detector/src/promanomaly/cli/_top.py @@ -19,7 +19,9 @@ def _format_duration(seconds: float) -> str: parts.append(f"{hours}h") if minutes: parts.append(f"{minutes}m") - if secs and not hours: + if secs and not hours and not minutes: + # Show seconds only when no larger unit is present, keeping the + # output compact ("1h5m" not "1h5m30s", "45s" not "0h0m45s"). parts.append(f"{secs}s") return "".join(parts) or "0s" diff --git a/detector/src/promanomaly/cohort_context.py b/detector/src/promanomaly/cohort_context.py index 92a4031..8c61385 100644 --- a/detector/src/promanomaly/cohort_context.py +++ b/detector/src/promanomaly/cohort_context.py @@ -102,7 +102,7 @@ def build_cohort_context( # Partition series by cohort key. buckets: dict[tuple[tuple[str, str], ...], list[np.ndarray[Any, Any]]] = {} for series in series_list: - if not series.samples.shape[0]: + if series.samples.empty: continue key = cohort_key_for(series.labels, cohort_label) values = series.samples["y"].to_numpy(dtype=float, copy=False) diff --git a/detector/src/promanomaly/config.py b/detector/src/promanomaly/config.py index 7cf3cca..c6a6d88 100644 --- a/detector/src/promanomaly/config.py +++ b/detector/src/promanomaly/config.py @@ -286,6 +286,10 @@ class ReloadAuthConfig(_ModelBase): class ReloadConfig(_ModelBase): enabled: bool = True + # When True (the default), the detector watches the mounted ConfigMap + # for changes and triggers a reload automatically. Disabling this + # restricts reloads to explicit ``/-/reload`` calls or SIGHUP. + watch_configmap: bool = True auth: ReloadAuthConfig = Field(default_factory=ReloadAuthConfig) diff --git a/detector/src/promanomaly/detectors/base.py b/detector/src/promanomaly/detectors/base.py index 904f482..5bd5ad1 100644 --- a/detector/src/promanomaly/detectors/base.py +++ b/detector/src/promanomaly/detectors/base.py @@ -25,6 +25,7 @@ "baseline_lower", "baseline_upper", "change_point", + "anomaly_type", ) diff --git a/detector/src/promanomaly/detectors/hampel.py b/detector/src/promanomaly/detectors/hampel.py index 03f6d51..0ab7e15 100644 --- a/detector/src/promanomaly/detectors/hampel.py +++ b/detector/src/promanomaly/detectors/hampel.py @@ -59,6 +59,7 @@ class Hampel: type_="int", default=3, doc="Half-width of the local sub-window; sub-window spans 2*t0 + 1 points.", + minimum=1, ), ParamSpec( name="recursive_trim", @@ -78,6 +79,7 @@ class Hampel: "Local-MAD-units cutoff used by recursive_trim to drop " "contaminating points from the second-pass baseline." ), + minimum=0.0, ), ) diff --git a/detector/src/promanomaly/detectors/histogram_distribution_shift.py b/detector/src/promanomaly/detectors/histogram_distribution_shift.py index 0c69e39..f439418 100644 --- a/detector/src/promanomaly/detectors/histogram_distribution_shift.py +++ b/detector/src/promanomaly/detectors/histogram_distribution_shift.py @@ -159,16 +159,18 @@ def _score_histogram( return 0.0 split_idx = max(2, int(unique_ts.size * (1.0 - split))) - baseline_ts = set(unique_ts[:split_idx].tolist()) - recent_ts = set(unique_ts[split_idx:].tolist()) + baseline_ts = unique_ts[:split_idx] + recent_ts = unique_ts[split_idx:] # Aggregate bucket counts per window. unique_le = np.sort(np.unique(le_vals[np.isfinite(le_vals)])) if unique_le.size < 2: return 0.0 - def _aggregate_cdf(ts_set: set[float]) -> np.ndarray[Any, Any]: - mask = np.array([t in ts_set for t in timestamps]) + def _aggregate_cdf(ts_arr: np.ndarray[Any, Any]) -> np.ndarray[Any, Any]: + # Vectorised membership test — O(n) via numpy hashing instead + # of the O(n*m) Python list comprehension it replaces. + mask = np.isin(timestamps, ts_arr) agg = np.zeros(unique_le.size) for i, le in enumerate(unique_le): bucket_mask = mask & (le_vals == le) diff --git a/detector/src/promanomaly/detectors/iqr.py b/detector/src/promanomaly/detectors/iqr.py index ff8bdfa..de6a0ec 100644 --- a/detector/src/promanomaly/detectors/iqr.py +++ b/detector/src/promanomaly/detectors/iqr.py @@ -45,6 +45,7 @@ class IQR: type_="float", default=1.5, doc="Multiplier for the IQR band. 1.5 = classical outlier; 3.0 = far outlier.", + minimum=0.0, ), ) diff --git a/detector/src/promanomaly/detectors/mad.py b/detector/src/promanomaly/detectors/mad.py index d8d0316..3d17611 100644 --- a/detector/src/promanomaly/detectors/mad.py +++ b/detector/src/promanomaly/detectors/mad.py @@ -100,6 +100,7 @@ class MAD: "MAD-units cutoff used by recursive_trim to drop " "contaminating points from the second-pass baseline." ), + minimum=0.0, ), ) diff --git a/detector/src/promanomaly/detectors/matrix_profile.py b/detector/src/promanomaly/detectors/matrix_profile.py index 39ec152..5864515 100644 --- a/detector/src/promanomaly/detectors/matrix_profile.py +++ b/detector/src/promanomaly/detectors/matrix_profile.py @@ -95,11 +95,15 @@ def fit_score( ] ) - # Fill NaNs for stumpy (it can't handle them). - clean = values.copy() - nan_mask = ~np.isfinite(clean) + # Fill NaNs for stumpy (it can't handle them). Copy only when + # NaN replacement is needed; the source array belongs to the + # caller's DataFrame and must not be mutated in place. + nan_mask = ~np.isfinite(values) if nan_mask.any(): + clean = values.copy() clean[nan_mask] = float(np.nanmedian(values)) + else: + clean = values profile = stumpy.stump(clean, m) # profile[:, 0] contains the nearest-neighbour distances. diff --git a/detector/src/promanomaly/detectors/seasonal_hybrid_esd.py b/detector/src/promanomaly/detectors/seasonal_hybrid_esd.py index 1b3108a..ca2b339 100644 --- a/detector/src/promanomaly/detectors/seasonal_hybrid_esd.py +++ b/detector/src/promanomaly/detectors/seasonal_hybrid_esd.py @@ -81,8 +81,12 @@ def _generalized_esd( latest_flagged = False latest_score = 0.0 - working = clean.copy() - working_indices = indices.copy() + # ``clean`` is already a fresh array from the isfinite filter; + # ``indices`` is from np.arange — both can be used directly without + # an extra copy since neither is referenced after the loop mutates + # ``working`` / ``working_indices`` via boolean masking. + working = clean + working_indices = indices for k in range(1, min(max_anomalies, n - 2) + 1): median = float(np.median(working)) diff --git a/detector/src/promanomaly/detectors/zscore_ewma.py b/detector/src/promanomaly/detectors/zscore_ewma.py index cc9aaff..9bf4324 100644 --- a/detector/src/promanomaly/detectors/zscore_ewma.py +++ b/detector/src/promanomaly/detectors/zscore_ewma.py @@ -68,6 +68,8 @@ class ZScoreEWMA: "EWMA smoothing factor in (0, 1]. Smaller tracks the mean more slowly; " "larger adapts faster but lets recent anomalies pollute the baseline." ), + minimum=0.0, + maximum=1.0, ), ) diff --git a/detector/src/promanomaly/discovery.py b/detector/src/promanomaly/discovery.py index 5bac428..d5cf8b5 100644 --- a/detector/src/promanomaly/discovery.py +++ b/detector/src/promanomaly/discovery.py @@ -138,7 +138,13 @@ def expand_query(query: QueryConfig, value_sets: dict[str, list[str]]) -> list[E # Don't raise — keep the run going so a stray entry doesn't # take production offline mid-rollout — but the warning surfaces # in tests and logs. - pass + import warnings + + warnings.warn( + f"query {query.id!r}: discover variables {sorted(extra)!r} " + "are declared but never referenced in id or promql templates", + stacklevel=2, + ) ordered_vars = [d.variable for d in query.discover] bindings_list: list[list[str]] = [] for name in ordered_vars: @@ -148,7 +154,7 @@ def expand_query(query: QueryConfig, value_sets: dict[str, list[str]]) -> list[E bindings_list.append(values) out: list[ExpandedQuery] = [] for combo in itertools.product(*bindings_list): - binding = dict(zip(ordered_vars, combo, strict=False)) + binding = dict(zip(ordered_vars, combo, strict=True)) rendered_id = _render(query.id, binding) if not _PROM_NAME_RE.match(rendered_id): raise DiscoveryError( diff --git a/detector/src/promanomaly/source.py b/detector/src/promanomaly/source.py index 484565a..5dfac3a 100644 --- a/detector/src/promanomaly/source.py +++ b/detector/src/promanomaly/source.py @@ -141,6 +141,62 @@ async def close(self) -> None: finally: self._client = None + async def _request_with_retry( + self, + path: str, + params: dict[str, str] | None = None, + ) -> httpx.Response: + """Issue a GET with one transparent retry on transient transport errors. + + The common case is a keepalive connection the TSDB (or an + intermediary) closed while idle between scrapes: the next request + on that pooled connection fails immediately and a fresh connection + succeeds. A single retry absorbs that blip instead of degrading the + whole group to serve_stale and tripping AnomalySourceFailing on a + harmless hiccup. Timeouts are NOT retried — under a slow TSDB a + retry only compounds load. HTTP error envelopes (4xx/5xx) are real + server responses, not transport failures, so they are checked after + the retry loop. + """ + if self._client is None: + await self.start() + assert self._client is not None + + response: httpx.Response | None = None + transport_exc: httpx.HTTPError | None = None + for _attempt in range(2): + try: + response = await self._client.get(path, params=params) + break + except httpx.TimeoutException as exc: + raise SourceQueryError("timeout", _exc_detail(exc)) from exc + except httpx.HTTPError as exc: + transport_exc = exc + if response is None: + assert transport_exc is not None + raise SourceQueryError("transport_error", _exc_detail(transport_exc)) from transport_exc + + if response.status_code >= 500: + raise SourceQueryError("server_error", f"HTTP {response.status_code}") + if response.status_code >= 400: + raise SourceQueryError( + "client_error", + f"HTTP {response.status_code}: {response.text[:200]}", + ) + return response + + @staticmethod + def _parse_json_payload(response: httpx.Response) -> dict[str, Any]: + """Parse a Prometheus-API JSON envelope, raising on failure.""" + try: + payload: dict[str, Any] = response.json() + except ValueError as exc: + raise SourceQueryError("invalid_response", str(exc)) from exc + if payload.get("status") != "success": + err = payload.get("error") or "unknown" + raise SourceQueryError("query_error", str(err)) + return payload + async def range_query( self, promql: str, @@ -189,10 +245,6 @@ async def range_query( if cached is not None: return cached - if self._client is None: - await self.start() - assert self._client is not None - start = end - window_seconds params = { "query": promql, @@ -200,47 +252,8 @@ async def range_query( "end": f"{end:.3f}", "step": f"{step_seconds:.3f}", } - # One transparent retry on a transient transport error. The common - # case is a keepalive connection the TSDB (or an intermediary) - # closed while idle between scrapes: the next request on that pooled - # connection fails immediately and a fresh connection succeeds. A - # single retry absorbs that blip instead of degrading the whole - # group to serve_stale and tripping AnomalySourceFailing on a - # harmless hiccup. Timeouts are NOT retried — under a slow TSDB a - # retry only compounds load, and the per-query timeout already - # bounds the wait. HTTP error envelopes (4xx/5xx) are real server - # responses handled below, not transport failures, so they never - # reach this retry. - response: httpx.Response | None = None - transport_exc: httpx.HTTPError | None = None - for _attempt in range(2): - try: - response = await self._client.get("/api/v1/query_range", params=params) - break - except httpx.TimeoutException as exc: - raise SourceQueryError("timeout", _exc_detail(exc)) from exc - except httpx.HTTPError as exc: - transport_exc = exc - if response is None: - assert transport_exc is not None - raise SourceQueryError("transport_error", _exc_detail(transport_exc)) from transport_exc - - if response.status_code >= 500: - raise SourceQueryError("server_error", f"HTTP {response.status_code}") - if response.status_code >= 400: - raise SourceQueryError( - "client_error", - f"HTTP {response.status_code}: {response.text[:200]}", - ) - - try: - payload = response.json() - except ValueError as exc: - raise SourceQueryError("invalid_response", str(exc)) from exc - - if payload.get("status") != "success": - err = payload.get("error") or "unknown" - raise SourceQueryError("query_error", str(err)) + response = await self._request_with_retry("/api/v1/query_range", params) + payload = self._parse_json_payload(response) data = payload.get("data") or {} result = data.get("result") or [] @@ -274,27 +287,8 @@ async def instant_query(self, promql: str) -> list[tuple[dict[str, str], float]] dropped. Raises :class:`SourceQueryError` on a TSDB-side failure so the caller can report it. """ - if self._client is None: - await self.start() - assert self._client is not None - try: - response = await self._client.get("/api/v1/query", params={"query": promql}) - except httpx.TimeoutException as exc: - raise SourceQueryError("timeout", _exc_detail(exc)) from exc - except httpx.HTTPError as exc: - raise SourceQueryError("transport_error", _exc_detail(exc)) from exc - if response.status_code >= 500: - raise SourceQueryError("server_error", f"HTTP {response.status_code}") - if response.status_code >= 400: - raise SourceQueryError( - "client_error", f"HTTP {response.status_code}: {response.text[:200]}" - ) - try: - payload = response.json() - except ValueError as exc: - raise SourceQueryError("invalid_response", str(exc)) from exc - if payload.get("status") != "success": - raise SourceQueryError("query_error", str(payload.get("error") or "unknown")) + response = await self._request_with_retry("/api/v1/query", {"query": promql}) + payload = self._parse_json_payload(response) data = payload.get("data") or {} result = data.get("result") or [] rows: list[tuple[dict[str, str], float]] = [] @@ -329,23 +323,9 @@ async def metric_metadata(self) -> dict[str, str]: Mimir / Thanos are absorbed: a malformed envelope yields ``{}`` rather than crashing the lint. """ - if self._client is None: - await self.start() - assert self._client is not None - try: - response = await self._client.get("/api/v1/metadata") - except httpx.TimeoutException as exc: - raise SourceQueryError("timeout", _exc_detail(exc)) from exc - except httpx.HTTPError as exc: - raise SourceQueryError("transport_error", _exc_detail(exc)) from exc - if response.status_code >= 500: - raise SourceQueryError("server_error", f"HTTP {response.status_code}") - if response.status_code >= 400: - raise SourceQueryError( - "client_error", f"HTTP {response.status_code}: {response.text[:200]}" - ) + response = await self._request_with_retry("/api/v1/metadata") try: - payload = response.json() + payload: dict[str, Any] = response.json() except ValueError as exc: raise SourceQueryError("invalid_response", str(exc)) from exc data = payload.get("data") if isinstance(payload, dict) else None diff --git a/detector/src/promanomaly/state/shared_snapshot.py b/detector/src/promanomaly/state/shared_snapshot.py index 7d3fb73..13a5332 100644 --- a/detector/src/promanomaly/state/shared_snapshot.py +++ b/detector/src/promanomaly/state/shared_snapshot.py @@ -15,11 +15,14 @@ from __future__ import annotations +import logging import time from typing import Any from .snapshot import GroupSnapshot, SnapshotStore +logger = logging.getLogger(__name__) + class SharedSnapshotCache: """Redis-backed snapshot cache shared between leader and followers.""" @@ -57,10 +60,12 @@ def publish(self, snapshot: GroupSnapshot) -> None: try: payload = encode_snapshot(snapshot) except Exception: + logger.debug("snapshot_encode_failed", exc_info=True) return try: self._client.set(self._key_for(snapshot.group), payload, ex=self._ttl) except Exception: + logger.debug("snapshot_publish_failed", exc_info=True) return def load_all(self) -> list[GroupSnapshot]: @@ -81,6 +86,7 @@ def load_all(self) -> list[GroupSnapshot]: return [] raws = self._client.mget(keys) except Exception: + logger.debug("snapshot_load_all_failed", exc_info=True) return [] results: list[GroupSnapshot] = [] for raw in raws: @@ -95,6 +101,7 @@ def remove(self, group: str) -> None: try: self._client.delete(self._key_for(group)) except Exception: + logger.debug("snapshot_remove_failed", exc_info=True) return def _key_for(self, group: str) -> str: diff --git a/detector/src/promanomaly/telemetry.py b/detector/src/promanomaly/telemetry.py index 9d4afa7..3041386 100644 --- a/detector/src/promanomaly/telemetry.py +++ b/detector/src/promanomaly/telemetry.py @@ -19,11 +19,13 @@ from __future__ import annotations -import contextlib +import logging from collections.abc import Iterator from contextlib import contextmanager from typing import TYPE_CHECKING, Any +logger = logging.getLogger(__name__) + if TYPE_CHECKING: # pragma: no cover - import only for type hints from .config import TelemetryConfig @@ -135,8 +137,10 @@ def shutdown(self) -> None: # Shutdown failures are observability-side and must not block the # rest of the application shutdown sequence — tracing is # observability, not control flow. - with contextlib.suppress(Exception): # pragma: no cover - defensive + try: # pragma: no cover - defensive self._provider.shutdown() + except Exception: # pragma: no cover - defensive + logger.debug("telemetry_provider_shutdown_failed", exc_info=True) @contextmanager def span(self, name: str, **attributes: Any) -> Iterator[Any]: diff --git a/detector/tests/test_blast_radius.py b/detector/tests/test_blast_radius.py index c435fcc..9cf2ae7 100644 --- a/detector/tests/test_blast_radius.py +++ b/detector/tests/test_blast_radius.py @@ -106,6 +106,36 @@ def test_cohort_rollup_groups_by_cohort_identity() -> None: assert cohort.cohort_key.get("id") == "x" +def test_empty_group_produces_no_rows() -> None: + """A group with zero scored series produces no blast-radius rows.""" + store = SnapshotStore() + store.write(GroupSnapshot(group="empty", timestamp=time.time(), samples=[])) + rows = collect_blast_radius(store) + assert rows == [] + + +def test_all_warming_up_produces_no_rows() -> None: + """A group where every series is warming up has no scored members. + + The density rollup excludes warming-up series, so the blast-radius + rollup should mirror that and report nothing rather than 0/0. + """ + samples = [ + _sample("anomaly_outside_threshold", 0.0, id="a", group="g", detector="MAD"), + _sample("anomaly_warming_up", 1.0, id="a", group="g", detector="MAD"), + _sample("anomaly_outside_threshold", 0.0, id="b", group="g", detector="MAD"), + _sample("anomaly_warming_up", 1.0, id="b", group="g", detector="MAD"), + ] + rows = collect_blast_radius(_store_with("g", samples)) + group_rows = [r for r in rows if r.scope == "group"] + # All members excluded by warming filter → total=0 → no row emitted, + # consistent with the density rollup's "no fleet to measure" semantics. + assert len(group_rows) == 1 + assert group_rows[0].total == 0 + assert group_rows[0].firing == 0 + assert group_rows[0].fraction == 0.0 + + def test_rows_sorted_by_fraction_then_firing() -> None: samples = [ # group g1: 1 of 4 firing -> fraction 0.25 From f49d9ebf918cad05a720ce18673df58377ae2ef5 Mon Sep 17 00:00:00 2001 From: Werner Dijkerman Date: Sat, 30 May 2026 22:45:17 +0200 Subject: [PATCH 3/3] fix lint issue --- detector/src/promanomaly/httpauth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/detector/src/promanomaly/httpauth.py b/detector/src/promanomaly/httpauth.py index 033a8a0..5f65d8b 100644 --- a/detector/src/promanomaly/httpauth.py +++ b/detector/src/promanomaly/httpauth.py @@ -132,7 +132,7 @@ def _do_init(self) -> None: ) from exc gcp_cfg = self._auth.gcp if gcp_cfg and gcp_cfg.credentials_file: - self._gcp_credentials, _ = google.auth.load_credentials_from_file( + self._gcp_credentials, _ = google.auth.load_credentials_from_file( # type: ignore[no-untyped-call] gcp_cfg.credentials_file, scopes=["https://www.googleapis.com/auth/monitoring.read"], )