diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bfcc80..4ee514d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to this project will be documented in this file. +## [v0.0.5] - 2026-04-20 + +### Added + +- Added notifier mutation-testing configuration for `mutmut` and selective notifier test execution. + +### Changed + +- Added Apache license header blocks to notifier regression and middleware tests. + +### Fixed + +- Cleaned up notifier test assertions and deterministic state handling in edge-case regression tests. +- Hardened URL safety parsing to satisfy strict typing without behavior drift, and restored full notifier `mypy`/`pytest` green status at 100% coverage. +- Refactored alertmanager, incident, Jira, notification, and storage/access call surfaces to typed request/context models while preserving router/runtime behavior. +- Removed remaining strict-design lint violations (`too-many-args`, `too-many-positional-arguments`, `too-many-statements`, etc.) across notifier and restored full `pylint` 10.00/10 with notifier-wide `mypy` + `pytest` green at 100% coverage. +- Removed stale deprecated/legacy wording in notifier Jira secret warning and related tests without changing runtime logic. +- Added a temporary SQLite test bootstrap for notifier, aligned channel/Jira/webhook storage access-context handling, and refreshed the generated OpenAPI snapshot. +- Excluded generated mutation fixtures from notifier mypy so type-checking stays focused on the real source tree. + ## [v0.0.4] - 2026-04-14 ### Added diff --git a/__init__.py b/__init__.py index ec9490e..003f8cb 100644 --- a/__init__.py +++ b/__init__.py @@ -2,9 +2,8 @@ """ Be Notified service package. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ diff --git a/config.py b/config.py index 39176db..920a2cc 100644 --- a/config.py +++ b/config.py @@ -5,11 +5,10 @@ security hardening features. The configuration is designed to be flexible and secure by default, with special considerations for production environments. It also includes integration with Vault for secret management when enabled. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import importlib diff --git a/custom_types/__init__.py b/custom_types/__init__.py index ae53ea3..2048501 100644 --- a/custom_types/__init__.py +++ b/custom_types/__init__.py @@ -1,11 +1,10 @@ """ Custom types used across the Notifier codebase. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/custom_types/json.py b/custom_types/json.py index d58dda6..6dbf4c5 100644 --- a/custom_types/json.py +++ b/custom_types/json.py @@ -1,11 +1,10 @@ """ Json-related custom types and helper functions. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/database.py b/database.py index 25bee49..7f73c89 100644 --- a/database.py +++ b/database.py @@ -5,11 +5,10 @@ resources on application shutdown. It also includes a function to initialize the database schema based on defined SQLAlchemy models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import logging @@ -42,6 +41,9 @@ def _new_session(factory: _SessionFactory) -> Session: def ensure_database_exists(database_url: str) -> None: url = make_url(database_url) + drivername = str(getattr(url, "drivername", "postgresql")) + if not drivername.startswith("postgresql"): + return db_name = url.database if not db_name: raise RuntimeError("Database URL must include a database name") diff --git a/db_models.py b/db_models.py index 79c8058..0f368c6 100644 --- a/db_models.py +++ b/db_models.py @@ -2,11 +2,10 @@ SQLAlchemy models for Be Notified Service, defining the schema for tenants, groups, alert rules, incidents, notification channels, and purged silences. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/main.py b/main.py index 458d5b4..5667797 100644 --- a/main.py +++ b/main.py @@ -1,23 +1,25 @@ """ Entrypoint for the Notifier service. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations +import asyncio import logging +import os import secrets +import time from collections.abc import Awaitable, Callable -import uvicorn from fastapi import FastAPI, HTTPException, Request, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse +from sqlalchemy.exc import SQLAlchemyError from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.responses import Response @@ -34,6 +36,7 @@ openapi_tags, ) from middleware.request_size_limit import RequestSizeLimitMiddleware +from middleware.runtime_ssl import RuntimeSSLOptions, run_uvicorn from routers.observability.alerts import ( router as alertmanager_alerts_router, ) @@ -52,9 +55,39 @@ # Expose this name for route logic and tests that monkeypatch main.connection_test. connection_test = database_module.connection_test -database_module.ensure_database_exists(config.notifier_database_url) -database_module.init_database(config.notifier_database_url, config.log_level == "debug") -database_module.init_db() + +def _get_float_env(var_name: str, default: str) -> float: + raw_value = os.getenv(var_name, default) + try: + return float(raw_value) + except ValueError as exc: + raise RuntimeError(f"Invalid value for {var_name}: {raw_value!r}. Expected a numeric value.") from exc + +def _bootstrap_database() -> None: + timeout_seconds = _get_float_env("DATABASE_STARTUP_TIMEOUT", "180") + retry_delay_seconds = _get_float_env("DATABASE_STARTUP_RETRY_DELAY", "2") + deadline = time.monotonic() + timeout_seconds + attempt = 0 + + while True: + attempt += 1 + try: + database_module.ensure_database_exists(config.notifier_database_url) + database_module.init_database(config.notifier_database_url, config.log_level == "debug") + database_module.init_db() + logger.info("Notifier database initialization completed") + return + except SQLAlchemyError as exc: + if time.monotonic() >= deadline: + raise RuntimeError("Notifier database did not become ready before startup timeout") from exc + logger.warning( + "Notifier database not ready (attempt %d, retrying in %.1fs): %s", + attempt, + retry_delay_seconds, + exc, + ) + time.sleep(retry_delay_seconds) + APP_TITLE = "Notifier" APP_DESCRIPTION = "Internal alerting service for Watchdog" @@ -91,6 +124,11 @@ ) +@app.on_event("startup") +async def _startup_database() -> None: + await asyncio.to_thread(_bootstrap_database) + + @app.middleware("http") async def require_internal_service_token( request: Request, @@ -167,15 +205,11 @@ async def readiness() -> JSONResponse: if __name__ == "__main__": - if config.notifier_ssl_enabled: - uvicorn.run( - app, - host=config.host, - port=config.port, - loop="uvloop", - log_level=config.log_level, - ssl_certfile=config.notifier_ssl_certfile, - ssl_keyfile=config.notifier_ssl_keyfile, - ) - else: - uvicorn.run(app, host=config.host, port=config.port, loop="uvloop", log_level=config.log_level) + run_uvicorn( + app, + host=config.host, + port=config.port, + loop="uvloop", + log_level=config.log_level, + ssl_options=RuntimeSSLOptions.from_config(config), + ) diff --git a/middleware/__init__.py b/middleware/__init__.py index bc7e54c..1f1d0c5 100644 --- a/middleware/__init__.py +++ b/middleware/__init__.py @@ -1,11 +1,10 @@ """ Middleware components for Notifier API. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from .concurrency_limit import ConcurrencyLimitMiddleware diff --git a/middleware/concurrency_limit.py b/middleware/concurrency_limit.py index 02f5717..e4babb6 100644 --- a/middleware/concurrency_limit.py +++ b/middleware/concurrency_limit.py @@ -1,11 +1,10 @@ """ Concurrency limiting middleware. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/middleware/dependencies.py b/middleware/dependencies.py index 134a17e..a5d26fc 100644 --- a/middleware/dependencies.py +++ b/middleware/dependencies.py @@ -2,11 +2,10 @@ Dependency and authentication utilities for Be Notified Service, including context token verification and permission checks. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -16,6 +15,7 @@ import threading import time from collections.abc import Callable +from dataclasses import dataclass from functools import lru_cache from ipaddress import IPv4Network, IPv6Network, ip_address, ip_network @@ -209,23 +209,30 @@ def dependency(current_user: TokenData = Depends(checker)) -> TokenData: return dependency -def enforce_public_endpoint_security( - request: Request, - *, - scope: str, - limit: int, - window_seconds: int, - allowlist: str | None = None, - fallback_mode: str | None = None, -) -> None: +@dataclass(frozen=True) +class PublicEndpointSecurityConfig: + scope: str + limit: int + window_seconds: int + allowlist: str | None = None + fallback_mode: str | None = None + + +def enforce_public_endpoint_security(request: Request, config_data: PublicEndpointSecurityConfig) -> None: resolved_ip = client_ip(request) if config.require_client_ip_for_public_endpoints and resolved_ip == "unknown": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"Access denied for {scope}: client IP resolution failed", + detail=f"Access denied for {config_data.scope}: client IP resolution failed", ) - enforce_ip_rate_limit(request, scope=scope, limit=limit, window_seconds=window_seconds, fallback_mode=fallback_mode) - _enforce_ip_allowlist(request, allowlist, scope=scope) + enforce_ip_rate_limit( + request, + scope=config_data.scope, + limit=config_data.limit, + window_seconds=config_data.window_seconds, + fallback_mode=config_data.fallback_mode, + ) + _enforce_ip_allowlist(request, config_data.allowlist, scope=config_data.scope) def _enforce_ip_allowlist(request: Request, allowlist: str | None, *, scope: str) -> None: diff --git a/middleware/error_handlers.py b/middleware/error_handlers.py index 233955b..a14b57a 100644 --- a/middleware/error_handlers.py +++ b/middleware/error_handlers.py @@ -2,18 +2,17 @@ Shared router-level error handling helpers (moved from routers). Decorators for mapping expected exceptions to HTTP status codes consistently across route handlers. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import logging from collections.abc import Awaitable, Callable from dataclasses import dataclass from functools import wraps -from typing import TypeVar, cast +from typing import TypeVar import httpx from fastapi import HTTPException, Request, status @@ -22,7 +21,6 @@ logger = logging.getLogger(__name__) RouteResult = TypeVar("RouteResult") -_MISSING = object() @dataclass(frozen=True) @@ -36,21 +34,10 @@ def _coerce_route_error_response( *, default_detail: str | None, default_status_code: int, - detail_override: object, - status_override: object, ) -> RouteErrorResponse: - detail = current.detail if current else default_detail - status_code = current.status_code if current else default_status_code - - if detail_override is not _MISSING: - detail = str(detail_override) if detail_override is not None else None - if status_override is not _MISSING: - try: - status_code = int(cast(int | str | bytes | bytearray, status_override)) - except (TypeError, ValueError): - status_code = default_status_code - - return RouteErrorResponse(detail=detail, status_code=status_code) + if current is None: + return RouteErrorResponse(detail=default_detail, status_code=default_status_code) + return RouteErrorResponse(detail=current.detail, status_code=current.status_code) def handle_route_errors( @@ -60,21 +47,16 @@ def handle_route_errors( bad_gateway_exceptions: tuple[type[Exception], ...] = (httpx.HTTPError,), bad_gateway: RouteErrorResponse | None = None, internal: RouteErrorResponse | None = None, - **legacy_kwargs: object, ) -> Callable[[Callable[..., Awaitable[RouteResult]]], Callable[..., Awaitable[RouteResult]]]: bad_gateway_response = _coerce_route_error_response( bad_gateway, default_detail="Upstream request failed", default_status_code=status.HTTP_502_BAD_GATEWAY, - detail_override=legacy_kwargs.pop("bad_gateway_detail", _MISSING), - status_override=legacy_kwargs.pop("bad_gateway_status_code", _MISSING), ) internal_response = _coerce_route_error_response( internal, default_detail="Internal server error", default_status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail_override=legacy_kwargs.pop("internal_detail", _MISSING), - status_override=legacy_kwargs.pop("internal_status_code", _MISSING), ) def decorator(func: Callable[..., Awaitable[RouteResult]]) -> Callable[..., Awaitable[RouteResult]]: diff --git a/middleware/headers.py b/middleware/headers.py index f271d2d..37e6653 100644 --- a/middleware/headers.py +++ b/middleware/headers.py @@ -1,11 +1,10 @@ """ Security Middleware for Be Notified Service, including header enforcement. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/middleware/openapi.py b/middleware/openapi.py index 225eaac..692ab9a 100644 --- a/middleware/openapi.py +++ b/middleware/openapi.py @@ -1,11 +1,10 @@ """ OpenAPI customization wiring for the Notifier FastAPI app. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/middleware/rate_limit/__init__.py b/middleware/rate_limit/__init__.py index f739e96..e158ed4 100644 --- a/middleware/rate_limit/__init__.py +++ b/middleware/rate_limit/__init__.py @@ -1,11 +1,10 @@ """ Rate limiting primitives and helpers. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/middleware/rate_limit/hybrid.py b/middleware/rate_limit/hybrid.py index decdb91..1109bbb 100644 --- a/middleware/rate_limit/hybrid.py +++ b/middleware/rate_limit/hybrid.py @@ -2,11 +2,10 @@ Hybrid rate limiter that uses Redis for distributed rate limiting and falls back to an in-memory limiter if Redis is unavailable. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/middleware/rate_limit/in_memory.py b/middleware/rate_limit/in_memory.py index 121f286..621f266 100644 --- a/middleware/rate_limit/in_memory.py +++ b/middleware/rate_limit/in_memory.py @@ -1,11 +1,10 @@ """ In-memory rate limiter implementation for Be Notified middleware. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/middleware/rate_limit/ip.py b/middleware/rate_limit/ip.py index ae2222e..d6aa304 100644 --- a/middleware/rate_limit/ip.py +++ b/middleware/rate_limit/ip.py @@ -1,11 +1,10 @@ """ IP-based rate limiter for Be Notified middleware. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -41,17 +40,19 @@ def _trusted_proxy_peer() -> bool: if not validated: return False + is_trusted = False try: peer_ip = ip_address(validated) for cidr in trusted_cidrs: try: if peer_ip in ip_network(cidr, strict=False): - return True + is_trusted = True + break except ValueError: continue except ValueError: - return False - return False + is_trusted = False + return is_trusted if _trusted_proxy_peer(): forwarded_for = (request.headers.get("x-forwarded-for") or "").strip() diff --git a/middleware/rate_limit/models.py b/middleware/rate_limit/models.py index cf04c73..7a28b79 100644 --- a/middleware/rate_limit/models.py +++ b/middleware/rate_limit/models.py @@ -1,11 +1,10 @@ """ Rate limit models for Be Notified middleware. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/middleware/rate_limit/observability.py b/middleware/rate_limit/observability.py index fc025da..26384c5 100644 --- a/middleware/rate_limit/observability.py +++ b/middleware/rate_limit/observability.py @@ -1,11 +1,10 @@ """ Observability utilities for rate limiting in Be Notified middleware. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/middleware/rate_limit/redis_fixed_window.py b/middleware/rate_limit/redis_fixed_window.py index 055654e..a1e604f 100644 --- a/middleware/rate_limit/redis_fixed_window.py +++ b/middleware/rate_limit/redis_fixed_window.py @@ -1,11 +1,10 @@ """ Redis-based fixed window rate limiter for Watchdog middleware. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/middleware/request_size_limit.py b/middleware/request_size_limit.py index 2f8dcb7..d943807 100644 --- a/middleware/request_size_limit.py +++ b/middleware/request_size_limit.py @@ -1,11 +1,10 @@ """ Request size limiting middleware. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/middleware/resilience.py b/middleware/resilience.py index c93b07e..73c893f 100644 --- a/middleware/resilience.py +++ b/middleware/resilience.py @@ -1,11 +1,10 @@ """ Resilience decorators for service calls and rate limiting. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import asyncio diff --git a/middleware/runtime_ssl.py b/middleware/runtime_ssl.py new file mode 100644 index 0000000..e08fd4e --- /dev/null +++ b/middleware/runtime_ssl.py @@ -0,0 +1,45 @@ +""" +Runtime SSL helpers for the Notifier service. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from importlib import import_module +from typing import Any + + +@dataclass(frozen=True) +class RuntimeSSLOptions: + ssl_certfile: str + ssl_keyfile: str + + @classmethod + def from_config(cls, config: object) -> RuntimeSSLOptions | None: + if not getattr(config, "notifier_ssl_enabled"): + return None + + return cls( + ssl_certfile=str(getattr(config, "notifier_ssl_certfile", "")).strip(), + ssl_keyfile=str(getattr(config, "notifier_ssl_keyfile", "")).strip(), + ) + + def to_uvicorn_kwargs(self) -> dict[str, str]: + return { + "ssl_certfile": self.ssl_certfile, + "ssl_keyfile": self.ssl_keyfile, + } + + +def run_uvicorn(app: Any, *, ssl_options: RuntimeSSLOptions | None = None, **kwargs: Any) -> None: + if ssl_options is not None: + kwargs.update(ssl_options.to_uvicorn_kwargs()) + + uvicorn = import_module("uvicorn") + uvicorn.run(app=app, **kwargs) diff --git a/models/__init__.py b/models/__init__.py index a645fb6..1140959 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ __all__ = [] diff --git a/models/access/__init__.py b/models/access/__init__.py index d1d8ad3..26b976d 100644 --- a/models/access/__init__.py +++ b/models/access/__init__.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from .auth_models import TokenData diff --git a/models/access/auth_models.py b/models/access/auth_models.py index cda9052..7674cd3 100644 --- a/models/access/auth_models.py +++ b/models/access/auth_models.py @@ -1,11 +1,10 @@ """ This module defines Pydantic models for authentication and authorization data structures used in the API layer. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from enum import Enum diff --git a/models/alerting/__init__.py b/models/alerting/__init__.py index d0acfc6..75c2fc7 100644 --- a/models/alerting/__init__.py +++ b/models/alerting/__init__.py @@ -2,6 +2,5 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ diff --git a/models/alerting/alerts.py b/models/alerting/alerts.py index b0eb606..ddf2f0c 100644 --- a/models/alerting/alerts.py +++ b/models/alerting/alerts.py @@ -1,11 +1,10 @@ """ Module defines Pydantic models for alerting-related data structures used in the API layer. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from enum import Enum diff --git a/models/alerting/channels.py b/models/alerting/channels.py index deb2697..487f3d1 100644 --- a/models/alerting/channels.py +++ b/models/alerting/channels.py @@ -1,11 +1,10 @@ """ Module defines Pydantic models for notification channel-related data structures used in the API layer. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from enum import Enum diff --git a/models/alerting/incidents.py b/models/alerting/incidents.py index 6fad9a1..fdfef6a 100644 --- a/models/alerting/incidents.py +++ b/models/alerting/incidents.py @@ -1,11 +1,10 @@ """ Module defines Pydantic models for alerting-related data structures used in the API layer. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from datetime import UTC, datetime diff --git a/models/alerting/receivers.py b/models/alerting/receivers.py index ec2ef75..fae5aa3 100644 --- a/models/alerting/receivers.py +++ b/models/alerting/receivers.py @@ -1,11 +1,10 @@ """ Module defines Pydantic models for alerting-related data structures used in the API layer. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from pydantic import BaseModel, ConfigDict, Field diff --git a/models/alerting/requests.py b/models/alerting/requests.py index 8c4f04c..bbe3b42 100644 --- a/models/alerting/requests.py +++ b/models/alerting/requests.py @@ -1,11 +1,10 @@ """ Request models for alerting-related API endpoints. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/models/alerting/rules.py b/models/alerting/rules.py index 1db6d8b..2d37ddd 100644 --- a/models/alerting/rules.py +++ b/models/alerting/rules.py @@ -1,11 +1,10 @@ """ Module defines Pydantic models for alerting-related data structures used in the API layer. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from enum import Enum diff --git a/models/alerting/silences.py b/models/alerting/silences.py index c625aab..80f1346 100644 --- a/models/alerting/silences.py +++ b/models/alerting/silences.py @@ -1,11 +1,10 @@ """ Module defines Pydantic models for alerting-related data structures used in the API layer. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from enum import Enum diff --git a/openapi.json b/openapi.json index 8ab6887..772f5c2 100644 --- a/openapi.json +++ b/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Notifier","description":"Internal alerting service for Watchdog","version":"0.0.4"},"paths":{"/internal/v1/api/alertmanager/alerts":{"get":{"tags":["alertmanager"],"summary":"List Alerts","description":"Lists alertmanager alerts with optional state and label filtering for the current user scope.","operationId":"list_alerts","security":[{"HTTPBearer":[]}],"parameters":[{"name":"active","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Active"}},{"name":"silenced","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Silenced"}},{"name":"inhibited","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Inhibited"}},{"name":"filter_labels","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filter Labels"}},{"name":"show_hidden","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Show Hidden"}}],"responses":{"200":{"description":"The alerts visible to the current caller.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Alert-Output"},"title":"Response List Alerts"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["alertmanager"],"summary":"Create Alerts","description":"Submits one or more alerts to alertmanager for processing.","operationId":"create_alerts","security":[{"HTTPBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Alert-Input"},"title":"Alerts"}}}},"responses":{"200":{"description":"The submission result including the number of alerts accepted.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Create Alerts"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["alertmanager"],"summary":"Delete Alerts","description":"Deletes alerts in alertmanager that match the provided label filter set.","operationId":"delete_alerts","security":[{"HTTPBearer":[]}],"parameters":[{"name":"filter_labels","in":"query","required":true,"schema":{"type":"string","title":"Filter Labels"}}],"responses":{"200":{"description":"The deletion result for the matched alerts.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Alerts"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/alerts/groups":{"get":{"tags":["alertmanager"],"summary":"List Alert Groups","description":"Lists grouped alerts from alertmanager with optional label filtering.","operationId":"list_alert_groups","security":[{"HTTPBearer":[]}],"parameters":[{"name":"filter_labels","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filter Labels"}}],"responses":{"200":{"description":"Grouped alerts returned by alertmanager.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlertGroup"},"title":"Response List Alert Groups"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/access/group-shares/prune":{"post":{"tags":["alertmanager"],"summary":"Prune Group Shares","description":"Removes stale shared-group visibility references for users that were removed from a group.","operationId":"prune_group_shares","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GroupSharePruneRequest"}}},"required":true},"responses":{"200":{"description":"The counts of updated records and pruned silences.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Prune Group Shares"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/integrations/channel-types":{"get":{"tags":["alertmanager"],"summary":"List Allowed Channel Types","description":"Lists notification channel types that are currently enabled for alertmanager integrations.","operationId":"list_channel_types","responses":{"200":{"description":"The channel types allowed by integration policy.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response List Channel Types"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"}},"security":[{"HTTPBearer":[]}]}},"/internal/v1/api/alertmanager/silences":{"get":{"tags":["alertmanager-silences"],"summary":"List Silences","description":"Lists silences visible to the current user with optional label and expiration filtering.","operationId":"list_silences","security":[{"HTTPBearer":[]}],"parameters":[{"name":"filter_labels","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filter Labels"}},{"name":"include_expired","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Expired"}},{"name":"show_hidden","in":"query","required":false,"schema":{"type":"string","pattern":"^(true|false)$","default":"false","title":"Show Hidden"}}],"responses":{"200":{"description":"The silences visible to the current caller.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Silence"},"title":"Response List Silences"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["alertmanager-silences"],"summary":"Create Silence","description":"Creates a new silence in alertmanager for the current user scope.","operationId":"create_silence","security":[{"HTTPBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SilenceCreateRequest"}}}},"responses":{"200":{"description":"The created silence identifier and operation result.","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"string"},"title":"Response Create Silence"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/silences/{silence_id}":{"get":{"tags":["alertmanager-silences"],"summary":"Get Silence","description":"Returns a single silence when it exists and is visible to the current user.","operationId":"get_silence","security":[{"HTTPBearer":[]}],"parameters":[{"name":"silence_id","in":"path","required":true,"schema":{"type":"string","title":"Silence Id"}},{"name":"show_hidden","in":"query","required":false,"schema":{"type":"string","pattern":"^(true|false)$","default":"false","title":"Show Hidden"}}],"responses":{"200":{"description":"The requested silence.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Silence"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["alertmanager-silences"],"summary":"Update Silence","description":"Updates an existing silence when the caller owns it and still has access.","operationId":"update_silence","security":[{"HTTPBearer":[]}],"parameters":[{"name":"silence_id","in":"path","required":true,"schema":{"type":"string","title":"Silence Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SilenceCreateRequest"}}}},"responses":{"200":{"description":"The updated silence identifier and operation result.","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"string"},"title":"Response Update Silence"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["alertmanager-silences"],"summary":"Delete Silence","description":"Deletes an existing silence when the caller owns it and still has access.","operationId":"delete_silence","security":[{"HTTPBearer":[]}],"parameters":[{"name":"silence_id","in":"path","required":true,"schema":{"type":"string","title":"Silence Id"}}],"responses":{"200":{"description":"The deletion result for the specified silence.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Silence"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/silences/{silence_id}/hide":{"post":{"tags":["alertmanager-silences"],"summary":"Hide Silence","description":"Toggles whether a shared silence is hidden for the current user.","operationId":"hide_silence","security":[{"HTTPBearer":[]}],"parameters":[{"name":"silence_id","in":"path","required":true,"schema":{"type":"string","title":"Silence Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/routers__observability__alerts__shared__HideTogglePayload"}}}},"responses":{"200":{"description":"The hide state applied to the silence.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Hide Silence"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/status":{"get":{"tags":["alertmanager"],"summary":"Get Alertmanager Status","description":"Returns alertmanager runtime status and cluster metadata.","operationId":"get_alertmanager_status","responses":{"200":{"description":"The current alertmanager status payload.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertManagerStatus"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"}},"security":[{"HTTPBearer":[]}]}},"/internal/v1/api/alertmanager/receivers":{"get":{"tags":["alertmanager"],"summary":"List Receivers","description":"Lists alertmanager receiver names available for routing and inspection.","operationId":"list_receivers","responses":{"200":{"description":"The configured alertmanager receiver names.","content":{"application/json":{"schema":{"items":{"type":"string"},"type":"array","title":"Response List Receivers"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"}},"security":[{"HTTPBearer":[]}]}},"/internal/v1/api/alertmanager/rules/import":{"post":{"tags":["alertmanager-rules"],"summary":"Import Alert Rules","description":"Imports alert rules from YAML, optionally previewing the parsed rules without persisting them.","operationId":"import_rules","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RuleImportRequest"}}},"required":true},"responses":{"200":{"description":"The imported or previewed rule set with creation and update counts.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Import Rules"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/internal/v1/api/alertmanager/rules":{"get":{"tags":["alertmanager-rules"],"summary":"List Alert Rules","description":"Lists alert rules visible to the current user with pagination support.","operationId":"list_rules","security":[{"HTTPBearer":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":1000,"minimum":1,"default":20,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"show_hidden","in":"query","required":false,"schema":{"type":"string","pattern":"^(true|false)$","default":"false","title":"Show Hidden"}}],"responses":{"200":{"description":"The alert rules visible to the current caller.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlertRule"},"title":"Response List Rules"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["alertmanager-rules"],"summary":"Create Alert Rule","description":"Creates a new alert rule and synchronizes it to the backing Mimir rule set.","operationId":"create_rule","security":[{"HTTPBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertRuleCreate"}}}},"responses":{"201":{"description":"The newly created alert rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertRule"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/public/rules":{"get":{"tags":["alertmanager-rules"],"summary":"List Public Alert Rules","description":"Lists public alert rules for the default tenant after public endpoint protections are enforced.","operationId":"list_public_rules","responses":{"200":{"description":"The public alert rules available for the default tenant.","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/AlertRule"},"type":"array","title":"Response List Public Rules"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"}}}},"/internal/v1/api/alertmanager/metrics/names":{"get":{"tags":["alertmanager-rules"],"summary":"List Metric Names","description":"Lists metric names available in Mimir for the resolved organization scope.","operationId":"list_metric_names","security":[{"HTTPBearer":[]}],"parameters":[{"name":"orgId","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Orgid"}}],"responses":{"200":{"description":"The resolved organization identifier and metric names.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Metric Names"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/metrics/query":{"get":{"tags":["alertmanager-rules"],"summary":"Evaluate PromQL Query","description":"Evaluates a PromQL query against Mimir for the resolved organization scope.","operationId":"query_metrics","security":[{"HTTPBearer":[]}],"parameters":[{"name":"query","in":"query","required":true,"schema":{"type":"string","minLength":1,"title":"Query"}},{"name":"orgId","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Orgid"}},{"name":"sampleLimit","in":"query","required":false,"schema":{"type":"integer","maximum":20,"minimum":1,"default":5,"title":"Samplelimit"}}],"responses":{"200":{"description":"The evaluated query result returned from Mimir.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Query Metrics"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/metrics/labels":{"get":{"tags":["alertmanager-rules"],"summary":"List Metric Labels","description":"Lists label names available in Mimir for the resolved organization scope.","operationId":"list_metric_labels","security":[{"HTTPBearer":[]}],"parameters":[{"name":"orgId","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Orgid"}}],"responses":{"200":{"description":"The resolved organization identifier and metric label names.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Metric Labels"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/metrics/label-values/{label}":{"get":{"tags":["alertmanager-rules"],"summary":"List Metric Label Values","description":"Lists values for a metric label in Mimir for the resolved organization scope.","operationId":"list_metric_label_values","security":[{"HTTPBearer":[]}],"parameters":[{"name":"label","in":"path","required":true,"schema":{"type":"string","title":"Label"}},{"name":"orgId","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Orgid"}},{"name":"metricName","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Metricname"}}],"responses":{"200":{"description":"The resolved organization identifier and metric label values.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Metric Label Values"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"404":{"description":"Not Found"}}}},"/internal/v1/api/alertmanager/rules/{rule_id}":{"get":{"tags":["alertmanager-rules"],"summary":"Get Alert Rule","description":"Returns a single alert rule when it exists and is visible to the current user.","operationId":"get_rule","security":[{"HTTPBearer":[]}],"parameters":[{"name":"rule_id","in":"path","required":true,"schema":{"type":"string","title":"Rule Id"}}],"responses":{"200":{"description":"The requested alert rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertRule"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["alertmanager-rules"],"summary":"Update Alert Rule","description":"Updates an existing alert rule and synchronizes affected Mimir rule sets.","operationId":"update_rule","security":[{"HTTPBearer":[]}],"parameters":[{"name":"rule_id","in":"path","required":true,"schema":{"type":"string","title":"Rule Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertRuleCreate"}}}},"responses":{"200":{"description":"The updated alert rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertRule"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["alertmanager-rules"],"summary":"Delete Alert Rule","description":"Deletes an alert rule and synchronizes the backing Mimir rule set.","operationId":"delete_rule","security":[{"HTTPBearer":[]}],"parameters":[{"name":"rule_id","in":"path","required":true,"schema":{"type":"string","title":"Rule Id"}}],"responses":{"200":{"description":"The deletion result for the alert rule.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Rule"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/rules/{rule_id}/hide":{"post":{"tags":["alertmanager-rules"],"summary":"Hide Alert Rule","description":"Toggles whether a shared alert rule is hidden for the current user.","operationId":"hide_rule","security":[{"HTTPBearer":[]}],"parameters":[{"name":"rule_id","in":"path","required":true,"schema":{"type":"string","title":"Rule Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/routers__observability__alerts__shared__HideTogglePayload"}}}},"responses":{"200":{"description":"The hide state applied to the alert rule.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Hide Rule"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/rules/{rule_id}/test":{"post":{"tags":["alertmanager-rules"],"summary":"Test Alert Rule","description":"Builds a synthetic alert from the rule and sends test notifications through its configured channels.","operationId":"test_rule","security":[{"HTTPBearer":[]}],"parameters":[{"name":"rule_id","in":"path","required":true,"schema":{"type":"string","title":"Rule Id"}}],"responses":{"200":{"description":"The test execution result across the configured notification channels.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Test Rule"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/channels":{"get":{"tags":["alertmanager-channels"],"summary":"List Notification Channels","description":"Lists notification channels visible to the current user.","operationId":"list_channels","security":[{"HTTPBearer":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":1000,"minimum":1,"default":20,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"show_hidden","in":"query","required":false,"schema":{"type":"string","pattern":"^(true|false)$","default":"false","title":"Show Hidden"}}],"responses":{"200":{"description":"The notification channels visible to the current caller.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/NotificationChannel"},"title":"Response List Channels"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["alertmanager-channels"],"summary":"Create Notification Channel","description":"Creates a new notification channel for the current tenant scope.","operationId":"create_channel","security":[{"HTTPBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationChannelCreate"}}}},"responses":{"201":{"description":"The newly created notification channel.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationChannel"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/channels/{channel_id}":{"get":{"tags":["alertmanager-channels"],"summary":"Get Notification Channel","description":"Returns a single notification channel when it exists and is visible to the current user.","operationId":"get_channel","security":[{"HTTPBearer":[]}],"parameters":[{"name":"channel_id","in":"path","required":true,"schema":{"type":"string","title":"Channel Id"}}],"responses":{"200":{"description":"The requested notification channel.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationChannel"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["alertmanager-channels"],"summary":"Update Notification Channel","description":"Updates an existing notification channel in the current tenant scope.","operationId":"update_channel","security":[{"HTTPBearer":[]}],"parameters":[{"name":"channel_id","in":"path","required":true,"schema":{"type":"string","title":"Channel Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationChannelCreate"}}}},"responses":{"200":{"description":"The updated notification channel.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationChannel"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["alertmanager-channels"],"summary":"Delete Notification Channel","description":"Deletes an existing notification channel when the caller has access.","operationId":"delete_channel","security":[{"HTTPBearer":[]}],"parameters":[{"name":"channel_id","in":"path","required":true,"schema":{"type":"string","title":"Channel Id"}}],"responses":{"200":{"description":"The deletion result for the notification channel.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Channel"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"400":{"description":"Bad Request"}}}},"/internal/v1/api/alertmanager/channels/{channel_id}/hide":{"post":{"tags":["alertmanager-channels"],"summary":"Hide Notification Channel","description":"Toggles whether a shared notification channel is hidden for the current user.","operationId":"hide_channel","security":[{"HTTPBearer":[]}],"parameters":[{"name":"channel_id","in":"path","required":true,"schema":{"type":"string","title":"Channel Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/routers__observability__alerts__shared__HideTogglePayload"}}}},"responses":{"200":{"description":"The hide state applied to the notification channel.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Hide Channel"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/channels/{channel_id}/test":{"post":{"tags":["alertmanager-channels"],"summary":"Test Notification Channel","description":"Sends a test notification through the specified notification channel.","operationId":"test_channel","security":[{"HTTPBearer":[]}],"parameters":[{"name":"channel_id","in":"path","required":true,"schema":{"type":"string","title":"Channel Id"}}],"responses":{"200":{"description":"The test delivery result for the notification channel.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Test Channel"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/incidents":{"get":{"tags":["alertmanager-incidents"],"summary":"List Incidents","description":"Lists alert incidents visible to the current user with optional status, visibility, and group filters.","operationId":"list_incidents","security":[{"HTTPBearer":[]}],"parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"}},{"name":"visibility","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Visibility"}},{"name":"group_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Group Id"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":1,"default":100,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}}],"responses":{"200":{"description":"The incidents visible to the current caller.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlertIncident"},"title":"Response List Incidents"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/incidents/summary":{"get":{"tags":["alertmanager-incidents"],"summary":"Get Incident Summary","description":"Returns an aggregated summary of incidents visible to the current user.","operationId":"get_incident_summary","responses":{"200":{"description":"Aggregated incident summary counts and breakdowns.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Get Incident Summary"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"}},"security":[{"HTTPBearer":[]}]}},"/internal/v1/api/alertmanager/incidents/{incident_id}":{"patch":{"tags":["alertmanager-incidents"],"summary":"Update Incident","description":"Updates an incident's status, assignment, Jira metadata, or visibility settings.","operationId":"update_incident","security":[{"HTTPBearer":[]}],"parameters":[{"name":"incident_id","in":"path","required":true,"schema":{"type":"string","title":"Incident Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertIncidentUpdateRequest"}}}},"responses":{"200":{"description":"The updated incident.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertIncident"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/jira/config":{"get":{"tags":["alertmanager-jira"],"summary":"Get Jira Config","description":"Returns the tenant-level Jira configuration with secrets masked into presence flags.","operationId":"get_jira_config","responses":{"200":{"description":"The current tenant Jira configuration.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Get Jira Config"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"404":{"description":"Not Found"}},"security":[{"HTTPBearer":[]}]},"put":{"tags":["alertmanager-jira"],"summary":"Update Jira Config","description":"Updates the tenant-level Jira configuration used when an explicit integration is not selected.","operationId":"update_jira_config","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JiraConfigUpdateRequest"}}},"required":true},"responses":{"200":{"description":"The saved tenant Jira configuration.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Update Jira Config"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/internal/v1/api/alertmanager/jira/projects":{"get":{"tags":["alertmanager-jira"],"summary":"List Jira Projects","description":"Lists Jira projects using either the tenant Jira config or a selected integration.","operationId":"list_jira_projects","security":[{"HTTPBearer":[]}],"parameters":[{"name":"integrationId","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integrationid"}}],"responses":{"200":{"description":"The available Jira projects for the resolved credentials.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Jira Projects"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/jira/projects/{project_key}/issue-types":{"get":{"tags":["alertmanager-jira"],"summary":"List Jira Issue Types","description":"Lists Jira issue types for a project using either the tenant Jira config or a selected integration.","operationId":"list_jira_issue_types","security":[{"HTTPBearer":[]}],"parameters":[{"name":"project_key","in":"path","required":true,"schema":{"type":"string","title":"Project Key"}},{"name":"integrationId","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integrationid"}}],"responses":{"200":{"description":"The available Jira issue types for the requested project.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Jira Issue Types"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/integrations/jira/{integration_id}/projects":{"get":{"tags":["alertmanager-jira"],"summary":"List Jira Projects By Integration","description":"Lists Jira projects using a specific Jira integration.","operationId":"list_integration_projects","security":[{"HTTPBearer":[]}],"parameters":[{"name":"integration_id","in":"path","required":true,"schema":{"type":"string","title":"Integration Id"}}],"responses":{"200":{"description":"The Jira projects available through the selected integration.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Integration Projects"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/integrations/jira/{integration_id}/projects/{project_key}/issue-types":{"get":{"tags":["alertmanager-jira"],"summary":"List Jira Issue Types By Integration","description":"Lists Jira issue types for a project using a specific Jira integration.","operationId":"list_integration_issue_types","security":[{"HTTPBearer":[]}],"parameters":[{"name":"integration_id","in":"path","required":true,"schema":{"type":"string","title":"Integration Id"}},{"name":"project_key","in":"path","required":true,"schema":{"type":"string","title":"Project Key"}}],"responses":{"200":{"description":"The Jira issue types available through the selected integration.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Integration Issue Types"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/integrations/jira":{"get":{"tags":["alertmanager-jira"],"summary":"List Jira Integrations","description":"Lists Jira integrations visible to the current user, including hidden state when requested.","operationId":"list_jira_integrations","security":[{"HTTPBearer":[]}],"parameters":[{"name":"show_hidden","in":"query","required":false,"schema":{"type":"string","pattern":"^(true|false)$","default":"false","title":"Show Hidden"}}],"responses":{"200":{"description":"The Jira integrations visible to the current caller.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Jira Integrations"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["alertmanager-jira"],"summary":"Create Jira Integration","description":"Creates a new Jira integration for the current tenant.","operationId":"create_jira_integration","security":[{"HTTPBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JiraIntegrationCreateRequest"}}}},"responses":{"200":{"description":"The created Jira integration with sensitive values masked.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Create Jira Integration"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/integrations/jira/{integration_id}":{"put":{"tags":["alertmanager-jira"],"summary":"Update Jira Integration","description":"Updates an existing Jira integration owned by the current user.","operationId":"update_jira_integration","security":[{"HTTPBearer":[]}],"parameters":[{"name":"integration_id","in":"path","required":true,"schema":{"type":"string","title":"Integration Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JiraIntegrationUpdateRequest"}}}},"responses":{"200":{"description":"The updated Jira integration with sensitive values masked.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Update Jira Integration"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["alertmanager-jira"],"summary":"Delete Jira Integration","description":"Deletes a Jira integration owned by the current user and unlinks it from incidents.","operationId":"delete_jira_integration","security":[{"HTTPBearer":[]}],"parameters":[{"name":"integration_id","in":"path","required":true,"schema":{"type":"string","title":"Integration Id"}}],"responses":{"200":{"description":"The deletion result and count of incidents unlinked from the integration.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Jira Integration"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"400":{"description":"Bad Request"}}}},"/internal/v1/api/alertmanager/integrations/jira/{integration_id}/hide":{"post":{"tags":["alertmanager-jira"],"summary":"Hide Jira Integration","description":"Toggles whether a shared Jira integration is hidden for the current user.","operationId":"hide_jira_integration","security":[{"HTTPBearer":[]}],"parameters":[{"name":"integration_id","in":"path","required":true,"schema":{"type":"string","title":"Integration Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/routers__observability__jira__shared__HideTogglePayload"}}}},"responses":{"200":{"description":"The hide state applied to the Jira integration.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Hide Jira Integration"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/incidents/{incident_id}/jira":{"post":{"tags":["alertmanager-jira"],"summary":"Create Incident Jira Link","description":"Creates a Jira issue for an incident and stores the Jira linkage on the incident record.","operationId":"create_incident_link","security":[{"HTTPBearer":[]}],"parameters":[{"name":"incident_id","in":"path","required":true,"schema":{"type":"string","title":"Incident Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IncidentJiraCreateRequest"}}}},"responses":{"200":{"description":"The incident updated with Jira linkage metadata.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertIncident"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"409":{"description":"Conflict"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/incidents/{incident_id}/jira/sync-notes":{"post":{"tags":["alertmanager-jira"],"summary":"Sync Incident Jira Notes","description":"Backfills incident notes to the linked Jira issue while skipping notes already present.","operationId":"sync_incident_notes","security":[{"HTTPBearer":[]}],"parameters":[{"name":"incident_id","in":"path","required":true,"schema":{"type":"string","title":"Incident Id"}}],"responses":{"200":{"description":"The note synchronization result for the linked Jira issue.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Sync Incident Notes"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/incidents/{incident_id}/jira/comments":{"get":{"tags":["alertmanager-jira"],"summary":"List Incident Jira Comments","description":"Lists comments from the Jira issue linked to the specified incident when credentials are available.","operationId":"list_incident_comments","security":[{"HTTPBearer":[]}],"parameters":[{"name":"incident_id","in":"path","required":true,"schema":{"type":"string","title":"Incident Id"}}],"responses":{"200":{"description":"The Jira comments associated with the incident's linked issue.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Incident Comments"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/alertmanager/alerts/webhook":{"post":{"tags":["alertmanager-webhooks"],"summary":"Receive Alert Webhook","description":"Receives general alert webhook payloads and dispatches notifications for the inferred tenant.","operationId":"receive_alert_webhook","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertWebhookRequest"}}},"required":true},"responses":{"200":{"description":"The webhook processing result for the submitted alerts.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Receive Alert Webhook"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/alertmanager/alerts/critical":{"post":{"tags":["alertmanager-webhooks"],"summary":"Receive Critical Alert Webhook","description":"Receives critical alert webhook payloads and dispatches critical notifications for the inferred tenant.","operationId":"receive_critical_webhook","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertWebhookRequest"}}},"required":true},"responses":{"200":{"description":"The webhook processing result for the submitted critical alerts.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Receive Critical Webhook"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/alertmanager/alerts/warning":{"post":{"tags":["alertmanager-webhooks"],"summary":"Receive Warning Alert Webhook","description":"Receives warning alert webhook payloads and dispatches warning notifications for the inferred tenant.","operationId":"receive_warning_webhook","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertWebhookRequest"}}},"required":true},"responses":{"200":{"description":"The webhook processing result for the submitted warning alerts.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Receive Warning Webhook"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/health":{"get":{"tags":["system"],"summary":"Service Health","description":"Returns a lightweight health status for the notifier service.","operationId":"health","responses":{"200":{"description":"The current health status for the notifier service.","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Health"}}}}}}},"/ready":{"get":{"tags":["system"],"summary":"Service Readiness","description":"Runs readiness checks required for notifier to serve traffic.","operationId":"readiness","responses":{"200":{"description":"The readiness result and individual dependency checks.","content":{"application/json":{"schema":{}}}}}}}},"components":{"schemas":{"Alert-Input":{"properties":{"labels":{"additionalProperties":{"type":"string"},"type":"object","title":"Labels","description":"Key-value pairs that identify the alert","examples":[{"alertname":"HighCpuUsage","severity":"critical"}]},"annotations":{"additionalProperties":{"type":"string"},"type":"object","title":"Annotations","description":"Additional information about the alert","examples":[{"description":"Node cpu is saturated","summary":"CPU usage above 95%"}]},"startsAt":{"type":"string","title":"Startsat","description":"Time when the alert started firing","examples":["2026-04-03T12:00:00Z"]},"endsAt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Endsat","description":"Time when the alert stopped firing","examples":["2026-04-03T12:15:00Z"]},"generatorURL":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Generatorurl","description":"URL of the alert generator","examples":["https://grafana.example.internal/alerting/grafana/high-cpu"]},"status":{"$ref":"#/components/schemas/AlertStatus","description":"Current status of the alert"},"receivers":{"anyOf":[{"items":{"anyOf":[{"type":"string"},{"additionalProperties":true,"type":"object"}]},"type":"array"},{"type":"null"}],"title":"Receivers","description":"List of receivers for this alert","examples":[["primary-oncall",{"channel":"#alerts","type":"slack"}]]},"fingerprint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fingerprint","description":"Unique identifier for the alert","examples":["01ARZ3NDEKTSV4RRFFQ69G5FAV"]}},"type":"object","required":["labels","startsAt","status"],"title":"Alert"},"Alert-Output":{"properties":{"labels":{"additionalProperties":{"type":"string"},"type":"object","title":"Labels","description":"Key-value pairs that identify the alert","examples":[{"alertname":"HighCpuUsage","severity":"critical"}]},"annotations":{"additionalProperties":{"type":"string"},"type":"object","title":"Annotations","description":"Additional information about the alert","examples":[{"description":"Node cpu is saturated","summary":"CPU usage above 95%"}]},"startsAt":{"type":"string","title":"Startsat","description":"Time when the alert started firing","examples":["2026-04-03T12:00:00Z"]},"endsAt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Endsat","description":"Time when the alert stopped firing","examples":["2026-04-03T12:15:00Z"]},"generatorURL":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Generatorurl","description":"URL of the alert generator","examples":["https://grafana.example.internal/alerting/grafana/high-cpu"]},"status":{"$ref":"#/components/schemas/AlertStatus","description":"Current status of the alert"},"receivers":{"anyOf":[{"items":{"anyOf":[{"type":"string"},{"additionalProperties":true,"type":"object"}]},"type":"array"},{"type":"null"}],"title":"Receivers","description":"List of receivers for this alert","examples":[["primary-oncall",{"channel":"#alerts","type":"slack"}]]},"fingerprint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fingerprint","description":"Unique identifier for the alert","examples":["01ARZ3NDEKTSV4RRFFQ69G5FAV"]}},"type":"object","required":["labels","startsAt","status"],"title":"Alert"},"AlertGroup":{"properties":{"labels":{"additionalProperties":{"type":"string"},"type":"object","title":"Labels","description":"Common labels for the group","examples":[{"team":"platform"}]},"receiver":{"type":"string","title":"Receiver","description":"Receiver that will handle these alerts","examples":["primary-oncall"]},"alerts":{"items":{"$ref":"#/components/schemas/Alert-Output"},"type":"array","title":"Alerts","description":"List of alerts in this group"}},"type":"object","required":["labels","receiver","alerts"],"title":"AlertGroup"},"AlertIncident":{"properties":{"id":{"type":"string","title":"Id","examples":["incident-123"]},"fingerprint":{"type":"string","title":"Fingerprint","examples":["01ARZ3NDEKTSV4RRFFQ69G5FAV"]},"alertName":{"type":"string","title":"Alertname","examples":["HighCpuUsage"]},"severity":{"type":"string","title":"Severity","examples":["critical"]},"status":{"$ref":"#/components/schemas/IncidentStatus","examples":["open"]},"assignee":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Assignee","examples":["alice@example.com"]},"notes":{"items":{"$ref":"#/components/schemas/IncidentNote"},"type":"array","title":"Notes"},"labels":{"additionalProperties":{"type":"string"},"type":"object","title":"Labels","examples":[{"service":"api","severity":"critical"}]},"annotations":{"additionalProperties":{"type":"string"},"type":"object","title":"Annotations","examples":[{"summary":"CPU above 95%"}]},"visibility":{"$ref":"#/components/schemas/IncidentVisibility","default":"public","examples":["public"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","examples":[["group-ops"]]},"jiraTicketKey":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Jiraticketkey","examples":["OPS-321"]},"jiraTicketUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Jiraticketurl","examples":["https://jira.example.internal/browse/OPS-321"]},"jiraIntegrationId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Jiraintegrationid","examples":["jira-int-01"]},"startsAt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Startsat","examples":["2026-04-03T11:55:00Z"]},"lastSeenAt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Lastseenat","examples":["2026-04-03T12:05:00Z"]},"resolvedAt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resolvedat","examples":["2026-04-03T12:20:00Z"]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Createdat","examples":["2026-04-03T12:00:00Z"]},"updatedAt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updatedat","examples":["2026-04-03T12:10:00Z"]},"userManaged":{"type":"boolean","title":"Usermanaged","default":false,"examples":[false]},"hideWhenResolved":{"type":"boolean","title":"Hidewhenresolved","default":false,"examples":[false]}},"type":"object","required":["id","fingerprint","alertName","severity","status","lastSeenAt","createdAt","updatedAt"],"title":"AlertIncident"},"AlertIncidentUpdateRequest":{"properties":{"status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status","examples":["resolved"]},"assignee":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Assignee","examples":["alice@example.com"]},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Note","examples":["Resolved after scaling the deployment"]},"actorUsername":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Actorusername","examples":["alice"]},"visibility":{"anyOf":[{"$ref":"#/components/schemas/IncidentVisibility"},{"type":"null"}],"examples":["group"]},"sharedGroupIds":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Sharedgroupids","examples":[["group-ops"]]},"jiraTicketKey":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Jiraticketkey","examples":["OPS-321"]},"jiraTicketUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Jiraticketurl","examples":["https://jira.example.internal/browse/OPS-321"]},"jiraIntegrationId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Jiraintegrationid","examples":["jira-int-01"]},"hideWhenResolved":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Hidewhenresolved","examples":[true]}},"type":"object","title":"AlertIncidentUpdateRequest"},"AlertManagerStatus":{"properties":{"version":{"type":"string","title":"Version","description":"AlertManager version","examples":["0.28.1"]},"uptime":{"type":"string","title":"Uptime","description":"AlertManager uptime","examples":["72h15m"]},"configHash":{"type":"string","title":"Confighash","description":"Configuration hash","examples":["sha256:abc123"]},"config":{"additionalProperties":true,"type":"object","title":"Config","description":"Alertmanager configuration details","examples":[{"route":{"receiver":"primary-oncall"}}]},"cluster":{"additionalProperties":true,"type":"object","title":"Cluster","description":"Cluster status information","examples":[{"status":"ready"}]}},"type":"object","required":["version","uptime","configHash","cluster"],"title":"AlertManagerStatus"},"AlertRule":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id","description":"Unique identifier","examples":["rule-123"]},"createdBy":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Createdby","description":"User ID who created the rule","examples":["user-42"]},"orgId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Orgid","description":"Organization ID / API key scoped to this rule","examples":["org-abc"]},"name":{"type":"string","title":"Name","description":"Rule name","examples":["HighCpuUsage"]},"expression":{"type":"string","title":"Expression","description":"Prometheus expression for the alert rule","examples":["sum(rate(node_cpu_seconds_total{mode!=\"idle\"}[5m])) > 0.95"]},"severity":{"$ref":"#/components/schemas/RuleSeverity","description":"Severity level of the alert rule","examples":["critical"]},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Description of the alert rule","examples":["CPU usage is critically high"]},"enabled":{"type":"boolean","title":"Enabled","description":"Whether the rule is enabled","default":true,"examples":[true]},"labels":{"additionalProperties":{"type":"string"},"type":"object","title":"Labels","description":"Labels to add to alerts from this rule","examples":[{"service":"api"}]},"annotations":{"additionalProperties":{"type":"string"},"type":"object","title":"Annotations","description":"Annotations to add to alerts from this rule","examples":[{"summary":"API CPU alert"}]},"for":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"For","description":"Duration to wait before firing the alert","examples":["5m"]},"groupName":{"type":"string","title":"Groupname","description":"Name of the rule group this rule belongs to","examples":["watchdog-default"]},"groupInterval":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Groupinterval","description":"Interval between evaluations of this rule group","examples":["1m"]},"notificationChannels":{"items":{"type":"string"},"type":"array","title":"Notificationchannels","description":"Notification channel IDs for this rule","examples":[["channel-1"]]},"visibility":{"$ref":"#/components/schemas/Visibility","description":"Visibility scope","default":"private","examples":["private"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","description":"Group IDs this rule is shared with (when visibility=group)","examples":[["group-ops"]]},"isHidden":{"type":"boolean","title":"Ishidden","description":"Whether this rule is hidden for the current user","default":false,"examples":[false]}},"type":"object","required":["name","expression","severity","groupName"],"title":"AlertRule"},"AlertRuleCreate":{"properties":{"orgId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Orgid","description":"Optional org_id (API key) to scope this rule to","examples":["org-abc"]},"name":{"type":"string","maxLength":100,"minLength":1,"title":"Name","description":"Rule name","examples":["HighCpuUsage"]},"expression":{"type":"string","title":"Expression","description":"Prometheus expression for the alert rule","examples":["sum(rate(node_cpu_seconds_total{mode!=\"idle\"}[5m])) > 0.95"]},"severity":{"$ref":"#/components/schemas/RuleSeverity","description":"Severity level of the alert rule","examples":["critical"]},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Description of the alert rule","examples":["CPU usage is critically high"]},"enabled":{"type":"boolean","title":"Enabled","description":"Whether the rule is enabled","default":true,"examples":[true]},"labels":{"additionalProperties":{"type":"string"},"type":"object","title":"Labels","description":"Labels to add to alerts from this rule","examples":[{"service":"api"}]},"annotations":{"additionalProperties":{"type":"string"},"type":"object","title":"Annotations","description":"Annotations to add to alerts from this rule","examples":[{"summary":"API CPU alert"}]},"for":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"For","description":"Duration to wait before firing the alert","examples":["5m"]},"groupName":{"type":"string","title":"Groupname","description":"Name of the rule group this rule belongs to","examples":["watchdog-default"]},"groupInterval":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Groupinterval","description":"Interval between evaluations of this rule group","examples":["1m"]},"notificationChannels":{"items":{"type":"string"},"type":"array","title":"Notificationchannels","description":"Notification channel IDs for this rule","examples":[["channel-1"]]},"visibility":{"$ref":"#/components/schemas/Visibility","description":"Visibility scope","default":"private","examples":["private"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","description":"Group IDs to share with","examples":[["group-ops"]]}},"additionalProperties":false,"type":"object","required":["name","expression","severity","groupName"],"title":"AlertRuleCreate"},"AlertState":{"type":"string","enum":["unprocessed","active","suppressed"],"title":"AlertState"},"AlertStatus":{"properties":{"state":{"$ref":"#/components/schemas/AlertState","description":"Current state of the alert","examples":["active"]},"silencedBy":{"items":{"type":"string"},"type":"array","title":"Silencedby","description":"List of silences that silence this alert","examples":[["silence-123"]]},"inhibitedBy":{"items":{"type":"string"},"type":"array","title":"Inhibitedby","description":"List of alerts that inhibit this alert","examples":[["alert-456"]]}},"type":"object","required":["state"],"title":"AlertStatus"},"AlertWebhookRequest":{"properties":{"alerts":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Alerts","examples":[[{"labels":{"alertname":"HighCpuUsage","severity":"critical"}}]]}},"additionalProperties":true,"type":"object","title":"AlertWebhookRequest"},"ChannelType":{"type":"string","enum":["email","slack","teams","webhook","pagerduty"],"title":"ChannelType"},"GroupSharePruneRequest":{"properties":{"tenantId":{"type":"string","minLength":1,"pattern":"^[^\\x00]+$","title":"Tenantid","examples":["tenant-01"]},"groupId":{"type":"string","minLength":1,"pattern":"^[^\\x00]+$","title":"Groupid","examples":["group-ops"]},"removedUserIds":{"items":{"type":"string","minLength":1,"pattern":"^[^\\x00]+$"},"type":"array","title":"Removeduserids","examples":[["user-42"]]},"removedUsernames":{"items":{"type":"string","minLength":1,"pattern":"^[^\\x00]+$"},"type":"array","title":"Removedusernames","examples":[["alice"]]}},"type":"object","required":["tenantId","groupId"],"title":"GroupSharePruneRequest"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"IncidentJiraCreateRequest":{"properties":{"integrationId":{"type":"string","title":"Integrationid","examples":["jira-int-01"]},"projectKey":{"type":"string","title":"Projectkey","examples":["OPS"]},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary","examples":["Investigate HighCpuUsage incident"]},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","examples":["CPU usage has remained above 95% for five minutes."]},"issueType":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Issuetype","examples":["Task"]},"replaceExisting":{"type":"boolean","title":"Replaceexisting","default":false,"examples":[false]}},"type":"object","required":["integrationId","projectKey"],"title":"IncidentJiraCreateRequest"},"IncidentNote":{"properties":{"author":{"type":"string","title":"Author","examples":["alice@example.com"]},"text":{"type":"string","title":"Text","examples":["Investigating elevated CPU on api-01"]},"createdAt":{"type":"string","title":"Createdat","examples":["2026-04-03T12:00:00Z"]}},"type":"object","required":["author","text","createdAt"],"title":"IncidentNote"},"IncidentStatus":{"type":"string","enum":["open","resolved"],"title":"IncidentStatus"},"IncidentVisibility":{"type":"string","enum":["public","private","group"],"title":"IncidentVisibility"},"JiraConfigUpdateRequest":{"properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Enabled","examples":[true]},"baseUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Baseurl","examples":["https://jira.example.internal"]},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email","examples":["jira-bot@example.com"]},"apiToken":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Apitoken","examples":["jira-api-token"]},"bearerToken":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bearertoken","examples":["jira-bearer-token"]}},"additionalProperties":false,"type":"object","title":"JiraConfigUpdateRequest"},"JiraIntegrationCreateRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name","examples":["Primary Jira"]},"enabled":{"type":"boolean","title":"Enabled","default":true,"examples":[true]},"visibility":{"type":"string","title":"Visibility","default":"private","examples":["group"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","examples":[["group-ops"]]},"baseUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Baseurl","examples":["https://jira.example.internal"]},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email","examples":["jira-bot@example.com"]},"apiToken":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Apitoken","examples":["jira-api-token"]},"bearerToken":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bearertoken","examples":["jira-bearer-token"]},"authMode":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authmode","examples":["api_token"]},"supportsSso":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Supportssso","examples":[false]}},"additionalProperties":false,"type":"object","title":"JiraIntegrationCreateRequest"},"JiraIntegrationUpdateRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name","examples":["Primary Jira"]},"enabled":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Enabled","examples":[true]},"visibility":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Visibility","examples":["group"]},"sharedGroupIds":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Sharedgroupids","examples":[["group-ops"]]},"baseUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Baseurl","examples":["https://jira.example.internal"]},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email","examples":["jira-bot@example.com"]},"apiToken":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Apitoken","examples":["jira-api-token"]},"bearerToken":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bearertoken","examples":["jira-bearer-token"]},"authMode":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authmode","examples":["api_token"]},"supportsSso":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Supportssso","examples":[false]}},"additionalProperties":false,"type":"object","title":"JiraIntegrationUpdateRequest"},"Matcher":{"properties":{"name":{"type":"string","title":"Name","description":"Label name to match","examples":["alertname"]},"value":{"type":"string","title":"Value","description":"Value to match against","examples":["HighCpuUsage"]},"isRegex":{"type":"boolean","title":"Isregex","description":"Whether the value is a regular expression","default":false,"examples":[false]},"isEqual":{"type":"boolean","title":"Isequal","description":"Whether to match equal values","default":true,"examples":[true]}},"additionalProperties":false,"type":"object","required":["name","value"],"title":"Matcher"},"NotificationChannel":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id","description":"Unique identifier","examples":["channel-123"]},"name":{"type":"string","title":"Name","description":"Channel name","examples":["Primary Slack"]},"type":{"$ref":"#/components/schemas/ChannelType","description":"Channel type","examples":["slack"]},"enabled":{"type":"boolean","title":"Enabled","description":"Whether the channel is enabled","default":true,"examples":[true]},"config":{"additionalProperties":true,"type":"object","title":"Config","description":"Channel-specific configuration","examples":[{"webhookUrl":"https://hooks.slack.com/services/T000/B000/XXX"}]},"createdBy":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Createdby","description":"Owner user id","examples":["user-42"]},"visibility":{"$ref":"#/components/schemas/Visibility","description":"Visibility scope","default":"private","examples":["private"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","description":"Group IDs this channel is shared with (when visibility=group)","examples":[["group-ops"]]},"isHidden":{"type":"boolean","title":"Ishidden","description":"Whether this channel is hidden for the current user","default":false,"examples":[false]}},"type":"object","required":["name","type","config"],"title":"NotificationChannel"},"NotificationChannelCreate":{"properties":{"name":{"type":"string","maxLength":100,"minLength":1,"title":"Name","description":"Channel name","examples":["Primary Slack"]},"type":{"$ref":"#/components/schemas/ChannelType","description":"Channel type","examples":["slack"]},"enabled":{"type":"boolean","title":"Enabled","description":"Whether the channel is enabled","default":true,"examples":[true]},"config":{"additionalProperties":true,"type":"object","title":"Config","description":"Channel-specific configuration","examples":[{"webhookUrl":"https://hooks.slack.com/services/T000/B000/XXX"}]},"visibility":{"$ref":"#/components/schemas/Visibility","description":"Visibility scope","default":"private","examples":["private"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","description":"Group IDs to share with","examples":[["group-ops"]]}},"additionalProperties":false,"type":"object","required":["name","type","config"],"title":"NotificationChannelCreate"},"RuleImportRequest":{"properties":{"yamlContent":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Yamlcontent","examples":["groups:\n - name: watchdog-default\n rules:\n - alert: HighCpuUsage"]},"defaults":{"additionalProperties":true,"type":"object","title":"Defaults","examples":[{"labels":{"team":"platform"}}]},"dryRun":{"type":"boolean","title":"Dryrun","default":false,"examples":[true]}},"type":"object","title":"RuleImportRequest"},"RuleSeverity":{"type":"string","enum":["info","warning","error","critical"],"title":"RuleSeverity"},"Silence":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id","description":"Unique identifier for the silence","examples":["silence-123"]},"matchers":{"items":{"$ref":"#/components/schemas/Matcher"},"type":"array","title":"Matchers","description":"Matchers that define which alerts to silence","examples":[[{"isEqual":true,"isRegex":false,"name":"alertname","value":"HighCpuUsage"}]]},"startsAt":{"type":"string","title":"Startsat","description":"Time when the silence starts","examples":["2026-04-03T12:00:00Z"]},"endsAt":{"type":"string","title":"Endsat","description":"Time when the silence ends","examples":["2026-04-03T13:00:00Z"]},"createdBy":{"type":"string","title":"Createdby","description":"User who created the silence","examples":["user-42"]},"comment":{"type":"string","title":"Comment","description":"Comment explaining the silence","examples":["Suppress noisy deploy alert while maintenance is in progress"]},"status":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Status","description":"Current status of the silence","examples":[{"state":"active"}]},"visibility":{"anyOf":[{"$ref":"#/components/schemas/Visibility"},{"type":"null"}],"description":"Visibility scope","examples":["private"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","description":"Group IDs this silence is shared with","examples":[["group-ops"]]},"isHidden":{"type":"boolean","title":"Ishidden","description":"Whether this silence is hidden for the current user","default":false,"examples":[false]}},"type":"object","required":["matchers","startsAt","endsAt","createdBy","comment"],"title":"Silence"},"SilenceCreateRequest":{"properties":{"matchers":{"items":{"$ref":"#/components/schemas/Matcher"},"type":"array","minItems":1,"title":"Matchers","description":"Matchers that define which alerts to silence","examples":[[{"isEqual":true,"isRegex":false,"name":"alertname","value":"HighCpuUsage"}]]},"startsAt":{"type":"string","title":"Startsat","description":"Time when the silence starts","examples":["2026-04-03T12:00:00Z"]},"endsAt":{"type":"string","title":"Endsat","description":"Time when the silence ends","examples":["2026-04-03T13:00:00Z"]},"comment":{"type":"string","title":"Comment","description":"Comment explaining the silence","examples":["Suppress noisy deploy alert while maintenance is in progress"]},"visibility":{"$ref":"#/components/schemas/Visibility","description":"Visibility scope","default":"private","examples":["private"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","description":"Group IDs to share with","examples":[["group-ops"]]}},"additionalProperties":false,"type":"object","required":["matchers","startsAt","endsAt","comment"],"title":"SilenceCreateRequest"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"Visibility":{"type":"string","enum":["private","group","tenant","public"],"title":"Visibility"},"routers__observability__alerts__shared__HideTogglePayload":{"properties":{"hidden":{"type":"boolean","title":"Hidden","default":true,"examples":[true]}},"type":"object","title":"HideTogglePayload"},"routers__observability__jira__shared__HideTogglePayload":{"properties":{"hidden":{"type":"boolean","title":"Hidden","default":true}},"type":"object","title":"HideTogglePayload"}},"securitySchemes":{"HTTPBearer":{"type":"http","scheme":"bearer"}}},"jsonSchemaDialect":"https://spec.openapis.org/oas/3.1/dialect/base"} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Notifier","description":"Internal alerting service for Watchdog","version":"0.0.5"},"paths":{"/internal/v1/api/alertmanager/alerts":{"get":{"tags":["alertmanager"],"summary":"List Alerts","description":"Lists alertmanager alerts with optional state and label filtering for the current user scope.","operationId":"list_alerts","security":[{"HTTPBearer":[]}],"parameters":[{"name":"active","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Active"}},{"name":"silenced","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Silenced"}},{"name":"inhibited","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Inhibited"}},{"name":"filter_labels","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filter Labels"}},{"name":"show_hidden","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Show Hidden"}}],"responses":{"200":{"description":"The alerts visible to the current caller.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Alert-Output"},"title":"Response List Alerts"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["alertmanager"],"summary":"Create Alerts","description":"Submits one or more alerts to alertmanager for processing.","operationId":"create_alerts","security":[{"HTTPBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Alert-Input"},"title":"Alerts"}}}},"responses":{"200":{"description":"The submission result including the number of alerts accepted.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Create Alerts"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["alertmanager"],"summary":"Delete Alerts","description":"Deletes alerts in alertmanager that match the provided label filter set.","operationId":"delete_alerts","security":[{"HTTPBearer":[]}],"parameters":[{"name":"filter_labels","in":"query","required":true,"schema":{"type":"string","title":"Filter Labels"}}],"responses":{"200":{"description":"The deletion result for the matched alerts.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Alerts"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/alerts/groups":{"get":{"tags":["alertmanager"],"summary":"List Alert Groups","description":"Lists grouped alerts from alertmanager with optional label filtering.","operationId":"list_alert_groups","security":[{"HTTPBearer":[]}],"parameters":[{"name":"filter_labels","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filter Labels"}}],"responses":{"200":{"description":"Grouped alerts returned by alertmanager.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlertGroup"},"title":"Response List Alert Groups"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/access/group-shares/prune":{"post":{"tags":["alertmanager"],"summary":"Prune Group Shares","description":"Removes stale shared-group visibility references for users that were removed from a group.","operationId":"prune_group_shares","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GroupSharePruneRequest"}}},"required":true},"responses":{"200":{"description":"The counts of updated records and pruned silences.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Prune Group Shares"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/integrations/channel-types":{"get":{"tags":["alertmanager"],"summary":"List Allowed Channel Types","description":"Lists notification channel types that are currently enabled for alertmanager integrations.","operationId":"list_channel_types","responses":{"200":{"description":"The channel types allowed by integration policy.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response List Channel Types"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"}},"security":[{"HTTPBearer":[]}]}},"/internal/v1/api/alertmanager/silences":{"get":{"tags":["alertmanager-silences"],"summary":"List Silences","description":"Lists silences visible to the current user with optional label and expiration filtering.","operationId":"list_silences","security":[{"HTTPBearer":[]}],"parameters":[{"name":"filter_labels","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filter Labels"}},{"name":"include_expired","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Expired"}},{"name":"show_hidden","in":"query","required":false,"schema":{"type":"string","pattern":"^(true|false)$","default":"false","title":"Show Hidden"}}],"responses":{"200":{"description":"The silences visible to the current caller.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Silence"},"title":"Response List Silences"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["alertmanager-silences"],"summary":"Create Silence","description":"Creates a new silence in alertmanager for the current user scope.","operationId":"create_silence","security":[{"HTTPBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SilenceCreateRequest"}}}},"responses":{"200":{"description":"The created silence identifier and operation result.","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"string"},"title":"Response Create Silence"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/silences/{silence_id}":{"get":{"tags":["alertmanager-silences"],"summary":"Get Silence","description":"Returns a single silence when it exists and is visible to the current user.","operationId":"get_silence","security":[{"HTTPBearer":[]}],"parameters":[{"name":"silence_id","in":"path","required":true,"schema":{"type":"string","title":"Silence Id"}},{"name":"show_hidden","in":"query","required":false,"schema":{"type":"string","pattern":"^(true|false)$","default":"false","title":"Show Hidden"}}],"responses":{"200":{"description":"The requested silence.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Silence"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["alertmanager-silences"],"summary":"Update Silence","description":"Updates an existing silence when the caller owns it and still has access.","operationId":"update_silence","security":[{"HTTPBearer":[]}],"parameters":[{"name":"silence_id","in":"path","required":true,"schema":{"type":"string","title":"Silence Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SilenceCreateRequest"}}}},"responses":{"200":{"description":"The updated silence identifier and operation result.","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"string"},"title":"Response Update Silence"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["alertmanager-silences"],"summary":"Delete Silence","description":"Deletes an existing silence when the caller owns it and still has access.","operationId":"delete_silence","security":[{"HTTPBearer":[]}],"parameters":[{"name":"silence_id","in":"path","required":true,"schema":{"type":"string","title":"Silence Id"}}],"responses":{"200":{"description":"The deletion result for the specified silence.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Silence"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/silences/{silence_id}/hide":{"post":{"tags":["alertmanager-silences"],"summary":"Hide Silence","description":"Toggles whether a shared silence is hidden for the current user.","operationId":"hide_silence","security":[{"HTTPBearer":[]}],"parameters":[{"name":"silence_id","in":"path","required":true,"schema":{"type":"string","title":"Silence Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/routers__observability__alerts__shared__HideTogglePayload"}}}},"responses":{"200":{"description":"The hide state applied to the silence.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Hide Silence"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/status":{"get":{"tags":["alertmanager"],"summary":"Get Alertmanager Status","description":"Returns alertmanager runtime status and cluster metadata.","operationId":"get_alertmanager_status","responses":{"200":{"description":"The current alertmanager status payload.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertManagerStatus"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"}},"security":[{"HTTPBearer":[]}]}},"/internal/v1/api/alertmanager/receivers":{"get":{"tags":["alertmanager"],"summary":"List Receivers","description":"Lists alertmanager receiver names available for routing and inspection.","operationId":"list_receivers","responses":{"200":{"description":"The configured alertmanager receiver names.","content":{"application/json":{"schema":{"items":{"type":"string"},"type":"array","title":"Response List Receivers"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"}},"security":[{"HTTPBearer":[]}]}},"/internal/v1/api/alertmanager/rules/import":{"post":{"tags":["alertmanager-rules"],"summary":"Import Alert Rules","description":"Imports alert rules from YAML, optionally previewing the parsed rules without persisting them.","operationId":"import_rules","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RuleImportRequest"}}},"required":true},"responses":{"200":{"description":"The imported or previewed rule set with creation and update counts.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Import Rules"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/internal/v1/api/alertmanager/rules":{"get":{"tags":["alertmanager-rules"],"summary":"List Alert Rules","description":"Lists alert rules visible to the current user with pagination support.","operationId":"list_rules","security":[{"HTTPBearer":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":1000,"minimum":1,"default":20,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"show_hidden","in":"query","required":false,"schema":{"type":"string","pattern":"^(true|false)$","default":"false","title":"Show Hidden"}}],"responses":{"200":{"description":"The alert rules visible to the current caller.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlertRule"},"title":"Response List Rules"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["alertmanager-rules"],"summary":"Create Alert Rule","description":"Creates a new alert rule and synchronizes it to the backing Mimir rule set.","operationId":"create_rule","security":[{"HTTPBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertRuleCreate"}}}},"responses":{"201":{"description":"The newly created alert rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertRule"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/public/rules":{"get":{"tags":["alertmanager-rules"],"summary":"List Public Alert Rules","description":"Lists public alert rules for the default tenant after public endpoint protections are enforced.","operationId":"list_public_rules","responses":{"200":{"description":"The public alert rules available for the default tenant.","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/AlertRule"},"type":"array","title":"Response List Public Rules"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"}}}},"/internal/v1/api/alertmanager/metrics/names":{"get":{"tags":["alertmanager-rules"],"summary":"List Metric Names","description":"Lists metric names available in Mimir for the resolved organization scope.","operationId":"list_metric_names","security":[{"HTTPBearer":[]}],"parameters":[{"name":"orgId","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Orgid"}}],"responses":{"200":{"description":"The resolved organization identifier and metric names.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Metric Names"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/metrics/query":{"get":{"tags":["alertmanager-rules"],"summary":"Evaluate PromQL Query","description":"Evaluates a PromQL query against Mimir for the resolved organization scope.","operationId":"query_metrics","security":[{"HTTPBearer":[]}],"parameters":[{"name":"query","in":"query","required":true,"schema":{"type":"string","minLength":1,"title":"Query"}},{"name":"orgId","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Orgid"}},{"name":"sampleLimit","in":"query","required":false,"schema":{"type":"integer","maximum":20,"minimum":1,"default":5,"title":"Samplelimit"}}],"responses":{"200":{"description":"The evaluated query result returned from Mimir.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Query Metrics"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/metrics/labels":{"get":{"tags":["alertmanager-rules"],"summary":"List Metric Labels","description":"Lists label names available in Mimir for the resolved organization scope.","operationId":"list_metric_labels","security":[{"HTTPBearer":[]}],"parameters":[{"name":"orgId","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Orgid"}}],"responses":{"200":{"description":"The resolved organization identifier and metric label names.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Metric Labels"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/metrics/label-values/{label}":{"get":{"tags":["alertmanager-rules"],"summary":"List Metric Label Values","description":"Lists values for a metric label in Mimir for the resolved organization scope.","operationId":"list_metric_label_values","security":[{"HTTPBearer":[]}],"parameters":[{"name":"label","in":"path","required":true,"schema":{"type":"string","title":"Label"}},{"name":"orgId","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Orgid"}},{"name":"metricName","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Metricname"}}],"responses":{"200":{"description":"The resolved organization identifier and metric label values.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Metric Label Values"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"404":{"description":"Not Found"}}}},"/internal/v1/api/alertmanager/rules/{rule_id}":{"get":{"tags":["alertmanager-rules"],"summary":"Get Alert Rule","description":"Returns a single alert rule when it exists and is visible to the current user.","operationId":"get_rule","security":[{"HTTPBearer":[]}],"parameters":[{"name":"rule_id","in":"path","required":true,"schema":{"type":"string","title":"Rule Id"}}],"responses":{"200":{"description":"The requested alert rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertRule"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["alertmanager-rules"],"summary":"Update Alert Rule","description":"Updates an existing alert rule and synchronizes affected Mimir rule sets.","operationId":"update_rule","security":[{"HTTPBearer":[]}],"parameters":[{"name":"rule_id","in":"path","required":true,"schema":{"type":"string","title":"Rule Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertRuleCreate"}}}},"responses":{"200":{"description":"The updated alert rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertRule"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["alertmanager-rules"],"summary":"Delete Alert Rule","description":"Deletes an alert rule and synchronizes the backing Mimir rule set.","operationId":"delete_rule","security":[{"HTTPBearer":[]}],"parameters":[{"name":"rule_id","in":"path","required":true,"schema":{"type":"string","title":"Rule Id"}}],"responses":{"200":{"description":"The deletion result for the alert rule.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Rule"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/rules/{rule_id}/hide":{"post":{"tags":["alertmanager-rules"],"summary":"Hide Alert Rule","description":"Toggles whether a shared alert rule is hidden for the current user.","operationId":"hide_rule","security":[{"HTTPBearer":[]}],"parameters":[{"name":"rule_id","in":"path","required":true,"schema":{"type":"string","title":"Rule Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/routers__observability__alerts__shared__HideTogglePayload"}}}},"responses":{"200":{"description":"The hide state applied to the alert rule.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Hide Rule"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/rules/{rule_id}/test":{"post":{"tags":["alertmanager-rules"],"summary":"Test Alert Rule","description":"Builds a synthetic alert from the rule and sends test notifications through its configured channels.","operationId":"test_rule","security":[{"HTTPBearer":[]}],"parameters":[{"name":"rule_id","in":"path","required":true,"schema":{"type":"string","title":"Rule Id"}}],"responses":{"200":{"description":"The test execution result across the configured notification channels.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Test Rule"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/channels":{"get":{"tags":["alertmanager-channels"],"summary":"List Notification Channels","description":"Lists notification channels visible to the current user.","operationId":"list_channels","security":[{"HTTPBearer":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":1000,"minimum":1,"default":20,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"show_hidden","in":"query","required":false,"schema":{"type":"string","pattern":"^(true|false)$","default":"false","title":"Show Hidden"}}],"responses":{"200":{"description":"The notification channels visible to the current caller.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/NotificationChannel"},"title":"Response List Channels"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["alertmanager-channels"],"summary":"Create Notification Channel","description":"Creates a new notification channel for the current tenant scope.","operationId":"create_channel","security":[{"HTTPBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationChannelCreate"}}}},"responses":{"201":{"description":"The newly created notification channel.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationChannel"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/channels/{channel_id}":{"get":{"tags":["alertmanager-channels"],"summary":"Get Notification Channel","description":"Returns a single notification channel when it exists and is visible to the current user.","operationId":"get_channel","security":[{"HTTPBearer":[]}],"parameters":[{"name":"channel_id","in":"path","required":true,"schema":{"type":"string","title":"Channel Id"}}],"responses":{"200":{"description":"The requested notification channel.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationChannel"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["alertmanager-channels"],"summary":"Update Notification Channel","description":"Updates an existing notification channel in the current tenant scope.","operationId":"update_channel","security":[{"HTTPBearer":[]}],"parameters":[{"name":"channel_id","in":"path","required":true,"schema":{"type":"string","title":"Channel Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationChannelCreate"}}}},"responses":{"200":{"description":"The updated notification channel.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationChannel"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["alertmanager-channels"],"summary":"Delete Notification Channel","description":"Deletes an existing notification channel when the caller has access.","operationId":"delete_channel","security":[{"HTTPBearer":[]}],"parameters":[{"name":"channel_id","in":"path","required":true,"schema":{"type":"string","title":"Channel Id"}}],"responses":{"200":{"description":"The deletion result for the notification channel.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Channel"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"400":{"description":"Bad Request"}}}},"/internal/v1/api/alertmanager/channels/{channel_id}/hide":{"post":{"tags":["alertmanager-channels"],"summary":"Hide Notification Channel","description":"Toggles whether a shared notification channel is hidden for the current user.","operationId":"hide_channel","security":[{"HTTPBearer":[]}],"parameters":[{"name":"channel_id","in":"path","required":true,"schema":{"type":"string","title":"Channel Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/routers__observability__alerts__shared__HideTogglePayload"}}}},"responses":{"200":{"description":"The hide state applied to the notification channel.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Hide Channel"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/channels/{channel_id}/test":{"post":{"tags":["alertmanager-channels"],"summary":"Test Notification Channel","description":"Sends a test notification through the specified notification channel.","operationId":"test_channel","security":[{"HTTPBearer":[]}],"parameters":[{"name":"channel_id","in":"path","required":true,"schema":{"type":"string","title":"Channel Id"}}],"responses":{"200":{"description":"The test delivery result for the notification channel.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Test Channel"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/incidents":{"get":{"tags":["alertmanager-incidents"],"summary":"List Incidents","description":"Lists alert incidents visible to the current user with optional status, visibility, and group filters.","operationId":"list_incidents","security":[{"HTTPBearer":[]}],"parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"}},{"name":"visibility","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Visibility"}},{"name":"group_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Group Id"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":1,"default":100,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}}],"responses":{"200":{"description":"The incidents visible to the current caller.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlertIncident"},"title":"Response List Incidents"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/incidents/summary":{"get":{"tags":["alertmanager-incidents"],"summary":"Get Incident Summary","description":"Returns an aggregated summary of incidents visible to the current user.","operationId":"get_incident_summary","responses":{"200":{"description":"Aggregated incident summary counts and breakdowns.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Get Incident Summary"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"}},"security":[{"HTTPBearer":[]}]}},"/internal/v1/api/alertmanager/incidents/{incident_id}":{"patch":{"tags":["alertmanager-incidents"],"summary":"Update Incident","description":"Updates an incident's status, assignment, Jira metadata, or visibility settings.","operationId":"update_incident","security":[{"HTTPBearer":[]}],"parameters":[{"name":"incident_id","in":"path","required":true,"schema":{"type":"string","title":"Incident Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertIncidentUpdateRequest"}}}},"responses":{"200":{"description":"The updated incident.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertIncident"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/jira/config":{"get":{"tags":["alertmanager-jira"],"summary":"Get Jira Config","description":"Returns the tenant-level Jira configuration with secrets masked into presence flags.","operationId":"get_jira_config","responses":{"200":{"description":"The current tenant Jira configuration.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Get Jira Config"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"404":{"description":"Not Found"}},"security":[{"HTTPBearer":[]}]},"put":{"tags":["alertmanager-jira"],"summary":"Update Jira Config","description":"Updates the tenant-level Jira configuration used when an explicit integration is not selected.","operationId":"update_jira_config","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JiraConfigUpdateRequest"}}},"required":true},"responses":{"200":{"description":"The saved tenant Jira configuration.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Update Jira Config"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/internal/v1/api/alertmanager/jira/projects":{"get":{"tags":["alertmanager-jira"],"summary":"List Jira Projects","description":"Lists Jira projects using either the tenant Jira config or a selected integration.","operationId":"list_jira_projects","security":[{"HTTPBearer":[]}],"parameters":[{"name":"integrationId","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integrationid"}}],"responses":{"200":{"description":"The available Jira projects for the resolved credentials.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Jira Projects"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/jira/projects/{project_key}/issue-types":{"get":{"tags":["alertmanager-jira"],"summary":"List Jira Issue Types","description":"Lists Jira issue types for a project using either the tenant Jira config or a selected integration.","operationId":"list_jira_issue_types","security":[{"HTTPBearer":[]}],"parameters":[{"name":"project_key","in":"path","required":true,"schema":{"type":"string","title":"Project Key"}},{"name":"integrationId","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integrationid"}}],"responses":{"200":{"description":"The available Jira issue types for the requested project.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Jira Issue Types"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/integrations/jira/{integration_id}/projects":{"get":{"tags":["alertmanager-jira"],"summary":"List Jira Projects By Integration","description":"Lists Jira projects using a specific Jira integration.","operationId":"list_integration_projects","security":[{"HTTPBearer":[]}],"parameters":[{"name":"integration_id","in":"path","required":true,"schema":{"type":"string","title":"Integration Id"}}],"responses":{"200":{"description":"The Jira projects available through the selected integration.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Integration Projects"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/integrations/jira/{integration_id}/projects/{project_key}/issue-types":{"get":{"tags":["alertmanager-jira"],"summary":"List Jira Issue Types By Integration","description":"Lists Jira issue types for a project using a specific Jira integration.","operationId":"list_integration_issue_types","security":[{"HTTPBearer":[]}],"parameters":[{"name":"integration_id","in":"path","required":true,"schema":{"type":"string","title":"Integration Id"}},{"name":"project_key","in":"path","required":true,"schema":{"type":"string","title":"Project Key"}}],"responses":{"200":{"description":"The Jira issue types available through the selected integration.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Integration Issue Types"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/integrations/jira":{"get":{"tags":["alertmanager-jira"],"summary":"List Jira Integrations","description":"Lists Jira integrations visible to the current user, including hidden state when requested.","operationId":"list_jira_integrations","security":[{"HTTPBearer":[]}],"parameters":[{"name":"show_hidden","in":"query","required":false,"schema":{"type":"string","pattern":"^(true|false)$","default":"false","title":"Show Hidden"}}],"responses":{"200":{"description":"The Jira integrations visible to the current caller.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Jira Integrations"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["alertmanager-jira"],"summary":"Create Jira Integration","description":"Creates a new Jira integration for the current tenant.","operationId":"create_jira_integration","security":[{"HTTPBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JiraIntegrationCreateRequest"}}}},"responses":{"200":{"description":"The created Jira integration with sensitive values masked.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Create Jira Integration"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/integrations/jira/{integration_id}":{"put":{"tags":["alertmanager-jira"],"summary":"Update Jira Integration","description":"Updates an existing Jira integration owned by the current user.","operationId":"update_jira_integration","security":[{"HTTPBearer":[]}],"parameters":[{"name":"integration_id","in":"path","required":true,"schema":{"type":"string","title":"Integration Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JiraIntegrationUpdateRequest"}}}},"responses":{"200":{"description":"The updated Jira integration with sensitive values masked.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Update Jira Integration"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["alertmanager-jira"],"summary":"Delete Jira Integration","description":"Deletes a Jira integration owned by the current user and unlinks it from incidents.","operationId":"delete_jira_integration","security":[{"HTTPBearer":[]}],"parameters":[{"name":"integration_id","in":"path","required":true,"schema":{"type":"string","title":"Integration Id"}}],"responses":{"200":{"description":"The deletion result and count of incidents unlinked from the integration.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Jira Integration"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"400":{"description":"Bad Request"}}}},"/internal/v1/api/alertmanager/integrations/jira/{integration_id}/hide":{"post":{"tags":["alertmanager-jira"],"summary":"Hide Jira Integration","description":"Toggles whether a shared Jira integration is hidden for the current user.","operationId":"hide_jira_integration","security":[{"HTTPBearer":[]}],"parameters":[{"name":"integration_id","in":"path","required":true,"schema":{"type":"string","title":"Integration Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/routers__observability__jira__shared__HideTogglePayload"}}}},"responses":{"200":{"description":"The hide state applied to the Jira integration.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Hide Jira Integration"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/incidents/{incident_id}/jira":{"post":{"tags":["alertmanager-jira"],"summary":"Create Incident Jira Link","description":"Creates a Jira issue for an incident and stores the Jira linkage on the incident record.","operationId":"create_incident_link","security":[{"HTTPBearer":[]}],"parameters":[{"name":"incident_id","in":"path","required":true,"schema":{"type":"string","title":"Incident Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IncidentJiraCreateRequest"}}}},"responses":{"200":{"description":"The incident updated with Jira linkage metadata.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertIncident"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"409":{"description":"Conflict"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/incidents/{incident_id}/jira/sync-notes":{"post":{"tags":["alertmanager-jira"],"summary":"Sync Incident Jira Notes","description":"Backfills incident notes to the linked Jira issue while skipping notes already present.","operationId":"sync_incident_notes","security":[{"HTTPBearer":[]}],"parameters":[{"name":"incident_id","in":"path","required":true,"schema":{"type":"string","title":"Incident Id"}}],"responses":{"200":{"description":"The note synchronization result for the linked Jira issue.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Sync Incident Notes"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/api/alertmanager/incidents/{incident_id}/jira/comments":{"get":{"tags":["alertmanager-jira"],"summary":"List Incident Jira Comments","description":"Lists comments from the Jira issue linked to the specified incident when credentials are available.","operationId":"list_incident_comments","security":[{"HTTPBearer":[]}],"parameters":[{"name":"incident_id","in":"path","required":true,"schema":{"type":"string","title":"Incident Id"}}],"responses":{"200":{"description":"The Jira comments associated with the incident's linked issue.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response List Incident Comments"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"404":{"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/alertmanager/alerts/webhook":{"post":{"tags":["alertmanager-webhooks"],"summary":"Receive Alert Webhook","description":"Receives general alert webhook payloads and dispatches notifications for the inferred tenant.","operationId":"receive_alert_webhook","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertWebhookRequest"}}},"required":true},"responses":{"200":{"description":"The webhook processing result for the submitted alerts.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Receive Alert Webhook"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/alertmanager/alerts/critical":{"post":{"tags":["alertmanager-webhooks"],"summary":"Receive Critical Alert Webhook","description":"Receives critical alert webhook payloads and dispatches critical notifications for the inferred tenant.","operationId":"receive_critical_webhook","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertWebhookRequest"}}},"required":true},"responses":{"200":{"description":"The webhook processing result for the submitted critical alerts.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Receive Critical Webhook"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/internal/v1/alertmanager/alerts/warning":{"post":{"tags":["alertmanager-webhooks"],"summary":"Receive Warning Alert Webhook","description":"Receives warning alert webhook payloads and dispatches warning notifications for the inferred tenant.","operationId":"receive_warning_webhook","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertWebhookRequest"}}},"required":true},"responses":{"200":{"description":"The webhook processing result for the submitted warning alerts.","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Receive Warning Webhook"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"429":{"description":"Too Many Requests"},"400":{"description":"Bad Request"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/health":{"get":{"tags":["system"],"summary":"Service Health","description":"Returns a lightweight health status for the notifier service.","operationId":"health","responses":{"200":{"description":"The current health status for the notifier service.","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Health"}}}}}}},"/ready":{"get":{"tags":["system"],"summary":"Service Readiness","description":"Runs readiness checks required for notifier to serve traffic.","operationId":"readiness","responses":{"200":{"description":"The readiness result and individual dependency checks.","content":{"application/json":{"schema":{}}}}}}}},"components":{"schemas":{"Alert-Input":{"properties":{"labels":{"additionalProperties":{"type":"string"},"type":"object","title":"Labels","description":"Key-value pairs that identify the alert","examples":[{"alertname":"HighCpuUsage","severity":"critical"}]},"annotations":{"additionalProperties":{"type":"string"},"type":"object","title":"Annotations","description":"Additional information about the alert","examples":[{"description":"Node cpu is saturated","summary":"CPU usage above 95%"}]},"startsAt":{"type":"string","title":"Startsat","description":"Time when the alert started firing","examples":["2026-04-03T12:00:00Z"]},"endsAt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Endsat","description":"Time when the alert stopped firing","examples":["2026-04-03T12:15:00Z"]},"generatorURL":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Generatorurl","description":"URL of the alert generator","examples":["https://grafana.example.internal/alerting/grafana/high-cpu"]},"status":{"$ref":"#/components/schemas/AlertStatus","description":"Current status of the alert"},"receivers":{"anyOf":[{"items":{"anyOf":[{"type":"string"},{"additionalProperties":true,"type":"object"}]},"type":"array"},{"type":"null"}],"title":"Receivers","description":"List of receivers for this alert","examples":[["primary-oncall",{"channel":"#alerts","type":"slack"}]]},"fingerprint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fingerprint","description":"Unique identifier for the alert","examples":["01ARZ3NDEKTSV4RRFFQ69G5FAV"]}},"type":"object","required":["labels","startsAt","status"],"title":"Alert"},"Alert-Output":{"properties":{"labels":{"additionalProperties":{"type":"string"},"type":"object","title":"Labels","description":"Key-value pairs that identify the alert","examples":[{"alertname":"HighCpuUsage","severity":"critical"}]},"annotations":{"additionalProperties":{"type":"string"},"type":"object","title":"Annotations","description":"Additional information about the alert","examples":[{"description":"Node cpu is saturated","summary":"CPU usage above 95%"}]},"startsAt":{"type":"string","title":"Startsat","description":"Time when the alert started firing","examples":["2026-04-03T12:00:00Z"]},"endsAt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Endsat","description":"Time when the alert stopped firing","examples":["2026-04-03T12:15:00Z"]},"generatorURL":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Generatorurl","description":"URL of the alert generator","examples":["https://grafana.example.internal/alerting/grafana/high-cpu"]},"status":{"$ref":"#/components/schemas/AlertStatus","description":"Current status of the alert"},"receivers":{"anyOf":[{"items":{"anyOf":[{"type":"string"},{"additionalProperties":true,"type":"object"}]},"type":"array"},{"type":"null"}],"title":"Receivers","description":"List of receivers for this alert","examples":[["primary-oncall",{"channel":"#alerts","type":"slack"}]]},"fingerprint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fingerprint","description":"Unique identifier for the alert","examples":["01ARZ3NDEKTSV4RRFFQ69G5FAV"]}},"type":"object","required":["labels","startsAt","status"],"title":"Alert"},"AlertGroup":{"properties":{"labels":{"additionalProperties":{"type":"string"},"type":"object","title":"Labels","description":"Common labels for the group","examples":[{"team":"platform"}]},"receiver":{"type":"string","title":"Receiver","description":"Receiver that will handle these alerts","examples":["primary-oncall"]},"alerts":{"items":{"$ref":"#/components/schemas/Alert-Output"},"type":"array","title":"Alerts","description":"List of alerts in this group"}},"type":"object","required":["labels","receiver","alerts"],"title":"AlertGroup"},"AlertIncident":{"properties":{"id":{"type":"string","title":"Id","examples":["incident-123"]},"fingerprint":{"type":"string","title":"Fingerprint","examples":["01ARZ3NDEKTSV4RRFFQ69G5FAV"]},"alertName":{"type":"string","title":"Alertname","examples":["HighCpuUsage"]},"severity":{"type":"string","title":"Severity","examples":["critical"]},"status":{"$ref":"#/components/schemas/IncidentStatus","examples":["open"]},"assignee":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Assignee","examples":["alice@example.com"]},"notes":{"items":{"$ref":"#/components/schemas/IncidentNote"},"type":"array","title":"Notes"},"labels":{"additionalProperties":{"type":"string"},"type":"object","title":"Labels","examples":[{"service":"api","severity":"critical"}]},"annotations":{"additionalProperties":{"type":"string"},"type":"object","title":"Annotations","examples":[{"summary":"CPU above 95%"}]},"visibility":{"$ref":"#/components/schemas/IncidentVisibility","default":"public","examples":["public"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","examples":[["group-ops"]]},"jiraTicketKey":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Jiraticketkey","examples":["OPS-321"]},"jiraTicketUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Jiraticketurl","examples":["https://jira.example.internal/browse/OPS-321"]},"jiraIntegrationId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Jiraintegrationid","examples":["jira-int-01"]},"startsAt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Startsat","examples":["2026-04-03T11:55:00Z"]},"lastSeenAt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Lastseenat","examples":["2026-04-03T12:05:00Z"]},"resolvedAt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resolvedat","examples":["2026-04-03T12:20:00Z"]},"createdAt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Createdat","examples":["2026-04-03T12:00:00Z"]},"updatedAt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updatedat","examples":["2026-04-03T12:10:00Z"]},"userManaged":{"type":"boolean","title":"Usermanaged","default":false,"examples":[false]},"hideWhenResolved":{"type":"boolean","title":"Hidewhenresolved","default":false,"examples":[false]}},"type":"object","required":["id","fingerprint","alertName","severity","status","lastSeenAt","createdAt","updatedAt"],"title":"AlertIncident"},"AlertIncidentUpdateRequest":{"properties":{"status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status","examples":["resolved"]},"assignee":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Assignee","examples":["alice@example.com"]},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Note","examples":["Resolved after scaling the deployment"]},"actorUsername":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Actorusername","examples":["alice"]},"visibility":{"anyOf":[{"$ref":"#/components/schemas/IncidentVisibility"},{"type":"null"}],"examples":["group"]},"sharedGroupIds":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Sharedgroupids","examples":[["group-ops"]]},"jiraTicketKey":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Jiraticketkey","examples":["OPS-321"]},"jiraTicketUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Jiraticketurl","examples":["https://jira.example.internal/browse/OPS-321"]},"jiraIntegrationId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Jiraintegrationid","examples":["jira-int-01"]},"hideWhenResolved":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Hidewhenresolved","examples":[true]}},"type":"object","title":"AlertIncidentUpdateRequest"},"AlertManagerStatus":{"properties":{"version":{"type":"string","title":"Version","description":"AlertManager version","examples":["0.28.1"]},"uptime":{"type":"string","title":"Uptime","description":"AlertManager uptime","examples":["72h15m"]},"configHash":{"type":"string","title":"Confighash","description":"Configuration hash","examples":["sha256:abc123"]},"config":{"additionalProperties":true,"type":"object","title":"Config","description":"Alertmanager configuration details","examples":[{"route":{"receiver":"primary-oncall"}}]},"cluster":{"additionalProperties":true,"type":"object","title":"Cluster","description":"Cluster status information","examples":[{"status":"ready"}]}},"type":"object","required":["version","uptime","configHash","cluster"],"title":"AlertManagerStatus"},"AlertRule":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id","description":"Unique identifier","examples":["rule-123"]},"createdBy":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Createdby","description":"User ID who created the rule","examples":["user-42"]},"orgId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Orgid","description":"Organization ID / API key scoped to this rule","examples":["org-abc"]},"name":{"type":"string","title":"Name","description":"Rule name","examples":["HighCpuUsage"]},"expression":{"type":"string","title":"Expression","description":"Prometheus expression for the alert rule","examples":["sum(rate(node_cpu_seconds_total{mode!=\"idle\"}[5m])) > 0.95"]},"severity":{"$ref":"#/components/schemas/RuleSeverity","description":"Severity level of the alert rule","examples":["critical"]},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Description of the alert rule","examples":["CPU usage is critically high"]},"enabled":{"type":"boolean","title":"Enabled","description":"Whether the rule is enabled","default":true,"examples":[true]},"labels":{"additionalProperties":{"type":"string"},"type":"object","title":"Labels","description":"Labels to add to alerts from this rule","examples":[{"service":"api"}]},"annotations":{"additionalProperties":{"type":"string"},"type":"object","title":"Annotations","description":"Annotations to add to alerts from this rule","examples":[{"summary":"API CPU alert"}]},"for":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"For","description":"Duration to wait before firing the alert","examples":["5m"]},"groupName":{"type":"string","title":"Groupname","description":"Name of the rule group this rule belongs to","examples":["watchdog-default"]},"groupInterval":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Groupinterval","description":"Interval between evaluations of this rule group","examples":["1m"]},"notificationChannels":{"items":{"type":"string"},"type":"array","title":"Notificationchannels","description":"Notification channel IDs for this rule","examples":[["channel-1"]]},"visibility":{"$ref":"#/components/schemas/Visibility","description":"Visibility scope","default":"private","examples":["private"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","description":"Group IDs this rule is shared with (when visibility=group)","examples":[["group-ops"]]},"isHidden":{"type":"boolean","title":"Ishidden","description":"Whether this rule is hidden for the current user","default":false,"examples":[false]}},"type":"object","required":["name","expression","severity","groupName"],"title":"AlertRule"},"AlertRuleCreate":{"properties":{"orgId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Orgid","description":"Optional org_id (API key) to scope this rule to","examples":["org-abc"]},"name":{"type":"string","maxLength":100,"minLength":1,"title":"Name","description":"Rule name","examples":["HighCpuUsage"]},"expression":{"type":"string","title":"Expression","description":"Prometheus expression for the alert rule","examples":["sum(rate(node_cpu_seconds_total{mode!=\"idle\"}[5m])) > 0.95"]},"severity":{"$ref":"#/components/schemas/RuleSeverity","description":"Severity level of the alert rule","examples":["critical"]},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Description of the alert rule","examples":["CPU usage is critically high"]},"enabled":{"type":"boolean","title":"Enabled","description":"Whether the rule is enabled","default":true,"examples":[true]},"labels":{"additionalProperties":{"type":"string"},"type":"object","title":"Labels","description":"Labels to add to alerts from this rule","examples":[{"service":"api"}]},"annotations":{"additionalProperties":{"type":"string"},"type":"object","title":"Annotations","description":"Annotations to add to alerts from this rule","examples":[{"summary":"API CPU alert"}]},"for":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"For","description":"Duration to wait before firing the alert","examples":["5m"]},"groupName":{"type":"string","title":"Groupname","description":"Name of the rule group this rule belongs to","examples":["watchdog-default"]},"groupInterval":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Groupinterval","description":"Interval between evaluations of this rule group","examples":["1m"]},"notificationChannels":{"items":{"type":"string"},"type":"array","title":"Notificationchannels","description":"Notification channel IDs for this rule","examples":[["channel-1"]]},"visibility":{"$ref":"#/components/schemas/Visibility","description":"Visibility scope","default":"private","examples":["private"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","description":"Group IDs to share with","examples":[["group-ops"]]}},"additionalProperties":false,"type":"object","required":["name","expression","severity","groupName"],"title":"AlertRuleCreate"},"AlertState":{"type":"string","enum":["unprocessed","active","suppressed"],"title":"AlertState"},"AlertStatus":{"properties":{"state":{"$ref":"#/components/schemas/AlertState","description":"Current state of the alert","examples":["active"]},"silencedBy":{"items":{"type":"string"},"type":"array","title":"Silencedby","description":"List of silences that silence this alert","examples":[["silence-123"]]},"inhibitedBy":{"items":{"type":"string"},"type":"array","title":"Inhibitedby","description":"List of alerts that inhibit this alert","examples":[["alert-456"]]}},"type":"object","required":["state"],"title":"AlertStatus"},"AlertWebhookRequest":{"properties":{"alerts":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Alerts","examples":[[{"labels":{"alertname":"HighCpuUsage","severity":"critical"}}]]}},"additionalProperties":true,"type":"object","title":"AlertWebhookRequest"},"ChannelType":{"type":"string","enum":["email","slack","teams","webhook","pagerduty"],"title":"ChannelType"},"GroupSharePruneRequest":{"properties":{"tenantId":{"type":"string","minLength":1,"pattern":"^[^\\x00]+$","title":"Tenantid","examples":["tenant-01"]},"groupId":{"type":"string","minLength":1,"pattern":"^[^\\x00]+$","title":"Groupid","examples":["group-ops"]},"removedUserIds":{"items":{"type":"string","minLength":1,"pattern":"^[^\\x00]+$"},"type":"array","title":"Removeduserids","examples":[["user-42"]]},"removedUsernames":{"items":{"type":"string","minLength":1,"pattern":"^[^\\x00]+$"},"type":"array","title":"Removedusernames","examples":[["alice"]]}},"type":"object","required":["tenantId","groupId"],"title":"GroupSharePruneRequest"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"IncidentJiraCreateRequest":{"properties":{"integrationId":{"type":"string","title":"Integrationid","examples":["jira-int-01"]},"projectKey":{"type":"string","title":"Projectkey","examples":["OPS"]},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary","examples":["Investigate HighCpuUsage incident"]},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","examples":["CPU usage has remained above 95% for five minutes."]},"issueType":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Issuetype","examples":["Task"]},"replaceExisting":{"type":"boolean","title":"Replaceexisting","default":false,"examples":[false]}},"type":"object","required":["integrationId","projectKey"],"title":"IncidentJiraCreateRequest"},"IncidentNote":{"properties":{"author":{"type":"string","title":"Author","examples":["alice@example.com"]},"text":{"type":"string","title":"Text","examples":["Investigating elevated CPU on api-01"]},"createdAt":{"type":"string","title":"Createdat","examples":["2026-04-03T12:00:00Z"]}},"type":"object","required":["author","text","createdAt"],"title":"IncidentNote"},"IncidentStatus":{"type":"string","enum":["open","resolved"],"title":"IncidentStatus"},"IncidentVisibility":{"type":"string","enum":["public","private","group"],"title":"IncidentVisibility"},"JiraConfigUpdateRequest":{"properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Enabled","examples":[true]},"baseUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Baseurl","examples":["https://jira.example.internal"]},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email","examples":["jira-bot@example.com"]},"apiToken":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Apitoken","examples":["jira-api-token"]},"bearerToken":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bearertoken","examples":["jira-bearer-token"]}},"additionalProperties":false,"type":"object","title":"JiraConfigUpdateRequest"},"JiraIntegrationCreateRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name","examples":["Primary Jira"]},"enabled":{"type":"boolean","title":"Enabled","default":true,"examples":[true]},"visibility":{"type":"string","title":"Visibility","default":"private","examples":["group"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","examples":[["group-ops"]]},"baseUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Baseurl","examples":["https://jira.example.internal"]},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email","examples":["jira-bot@example.com"]},"apiToken":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Apitoken","examples":["jira-api-token"]},"bearerToken":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bearertoken","examples":["jira-bearer-token"]},"authMode":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authmode","examples":["api_token"]},"supportsSso":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Supportssso","examples":[false]}},"additionalProperties":false,"type":"object","title":"JiraIntegrationCreateRequest"},"JiraIntegrationUpdateRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name","examples":["Primary Jira"]},"enabled":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Enabled","examples":[true]},"visibility":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Visibility","examples":["group"]},"sharedGroupIds":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Sharedgroupids","examples":[["group-ops"]]},"baseUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Baseurl","examples":["https://jira.example.internal"]},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email","examples":["jira-bot@example.com"]},"apiToken":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Apitoken","examples":["jira-api-token"]},"bearerToken":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bearertoken","examples":["jira-bearer-token"]},"authMode":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authmode","examples":["api_token"]},"supportsSso":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Supportssso","examples":[false]}},"additionalProperties":false,"type":"object","title":"JiraIntegrationUpdateRequest"},"Matcher":{"properties":{"name":{"type":"string","title":"Name","description":"Label name to match","examples":["alertname"]},"value":{"type":"string","title":"Value","description":"Value to match against","examples":["HighCpuUsage"]},"isRegex":{"type":"boolean","title":"Isregex","description":"Whether the value is a regular expression","default":false,"examples":[false]},"isEqual":{"type":"boolean","title":"Isequal","description":"Whether to match equal values","default":true,"examples":[true]}},"additionalProperties":false,"type":"object","required":["name","value"],"title":"Matcher"},"NotificationChannel":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id","description":"Unique identifier","examples":["channel-123"]},"name":{"type":"string","title":"Name","description":"Channel name","examples":["Primary Slack"]},"type":{"$ref":"#/components/schemas/ChannelType","description":"Channel type","examples":["slack"]},"enabled":{"type":"boolean","title":"Enabled","description":"Whether the channel is enabled","default":true,"examples":[true]},"config":{"additionalProperties":true,"type":"object","title":"Config","description":"Channel-specific configuration","examples":[{"webhookUrl":"https://hooks.slack.com/services/T000/B000/XXX"}]},"createdBy":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Createdby","description":"Owner user id","examples":["user-42"]},"visibility":{"$ref":"#/components/schemas/Visibility","description":"Visibility scope","default":"private","examples":["private"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","description":"Group IDs this channel is shared with (when visibility=group)","examples":[["group-ops"]]},"isHidden":{"type":"boolean","title":"Ishidden","description":"Whether this channel is hidden for the current user","default":false,"examples":[false]}},"type":"object","required":["name","type","config"],"title":"NotificationChannel"},"NotificationChannelCreate":{"properties":{"name":{"type":"string","maxLength":100,"minLength":1,"title":"Name","description":"Channel name","examples":["Primary Slack"]},"type":{"$ref":"#/components/schemas/ChannelType","description":"Channel type","examples":["slack"]},"enabled":{"type":"boolean","title":"Enabled","description":"Whether the channel is enabled","default":true,"examples":[true]},"config":{"additionalProperties":true,"type":"object","title":"Config","description":"Channel-specific configuration","examples":[{"webhookUrl":"https://hooks.slack.com/services/T000/B000/XXX"}]},"visibility":{"$ref":"#/components/schemas/Visibility","description":"Visibility scope","default":"private","examples":["private"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","description":"Group IDs to share with","examples":[["group-ops"]]}},"additionalProperties":false,"type":"object","required":["name","type","config"],"title":"NotificationChannelCreate"},"RuleImportRequest":{"properties":{"yamlContent":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Yamlcontent","examples":["groups:\n - name: watchdog-default\n rules:\n - alert: HighCpuUsage"]},"defaults":{"additionalProperties":true,"type":"object","title":"Defaults","examples":[{"labels":{"team":"platform"}}]},"dryRun":{"type":"boolean","title":"Dryrun","default":false,"examples":[true]}},"type":"object","title":"RuleImportRequest"},"RuleSeverity":{"type":"string","enum":["info","warning","error","critical"],"title":"RuleSeverity"},"Silence":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id","description":"Unique identifier for the silence","examples":["silence-123"]},"matchers":{"items":{"$ref":"#/components/schemas/Matcher"},"type":"array","title":"Matchers","description":"Matchers that define which alerts to silence","examples":[[{"isEqual":true,"isRegex":false,"name":"alertname","value":"HighCpuUsage"}]]},"startsAt":{"type":"string","title":"Startsat","description":"Time when the silence starts","examples":["2026-04-03T12:00:00Z"]},"endsAt":{"type":"string","title":"Endsat","description":"Time when the silence ends","examples":["2026-04-03T13:00:00Z"]},"createdBy":{"type":"string","title":"Createdby","description":"User who created the silence","examples":["user-42"]},"comment":{"type":"string","title":"Comment","description":"Comment explaining the silence","examples":["Suppress noisy deploy alert while maintenance is in progress"]},"status":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Status","description":"Current status of the silence","examples":[{"state":"active"}]},"visibility":{"anyOf":[{"$ref":"#/components/schemas/Visibility"},{"type":"null"}],"description":"Visibility scope","examples":["private"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","description":"Group IDs this silence is shared with","examples":[["group-ops"]]},"isHidden":{"type":"boolean","title":"Ishidden","description":"Whether this silence is hidden for the current user","default":false,"examples":[false]}},"type":"object","required":["matchers","startsAt","endsAt","createdBy","comment"],"title":"Silence"},"SilenceCreateRequest":{"properties":{"matchers":{"items":{"$ref":"#/components/schemas/Matcher"},"type":"array","minItems":1,"title":"Matchers","description":"Matchers that define which alerts to silence","examples":[[{"isEqual":true,"isRegex":false,"name":"alertname","value":"HighCpuUsage"}]]},"startsAt":{"type":"string","title":"Startsat","description":"Time when the silence starts","examples":["2026-04-03T12:00:00Z"]},"endsAt":{"type":"string","title":"Endsat","description":"Time when the silence ends","examples":["2026-04-03T13:00:00Z"]},"comment":{"type":"string","title":"Comment","description":"Comment explaining the silence","examples":["Suppress noisy deploy alert while maintenance is in progress"]},"visibility":{"$ref":"#/components/schemas/Visibility","description":"Visibility scope","default":"private","examples":["private"]},"sharedGroupIds":{"items":{"type":"string"},"type":"array","title":"Sharedgroupids","description":"Group IDs to share with","examples":[["group-ops"]]}},"additionalProperties":false,"type":"object","required":["matchers","startsAt","endsAt","comment"],"title":"SilenceCreateRequest"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"Visibility":{"type":"string","enum":["private","group","tenant","public"],"title":"Visibility"},"routers__observability__alerts__shared__HideTogglePayload":{"properties":{"hidden":{"type":"boolean","title":"Hidden","default":true,"examples":[true]}},"type":"object","title":"HideTogglePayload"},"routers__observability__jira__shared__HideTogglePayload":{"properties":{"hidden":{"type":"boolean","title":"Hidden","default":true}},"type":"object","title":"HideTogglePayload"}},"securitySchemes":{"HTTPBearer":{"type":"http","scheme":"bearer"}}},"jsonSchemaDialect":"https://spec.openapis.org/oas/3.1/dialect/base"} \ No newline at end of file diff --git a/openapi.yaml b/openapi.yaml index 8ad9cca..5b25c60 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Notifier description: Internal alerting service for Watchdog - version: 0.0.4 + version: 0.0.5 paths: /internal/v1/api/alertmanager/alerts: get: diff --git a/pyproject.toml b/pyproject.toml index 46b021e..315c1e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "notifier" -version = "0.0.4" +version = "0.0.5" description = "Notifier alerting and incident-management service for the Observantio platform." readme = "README.md" requires-python = ">=3.11" @@ -73,6 +73,46 @@ filterwarnings = [ "ignore::DeprecationWarning:starlette\\..*", ] +[tool.mutmut] +paths_to_mutate = [ + "custom_types/json.py", + "models/access/auth_models.py", + "middleware/dependencies.py", + "middleware/error_handlers.py", + "middleware/headers.py", + "middleware/request_size_limit.py", + "middleware/resilience.py", + "middleware/rate_limit/hybrid.py", + "middleware/rate_limit/in_memory.py", + "middleware/rate_limit/ip.py", + "middleware/rate_limit/models.py", + "middleware/rate_limit/redis_fixed_window.py", + "routers/observability/alerts/access.py", + "services/common/__init__.py", + "services/common/access.py", + "services/common/encryption.py", + "services/common/http_client.py", + "services/common/meta.py", + "services/common/pagination.py", + "services/common/tenants.py", + "services/common/url_utils.py", + "services/common/visibility.py", + "services/secrets/provider.py", + "services/secrets/vault_client.py", +] +tests_dir = ["tests/test_url_utils.py"] +do_not_mutate = [ + "*/tests/*", + "*/mutants/*", + "*/openapi.json", + "*/openapi.yaml", + "*/assets/*", + "*/configs/*", + "*/migrations/*", +] +pytest_add_cli_args = ["-q"] +mutate_only_covered_lines = true + [tool.coverage.run] branch = true omit = ["tests/*"] @@ -123,10 +163,11 @@ files = ["."] exclude = [ "^(build|dist|venv|\\.venv|__pycache__|migrations)/", "^tests/", + "^mutants/", ] [tool.pylint.main] -jobs = 1 +jobs = 4 extension-pkg-allow-list = ["sqlalchemy"] ignore = [".git", "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".venv", "venv", "build", "dist", "tmp", "vendor", "tests", "mutants"] ignore-paths = ["^tests/", "^mutants/"] @@ -146,13 +187,13 @@ max-line-length = 120 max-module-lines = 800 [tool.pylint.design] -max-args = 6 -max-positional-arguments = 6 -max-attributes = 35 -max-public-methods = 25 -max-returns = 10 +max-args = 5 +max-positional-arguments = 4 +max-attributes = 20 +max-public-methods = 15 +max-returns = 5 max-nested-blocks = 5 -max-statements = 80 +max-statements = 50 min-public-methods = 0 [tool.pylint.basic] diff --git a/routers/__init__.py b/routers/__init__.py index 086e3f3..ca1f2bf 100644 --- a/routers/__init__.py +++ b/routers/__init__.py @@ -2,11 +2,10 @@ Router for AlertManager API endpoints, including alert retrieval, incident management, Jira integration management, and alert rule import. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ __all__ = [] diff --git a/routers/observability/__init__.py b/routers/observability/__init__.py index 1187c63..b4004e9 100644 --- a/routers/observability/__init__.py +++ b/routers/observability/__init__.py @@ -2,11 +2,10 @@ Routers for observability-related endpoints, including AlertManager alerts, silences, status, receivers, alert rules, notification channels, and Jira integrations. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from .alerts import router as alertmanager_alerts_router diff --git a/routers/observability/alerts/__init__.py b/routers/observability/alerts/__init__.py index ad57e92..b772b17 100644 --- a/routers/observability/alerts/__init__.py +++ b/routers/observability/alerts/__init__.py @@ -1,5 +1,8 @@ """ -Alertmanager routers split by resource domain. +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from fastapi import APIRouter diff --git a/routers/observability/alerts/access.py b/routers/observability/alerts/access.py index b03b128..a677c97 100644 --- a/routers/observability/alerts/access.py +++ b/routers/observability/alerts/access.py @@ -1,3 +1,10 @@ +""" +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +""" + from fastapi import APIRouter, Body from fastapi.concurrency import run_in_threadpool diff --git a/routers/observability/alerts/alerts_routes.py b/routers/observability/alerts/alerts_routes.py index b7978b2..3783d7f 100644 --- a/routers/observability/alerts/alerts_routes.py +++ b/routers/observability/alerts/alerts_routes.py @@ -1,7 +1,15 @@ +""" +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +""" + from typing import cast from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi.concurrency import run_in_threadpool +from pydantic import BaseModel, Field from custom_types.json import JSONDict from middleware.dependencies import require_any_permission_with_scope, require_permission_with_scope @@ -9,12 +17,21 @@ from middleware.openapi import BAD_REQUEST_ERRORS from models.access.auth_models import Permission, TokenData from models.alerting.alerts import Alert, AlertGroup +from services.alerting.alerts_ops import AlertQuery from .shared import INVALID_FILTER_LABELS_JSON, alertmanager_service, storage_service, sync_incidents router = APIRouter(tags=["alertmanager"]) +class AlertListQuery(BaseModel): + active: bool | None = Field(default=None) + silenced: bool | None = Field(default=None) + inhibited: bool | None = Field(default=None) + filter_labels: str | None = Field(default=None) + show_hidden: bool = Field(default=False) + + def _json_dict(value: object) -> dict[str, object]: return value if isinstance(value, dict) else {} @@ -29,16 +46,17 @@ def _json_dict(value: object) -> dict[str, object]: ) @handle_route_errors(bad_request_detail=INVALID_FILTER_LABELS_JSON) async def list_alerts( - active: bool | None = Query(None), - silenced: bool | None = Query(None), - inhibited: bool | None = Query(None), - filter_labels: str | None = Query(None), - show_hidden: bool = Query(False), + query: AlertListQuery = Depends(), current_user: TokenData = Depends(require_permission_with_scope(Permission.READ_ALERTS, "alertmanager")), ) -> list[Alert]: - labels = alertmanager_service.parse_filter_labels(filter_labels) + labels = alertmanager_service.parse_filter_labels(query.filter_labels) alerts = await alertmanager_service.get_alerts( - filter_labels=labels, active=active, silenced=silenced, inhibited=inhibited + AlertQuery( + filter_labels=labels or {}, + active=query.active, + silenced=query.silenced, + inhibited=query.inhibited, + ) ) alert_dicts = [alert.model_dump(by_alias=True) for alert in alerts] await sync_incidents(current_user.tenant_id, alert_dicts, log_context="get_alerts") @@ -50,7 +68,7 @@ async def list_alerts( getattr(current_user, "group_ids", []) or [], alert_dicts, ) - if not show_hidden: + if not query.show_hidden: hidden_rule_names = set( await run_in_threadpool( storage_service.get_hidden_rule_names, diff --git a/routers/observability/alerts/channels.py b/routers/observability/alerts/channels.py index acfb104..05a6945 100644 --- a/routers/observability/alerts/channels.py +++ b/routers/observability/alerts/channels.py @@ -2,27 +2,28 @@ Channel management endpoints for AlertManager integration, allowing users to create, update, delete, and test notification channels. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from datetime import UTC, datetime from typing import cast -from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, status +from fastapi import APIRouter, Body, Depends, HTTPException, Request, status from fastapi.concurrency import run_in_threadpool +from pydantic import BaseModel, Field from config import config from custom_types.json import JSONDict from middleware.dependencies import require_any_permission_with_scope, require_permission_with_scope -from middleware.error_handlers import handle_route_errors +from middleware.error_handlers import RouteErrorResponse, handle_route_errors from middleware.openapi import BAD_REQUEST_ERRORS, BAD_REQUEST_NOT_FOUND_ERRORS, NOT_FOUND_ERRORS from models.access.auth_models import Permission, TokenData from models.alerting.alerts import Alert from models.alerting.channels import ChannelType, NotificationChannel, NotificationChannelCreate +from services.storage.channels import ChannelAccessContext, PageRequest from .shared import ( HideTogglePayload, @@ -37,6 +38,12 @@ router = APIRouter(tags=["alertmanager-channels"]) +class ChannelListQuery(BaseModel): + limit: int = Field(default=config.default_query_limit, ge=1, le=config.max_query_limit) + offset: int = Field(default=0, ge=0) + show_hidden: str = Field(default="false", pattern="^(true|false)$") + + @router.get( "/channels", response_model=list[NotificationChannel], @@ -47,26 +54,23 @@ ) async def list_channels( request: Request, - limit: int = Query(config.default_query_limit, ge=1, le=config.max_query_limit), - offset: int = Query(0, ge=0), - show_hidden: str = Query("false", pattern="^(true|false)$"), + query: ChannelListQuery = Depends(), current_user: TokenData = Depends(require_permission_with_scope(Permission.READ_CHANNELS, "alertmanager")), ) -> list[NotificationChannel]: if request is not None: reject_unknown_query_params(request, {"limit", "offset", "show_hidden"}) tenant_id, user_id, group_ids = alertmanager_service.user_scope(current_user) + access = ChannelAccessContext(user_id=user_id, group_ids=group_ids) channels = await run_in_threadpool( storage_service.get_notification_channels, tenant_id, - user_id, - group_ids, - limit, - offset, + access, + PageRequest(limit=query.limit, offset=query.offset), ) hidden_ids = set(await run_in_threadpool(storage_service.get_hidden_channel_ids, tenant_id, user_id)) for channel in channels: channel.is_hidden = bool(channel.id and channel.id in hidden_ids) - if not parse_show_hidden(show_hidden): + if not parse_show_hidden(query.show_hidden): channels = [channel for channel in channels if not channel.is_hidden] return cast(list[NotificationChannel], channels) @@ -84,12 +88,12 @@ async def get_channel( current_user: TokenData = Depends(require_permission_with_scope(Permission.READ_CHANNELS, "alertmanager")), ) -> NotificationChannel: tenant_id, user_id, group_ids = alertmanager_service.user_scope(current_user) + access = ChannelAccessContext(user_id=user_id, group_ids=group_ids) channel = await run_in_threadpool( storage_service.get_notification_channel, channel_id, tenant_id, - user_id, - group_ids, + access, False, ) if not channel: @@ -113,12 +117,12 @@ async def hide_channel( current_user: TokenData = Depends(require_permission_with_scope(Permission.READ_CHANNELS, "alertmanager")), ) -> JSONDict: tenant_id, user_id, group_ids = alertmanager_service.user_scope(current_user) + access = ChannelAccessContext(user_id=user_id, group_ids=group_ids) channel = await run_in_threadpool( storage_service.get_notification_channel, channel_id, tenant_id, - user_id, - group_ids, + access, True, ) if not channel: @@ -153,7 +157,12 @@ async def create_channel( ) -> NotificationChannel: tenant_id, user_id, group_ids = alertmanager_service.user_scope(current_user) validate_channel(channel, notification_service) - return await run_in_threadpool(storage_service.create_notification_channel, channel, tenant_id, user_id, group_ids) + return await run_in_threadpool( + storage_service.create_notification_channel, + channel, + tenant_id, + ChannelAccessContext(user_id=user_id, group_ids=group_ids), + ) @router.put( @@ -174,7 +183,11 @@ async def update_channel( tenant_id, user_id, group_ids = alertmanager_service.user_scope(current_user) validate_channel(channel, notification_service) updated_channel = await run_in_threadpool( - storage_service.update_notification_channel, channel_id, channel, tenant_id, user_id, group_ids + storage_service.update_notification_channel, + channel_id, + channel, + tenant_id, + ChannelAccessContext(user_id=user_id, group_ids=group_ids), ) if not updated_channel: raise HTTPException(status_code=404, detail=f"Notification channel {channel_id} not found or access denied") @@ -193,8 +206,13 @@ async def delete_channel( channel_id: str, current_user: TokenData = Depends(require_permission_with_scope(Permission.DELETE_CHANNELS, "alertmanager")), ) -> JSONDict: - tenant_id, user_id, _ = alertmanager_service.user_scope(current_user) - if not await run_in_threadpool(storage_service.delete_notification_channel, channel_id, tenant_id, user_id): + tenant_id, user_id, group_ids = alertmanager_service.user_scope(current_user) + if not await run_in_threadpool( + storage_service.delete_notification_channel, + channel_id, + tenant_id, + ChannelAccessContext(user_id=user_id, group_ids=group_ids), + ): raise HTTPException(status_code=404, detail=f"Notification channel {channel_id} not found or access denied") return {"status": "success", "message": f"Notification channel {channel_id} deleted"} @@ -206,7 +224,7 @@ async def delete_channel( response_description="The test delivery result for the notification channel.", responses=BAD_REQUEST_NOT_FOUND_ERRORS, ) -@handle_route_errors(internal_detail="Failed to send test notification") +@handle_route_errors(internal=RouteErrorResponse(detail="Failed to send test notification", status_code=500)) async def test_channel( channel_id: str, current_user: TokenData = Depends( @@ -214,15 +232,15 @@ async def test_channel( ), ) -> JSONDict: tenant_id, user_id, group_ids = alertmanager_service.user_scope(current_user) - if not await run_in_threadpool(storage_service.is_notification_channel_owner, channel_id, tenant_id, user_id): + access = ChannelAccessContext(user_id=user_id, group_ids=group_ids) + if not await run_in_threadpool(storage_service.is_notification_channel_owner, channel_id, tenant_id, access): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only channel owner can test this channel") channel = await run_in_threadpool( storage_service.get_notification_channel, channel_id, tenant_id, - user_id, - group_ids, + access, True, ) if not channel: diff --git a/routers/observability/alerts/integrations.py b/routers/observability/alerts/integrations.py index 1d64d40..0bd0519 100644 --- a/routers/observability/alerts/integrations.py +++ b/routers/observability/alerts/integrations.py @@ -1,11 +1,10 @@ """ Integration endpoints for AlertManager integration, providing information on allowed channel types for notifications. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from fastapi import APIRouter, Depends diff --git a/routers/observability/alerts/rules.py b/routers/observability/alerts/rules.py index 61c48a9..26958d3 100644 --- a/routers/observability/alerts/rules.py +++ b/routers/observability/alerts/rules.py @@ -2,11 +2,10 @@ Rules management endpoints for AlertManager integration, allowing users to create, update, delete, hide, and test alert rules, as well as import rules from YAML and query metrics for rule creation. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import asyncio @@ -16,23 +15,26 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, status from fastapi.concurrency import run_in_threadpool +from pydantic import BaseModel, Field from config import config from custom_types.json import JSONDict from database import get_db_session from db_models import Tenant from middleware.dependencies import ( + PublicEndpointSecurityConfig, enforce_public_endpoint_security, require_any_permission_with_scope, require_permission_with_scope, ) -from middleware.error_handlers import handle_route_errors +from middleware.error_handlers import RouteErrorResponse, handle_route_errors from middleware.openapi import BAD_REQUEST_ERRORS, BAD_REQUEST_NOT_FOUND_ERRORS, COMMON_ERRORS, NOT_FOUND_ERRORS from models.access.auth_models import Permission, TokenData from models.alerting.alerts import Alert from models.alerting.requests import RuleImportRequest from models.alerting.rules import AlertRule, AlertRuleCreate from services.alerting.rule_import_service import RuleImportError, parse_rules_yaml +from services.storage.rules import PageRequest, RuleAccessContext from .shared import ( HideTogglePayload, @@ -48,6 +50,12 @@ router = APIRouter(tags=["alertmanager-rules"]) +class RuleListQuery(BaseModel): + limit: int = Field(default=config.default_query_limit, ge=1, le=config.max_query_limit) + offset: int = Field(default=0, ge=0) + show_hidden: str = Field(default="false", pattern="^(true|false)$") + + def _with_creator_username(rule: AlertRuleCreate, current_user: TokenData) -> AlertRuleCreate: annotations = dict(rule.annotations or {}) creator_username = str(getattr(current_user, "username", "") or getattr(current_user, "user_id", "") or "").strip() @@ -83,7 +91,8 @@ async def import_rules( "rules": [item.model_dump(by_alias=True) for item in parsed_rules], } - existing_rules = await run_in_threadpool(storage_service.get_alert_rules, tenant_id, user_id, group_ids) + access = RuleAccessContext(user_id=user_id, group_ids=group_ids) + existing_rules = await run_in_threadpool(storage_service.get_alert_rules, tenant_id, access) existing_index = {(item.name, item.group, item.org_id or ""): item for item in existing_rules} created = updated = 0 imported_rules: list[AlertRule] = [] @@ -97,13 +106,17 @@ async def import_rules( if current_id is None: continue updated_rule = await run_in_threadpool( - storage_service.update_alert_rule, current_id, rule, tenant_id, user_id, group_ids + storage_service.update_alert_rule, + current_id, + rule, + tenant_id, + access, ) if updated_rule: updated += 1 imported_rules.append(updated_rule) else: - new_rule = await run_in_threadpool(storage_service.create_alert_rule, rule, tenant_id, user_id, group_ids) + new_rule = await run_in_threadpool(storage_service.create_alert_rule, rule, tenant_id, access) created += 1 imported_rules.append(new_rule) existing_index[(new_rule.name, new_rule.group, new_rule.org_id or "")] = new_rule @@ -132,9 +145,7 @@ async def import_rules( ) async def list_rules( request: Request, - limit: int = Query(config.default_query_limit, ge=1, le=config.max_query_limit), - offset: int = Query(0, ge=0), - show_hidden: str = Query("false", pattern="^(true|false)$"), + query: RuleListQuery = Depends(), current_user: TokenData = Depends(require_permission_with_scope(Permission.READ_RULES, "alertmanager")), ) -> list[AlertRule]: if request is not None: @@ -148,12 +159,15 @@ async def list_rules( ) ) rules_with_owner = await run_in_threadpool( - storage_service.get_alert_rules_with_owner, tenant_id, user_id, group_ids, limit, offset + storage_service.get_alert_rules_with_owner, + tenant_id, + RuleAccessContext(user_id=user_id, group_ids=group_ids), + PageRequest(limit=query.limit, offset=query.offset), ) result: list[AlertRule] = [] for rule, owner in rules_with_owner: rule.is_hidden = bool(rule.id and rule.id in hidden_ids) - if rule.is_hidden and not parse_show_hidden(show_hidden): + if rule.is_hidden and not parse_show_hidden(query.show_hidden): continue if owner != current_user.user_id and not getattr(current_user, "is_superuser", False): rule.org_id = None @@ -172,10 +186,12 @@ async def list_rules( async def list_public_rules(request: Request) -> list[AlertRule]: enforce_public_endpoint_security( request, - scope="alertmanager_public_rules", - limit=config.rate_limit_public_per_minute, - window_seconds=60, - allowlist=config.auth_public_ip_allowlist, + PublicEndpointSecurityConfig( + scope="alertmanager_public_rules", + limit=config.rate_limit_public_per_minute, + window_seconds=60, + allowlist=config.auth_public_ip_allowlist, + ), ) def _resolve_default_tenant_id() -> str | None: @@ -196,7 +212,9 @@ def _resolve_default_tenant_id() -> str | None: response_description="The resolved organization identifier and metric names.", responses=BAD_REQUEST_ERRORS, ) -@handle_route_errors(bad_gateway_detail="Failed to fetch metrics from Mimir") +@handle_route_errors( + bad_gateway=RouteErrorResponse(detail="Failed to fetch metrics from Mimir", status_code=502) +) async def list_metric_names( org_id: str | None = Query(None, alias="orgId"), current_user: TokenData = Depends( @@ -222,7 +240,9 @@ async def list_metric_names( response_description="The evaluated query result returned from Mimir.", responses=BAD_REQUEST_ERRORS, ) -@handle_route_errors(bad_gateway_detail="Failed to evaluate PromQL against Mimir") +@handle_route_errors( + bad_gateway=RouteErrorResponse(detail="Failed to evaluate PromQL against Mimir", status_code=502) +) async def query_metrics( query: str = Query(..., min_length=1), org_id: str | None = Query(None, alias="orgId"), @@ -251,7 +271,9 @@ async def query_metrics( response_description="The resolved organization identifier and metric label names.", responses=BAD_REQUEST_ERRORS, ) -@handle_route_errors(bad_gateway_detail="Failed to fetch label names from Mimir") +@handle_route_errors( + bad_gateway=RouteErrorResponse(detail="Failed to fetch label names from Mimir", status_code=502) +) async def list_metric_labels( org_id: str | None = Query(None, alias="orgId"), current_user: TokenData = Depends( @@ -278,8 +300,7 @@ async def list_metric_labels( responses=BAD_REQUEST_ERRORS, ) @handle_route_errors( - bad_gateway_detail="Failed to fetch label values from Mimir", - bad_gateway_status_code=400, + bad_gateway=RouteErrorResponse(detail="Failed to fetch label values from Mimir", status_code=400), ) async def list_metric_label_values( label: str, @@ -319,7 +340,8 @@ async def get_rule( current_user: TokenData = Depends(require_permission_with_scope(Permission.READ_RULES, "alertmanager")), ) -> AlertRule: tenant_id, user_id, group_ids = alertmanager_service.user_scope(current_user) - rule = await run_in_threadpool(storage_service.get_alert_rule, rule_id, tenant_id, user_id, group_ids) + access = RuleAccessContext(user_id=user_id, group_ids=group_ids) + rule = await run_in_threadpool(storage_service.get_alert_rule, rule_id, tenant_id, access) if not rule: raise HTTPException(status_code=404, detail=f"Alert rule {rule_id} not found") hidden_ids = set( @@ -375,7 +397,7 @@ async def hide_rule( ) @handle_route_errors( bad_request_exceptions=(ValueError, UnicodeError, TypeError), - bad_gateway_status_code=400, + bad_gateway=RouteErrorResponse(detail="Upstream request failed", status_code=400), ) async def create_rule( rule: AlertRuleCreate = Body(...), @@ -388,7 +410,12 @@ async def create_rule( resolved_org_id = alertmanager_service.resolve_rule_org_id(rule.org_id, current_user) if rule.org_id != resolved_org_id: rule = rule.model_copy(update={"org_id": resolved_org_id}) - created_rule = await run_in_threadpool(storage_service.create_alert_rule, rule, tenant_id, user_id, group_ids) + created_rule = await run_in_threadpool( + storage_service.create_alert_rule, + rule, + tenant_id, + RuleAccessContext(user_id=user_id, group_ids=group_ids), + ) org_to_sync = created_rule.org_id or resolved_org_id await alertmanager_service.sync_mimir_rules_for_org( org_to_sync, await run_in_threadpool(storage_service.get_alert_rules_for_org, tenant_id, org_to_sync) @@ -406,7 +433,7 @@ async def create_rule( ) @handle_route_errors( bad_request_exceptions=(ValueError, UnicodeError, TypeError), - bad_gateway_status_code=400, + bad_gateway=RouteErrorResponse(detail="Upstream request failed", status_code=400), ) async def update_rule( rule_id: str, @@ -417,15 +444,14 @@ async def update_rule( ) -> AlertRule: tenant_id, user_id, group_ids = alertmanager_service.user_scope(current_user) rule = _with_creator_username(rule, current_user) - existing_rule = await run_in_threadpool(storage_service.get_alert_rule, rule_id, tenant_id, user_id, group_ids) + access = RuleAccessContext(user_id=user_id, group_ids=group_ids) + existing_rule = await run_in_threadpool(storage_service.get_alert_rule, rule_id, tenant_id, access) if not existing_rule: raise HTTPException(status_code=404, detail=f"Alert rule {rule_id} not found or access denied") resolved_org_id = alertmanager_service.resolve_rule_org_id(rule.org_id, current_user) if rule.org_id != resolved_org_id: rule = rule.model_copy(update={"org_id": resolved_org_id}) - updated_rule = await run_in_threadpool( - storage_service.update_alert_rule, rule_id, rule, tenant_id, user_id, group_ids - ) + updated_rule = await run_in_threadpool(storage_service.update_alert_rule, rule_id, rule, tenant_id, access) if not updated_rule: raise HTTPException(status_code=404, detail=f"Alert rule {rule_id} not found or access denied") updated_org_id = updated_rule.org_id or resolved_org_id @@ -456,7 +482,12 @@ async def test_rule( ), ) -> JSONDict: tenant_id, user_id, group_ids = alertmanager_service.user_scope(current_user) - rule = await run_in_threadpool(storage_service.get_alert_rule, rule_id, tenant_id, user_id, group_ids) + rule = await run_in_threadpool( + storage_service.get_alert_rule, + rule_id, + tenant_id, + RuleAccessContext(user_id=user_id, group_ids=group_ids), + ) if not rule: raise HTTPException(status_code=404, detail=f"Alert rule {rule_id} not found") @@ -557,10 +588,11 @@ async def delete_rule( current_user: TokenData = Depends(require_permission_with_scope(Permission.DELETE_RULES, "alertmanager")), ) -> JSONDict: tenant_id, user_id, group_ids = alertmanager_service.user_scope(current_user) - existing_rule = await run_in_threadpool(storage_service.get_alert_rule, rule_id, tenant_id, user_id, group_ids) + access = RuleAccessContext(user_id=user_id, group_ids=group_ids) + existing_rule = await run_in_threadpool(storage_service.get_alert_rule, rule_id, tenant_id, access) if not existing_rule: raise HTTPException(status_code=404, detail=f"Alert rule {rule_id} not found or access denied") - if not await run_in_threadpool(storage_service.delete_alert_rule, rule_id, tenant_id, user_id, group_ids): + if not await run_in_threadpool(storage_service.delete_alert_rule, rule_id, tenant_id, access): raise HTTPException(status_code=404, detail=f"Alert rule {rule_id} not found or access denied") resolved_org_id = alertmanager_service.resolve_rule_org_id(existing_rule.org_id, current_user) await alertmanager_service.sync_mimir_rules_for_org( diff --git a/routers/observability/alerts/shared.py b/routers/observability/alerts/shared.py index 7c50da3..da324c4 100644 --- a/routers/observability/alerts/shared.py +++ b/routers/observability/alerts/shared.py @@ -2,11 +2,10 @@ Shared utilities and models for AlertManager integration endpoints, including silence payload construction, channel validation, and incident synchronization. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import logging diff --git a/routers/observability/alerts/silences.py b/routers/observability/alerts/silences.py index 58fbdf0..6c48512 100644 --- a/routers/observability/alerts/silences.py +++ b/routers/observability/alerts/silences.py @@ -1,19 +1,19 @@ """ Silence management endpoints for AlertManager integration, allowing users to create, update, delete, and hide silences. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request from fastapi.concurrency import run_in_threadpool +from pydantic import BaseModel, Field from custom_types.json import JSONDict from middleware.dependencies import require_any_permission_with_scope, require_permission_with_scope -from middleware.error_handlers import handle_route_errors +from middleware.error_handlers import RouteErrorResponse, handle_route_errors from middleware.openapi import BAD_REQUEST_ERRORS, BAD_REQUEST_NOT_FOUND_ERRORS from models.access.auth_models import Permission, TokenData from models.alerting.silences import Silence, SilenceCreateRequest @@ -31,6 +31,12 @@ router = APIRouter(tags=["alertmanager-silences"]) +class SilenceListQuery(BaseModel): + filter_labels: str | None = Field(default=None) + include_expired: bool = Field(default=False) + show_hidden: str = Field(default="false", pattern="^(true|false)$") + + @router.get( "/silences", response_model=list[Silence], @@ -45,15 +51,13 @@ ) async def list_silences( request: Request, - filter_labels: str | None = Query(None), - include_expired: bool = Query(False), - show_hidden: str = Query("false", pattern="^(true|false)$"), + query: SilenceListQuery = Depends(), current_user: TokenData = Depends(require_permission_with_scope(Permission.READ_SILENCES, "alertmanager")), ) -> list[Silence]: if request is not None: reject_unknown_query_params(request, {"filter_labels", "include_expired", "show_hidden"}) silences = await alertmanager_service.get_silences( - filter_labels=alertmanager_service.parse_filter_labels(filter_labels) + filter_labels=alertmanager_service.parse_filter_labels(query.filter_labels) ) hidden_ids = set( await run_in_threadpool( @@ -65,13 +69,13 @@ async def list_silences( result = [] for silence in silences: silence = alertmanager_service.apply_silence_metadata(silence) - if not include_expired: + if not query.include_expired: state = (silence.status or {}).get("state") if silence.status else None if state and str(state).lower() == "expired": continue if alertmanager_service.silence_accessible(silence, current_user): silence.is_hidden = bool(silence.id and silence.id in hidden_ids) - if silence.is_hidden and not parse_show_hidden(show_hidden): + if silence.is_hidden and not parse_show_hidden(query.show_hidden): continue result.append(silence) return result @@ -87,8 +91,7 @@ async def list_silences( ) @handle_route_errors( bad_request_exceptions=(ValueError, UnicodeError, TypeError), - internal_detail="Invalid silence identifier", - internal_status_code=400, + internal=RouteErrorResponse(detail="Invalid silence identifier", status_code=400), ) async def get_silence( silence_id: str, @@ -177,8 +180,7 @@ async def update_silence( ) @handle_route_errors( bad_request_exceptions=(ValueError, UnicodeError, TypeError), - internal_detail="Invalid silence identifier", - internal_status_code=400, + internal=RouteErrorResponse(detail="Invalid silence identifier", status_code=400), ) async def delete_silence( silence_id: str, diff --git a/routers/observability/alerts/status.py b/routers/observability/alerts/status.py index 3d4ef1b..9b5e661 100644 --- a/routers/observability/alerts/status.py +++ b/routers/observability/alerts/status.py @@ -1,11 +1,10 @@ """ Status endpoints for AlertManager integration, providing health checks and receiver information. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from typing import cast diff --git a/routers/observability/alerts/webhooks.py b/routers/observability/alerts/webhooks.py index 98801db..931afa0 100644 --- a/routers/observability/alerts/webhooks.py +++ b/routers/observability/alerts/webhooks.py @@ -2,11 +2,10 @@ Webhook endpoints for receiving alerts from external systems like Alertmanager and triggering notifications/incidents in Watchdog. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import logging @@ -18,6 +17,7 @@ from middleware.error_handlers import handle_route_errors from middleware.openapi import BAD_REQUEST_ERRORS from models.alerting.requests import AlertWebhookRequest +from services.alerting.channels_ops import NotificationDispatchContext from services.alerting.integration_security_service import infer_tenant_id_from_alerts from .shared import alertmanager_service, notification_service, scope_header, storage_service, sync_incidents @@ -27,6 +27,13 @@ router = APIRouter(tags=["alertmanager-webhooks"]) +async def _dispatch_notifications(tenant_id: str, alerts: list[JSONDict]) -> None: + await alertmanager_service.notify_for_alerts( + NotificationDispatchContext(alertmanager_service, tenant_id, storage_service, notification_service), + alerts, + ) + + @router.post( "/alerts/webhook", summary="Receive Alert Webhook", @@ -40,7 +47,7 @@ async def receive_alert_webhook(request: Request, payload: AlertWebhookRequest = logger.info("Received webhook payload with %d alerts", len(payload.alerts)) tenant_id = infer_tenant_id_from_alerts(scope_header(request), payload.alerts) await sync_incidents(tenant_id, payload.alerts, log_context="webhook") - await alertmanager_service.notify_for_alerts(tenant_id, payload.alerts, storage_service, notification_service) + await _dispatch_notifications(tenant_id, payload.alerts) return {"status": constants.STATUS_SUCCESS, "count": len(payload.alerts)} @@ -59,7 +66,7 @@ async def receive_critical_webhook(request: Request, payload: AlertWebhookReques logger.warning("Received %d critical alerts", len(payload.alerts)) tenant_id = infer_tenant_id_from_alerts(scope_header(request), payload.alerts) await sync_incidents(tenant_id, payload.alerts, log_context="critical webhook") - await alertmanager_service.notify_for_alerts(tenant_id, payload.alerts, storage_service, notification_service) + await _dispatch_notifications(tenant_id, payload.alerts) return {"status": constants.STATUS_SUCCESS, "severity": "critical", "count": len(payload.alerts)} @@ -76,5 +83,5 @@ async def receive_warning_webhook(request: Request, payload: AlertWebhookRequest logger.info("Received warning alerts payload with %d alerts", len(payload.alerts)) tenant_id = infer_tenant_id_from_alerts(scope_header(request), payload.alerts) await sync_incidents(tenant_id, payload.alerts, log_context="warning webhook") - await alertmanager_service.notify_for_alerts(tenant_id, payload.alerts, storage_service, notification_service) + await _dispatch_notifications(tenant_id, payload.alerts) return {"status": constants.STATUS_SUCCESS, "severity": "warning", "count": len(payload.alerts)} diff --git a/routers/observability/incidents.py b/routers/observability/incidents.py index b43b620..7da06f1 100644 --- a/routers/observability/incidents.py +++ b/routers/observability/incidents.py @@ -2,14 +2,14 @@ Incident management API endpoints for querying and updating alert incidents, including status updates, assignee changes, and integration with AlertManager for active alert checks. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import logging +from dataclasses import dataclass from email.utils import parseaddr from typing import cast @@ -24,6 +24,7 @@ from middleware.openapi import BAD_REQUEST_ERRORS, BAD_REQUEST_NOT_FOUND_ERRORS, COMMON_ERRORS from models.access.auth_models import Permission, TokenData from models.alerting.incidents import AlertIncident, AlertIncidentUpdateRequest +from services.alerting.alerts_ops import AlertQuery from services.alertmanager_service import AlertManagerService from services.incidents.helpers import ( move_incident_ticket_to_done, @@ -31,9 +32,9 @@ move_incident_ticket_to_todo, sync_note_to_jira_comment, ) -from services.notification_service import NotificationService +from services.notification_service import IncidentAssignmentEmail, NotificationService from services.storage.incidents import incident_key_from_labels -from services.storage.incidents import IncidentActorContext +from services.storage.incidents import IncidentAccessContext from services.storage_db_service import DatabaseStorageService logger = logging.getLogger(__name__) @@ -45,6 +46,15 @@ notification_service = NotificationService() +@dataclass(frozen=True) +class IncidentListQuery: + status: str | None = Query(None) + visibility: str | None = Query(None) + group_id: str | None = Query(None) + limit: int = Query(100, ge=1, le=500) + offset: int = Query(0, ge=0) + + def _recipient_email(value: object) -> str | None: if value is None: return None @@ -55,6 +65,157 @@ def _recipient_email(value: object) -> str | None: return parsed if "@" in parsed else None +def _status_value(value: object) -> str: + status_value = value.value if hasattr(value, "value") else str(value) + return status_value.lower() + + +async def _send_incident_assignment_email_task( + payload: IncidentAssignmentEmail, +) -> bool: + return await notification_service.send_incident_assignment_email( + payload + ) + + +async def _ensure_resolve_allowed(payload: AlertIncidentUpdateRequest, existing: AlertIncident) -> None: + if payload.status is None or _status_value(payload.status) != "resolved": + return + + existing_incident_key = incident_key_from_labels(existing.labels or {}) + try: + if existing_incident_key: + active_alerts = [ + alert + for alert in (await alertmanager_service.get_alerts(AlertQuery(active=True))) + if incident_key_from_labels(getattr(alert, "labels", {}) or {}) == existing_incident_key + ] + else: + active_alerts = await alertmanager_service.get_alerts( + AlertQuery( + filter_labels={"fingerprint": existing.fingerprint}, + active=True, + ) + ) + except httpx.HTTPError: + active_alerts = [] + if active_alerts: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot mark resolved: underlying alert is still active", + ) + + +async def _record_assignment_change( + updated: AlertIncident, + existing: AlertIncident, + current_user: TokenData, + background_tasks: BackgroundTasks, +) -> None: + previous_assignee = str(getattr(existing, "assignee", "") or "").strip() + next_assignee = str(getattr(updated, "assignee", "") or "").strip() + if previous_assignee == next_assignee: + return + + actor_label = current_user.username or current_user.user_id + assignment_note = ( + f"{actor_label} assigned incident to {next_assignee}" + if next_assignee + else f"{actor_label} unassigned this incident" + ) + group_ids = getattr(current_user, "group_ids", []) or [] + incident_id = str(updated.id) + try: + await run_in_threadpool( + storage_service.update_incident, + incident_id, + current_user.tenant_id, + current_user.user_id, + AlertIncidentUpdateRequest.model_validate({"note": assignment_note}), + group_ids, + getattr(current_user, "email", None), + ) + except SQLAlchemyError: + logger.exception("Failed to record assignment note for incident %s", incident_id) + + await sync_note_to_jira_comment( + updated, + tenant_id=current_user.tenant_id, + current_user=current_user, + note_text=assignment_note, + ) + if not next_assignee: + return + + await move_incident_ticket_to_in_progress( + updated, + tenant_id=current_user.tenant_id, + current_user=current_user, + ) + recipient_email = _recipient_email(next_assignee) + if recipient_email: + background_tasks.add_task( + _send_incident_assignment_email_task, + IncidentAssignmentEmail( + recipient_email=recipient_email, + incident_title=updated.alert_name, + incident_status=updated.status, + incident_severity=updated.severity, + actor=actor_label, + ), + ) + return + logger.warning( + "Skipping assignment email for incident=%s because assignee is not an email address: %s", + incident_id, + next_assignee, + ) + + +async def _sync_status_side_effects( + payload: AlertIncidentUpdateRequest, + existing: AlertIncident, + updated: AlertIncident, + current_user: TokenData, +) -> None: + if payload.note: + await sync_note_to_jira_comment( + updated, + tenant_id=current_user.tenant_id, + current_user=current_user, + note_text=payload.note, + ) + + previous_status = str(existing.status or "").lower() + updated_status = str(updated.status or "").lower() + actor_label = current_user.username or current_user.user_id + if previous_status != updated_status: + status_note = None + if updated_status == "resolved": + status_note = f"{actor_label} marked this incident as resolved" + elif updated_status == "open": + status_note = f"{actor_label} reopened this incident" + if status_note: + await sync_note_to_jira_comment( + updated, + tenant_id=current_user.tenant_id, + current_user=current_user, + note_text=status_note, + ) + if updated_status == "resolved": + await move_incident_ticket_to_done( + updated, + tenant_id=current_user.tenant_id, + current_user=current_user, + ) + if previous_status == "resolved" and updated_status == "open": + await move_incident_ticket_to_todo( + updated, + tenant_id=current_user.tenant_id, + current_user=current_user, + ) + + @router.get( "/incidents", response_model=list[AlertIncident], @@ -66,11 +227,7 @@ def _recipient_email(value: object) -> str | None: responses=BAD_REQUEST_ERRORS, ) async def list_incidents( - status_filter: str | None = Query(None, alias="status"), - visibility_filter: str | None = Query(None, alias="visibility"), - group_id_filter: str | None = Query(None, alias="group_id"), - limit: int = Query(100, ge=1, le=500), - offset: int = Query(0, ge=0), + query: IncidentListQuery = Depends(), current_user: TokenData = Depends(require_permission_with_scope(Permission.READ_INCIDENTS, "alertmanager")), ) -> list[AlertIncident]: return await run_in_threadpool( @@ -78,11 +235,11 @@ async def list_incidents( tenant_id=current_user.tenant_id, user_id=current_user.user_id, group_ids=getattr(current_user, "group_ids", []) or [], - status=status_filter, - visibility=visibility_filter, - group_id=group_id_filter, - limit=limit, - offset=offset, + status=query.status, + visibility=query.visibility, + group_id=query.group_id, + limit=query.limit, + offset=query.offset, ) @@ -125,36 +282,16 @@ async def update_incident( storage_service.get_incident_for_user, incident_id, current_user.tenant_id, - current_user.user_id, - group_ids, - True, + IncidentAccessContext( + user_id=current_user.user_id, + group_ids=group_ids, + require_write=True, + ), ) if not existing: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Incident not found") - if payload.status is not None: - status_str = payload.status.value if hasattr(payload.status, "value") else str(payload.status) - if status_str.lower() == "resolved": - existing_incident_key = incident_key_from_labels(existing.labels or {}) - try: - if existing_incident_key: - active_alerts = [ - alert - for alert in (await alertmanager_service.get_alerts(active=True)) - if incident_key_from_labels(getattr(alert, "labels", {}) or {}) == existing_incident_key - ] - else: - active_alerts = await alertmanager_service.get_alerts( - filter_labels={"fingerprint": existing.fingerprint}, - active=True, - ) - except httpx.HTTPError: - active_alerts = [] - if active_alerts: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot mark resolved: underlying alert is still active", - ) + await _ensure_resolve_allowed(payload, existing) enriched_payload = payload.model_copy(update={"actorUsername": current_user.username or current_user.user_id}) @@ -162,105 +299,15 @@ async def update_incident( storage_service.update_incident, incident_id, current_user.tenant_id, + current_user.user_id, enriched_payload, - actor=IncidentActorContext( - user_id=current_user.user_id, - group_ids=group_ids, - user_email=getattr(current_user, "email", None), - ), + group_ids, + getattr(current_user, "email", None), ) if not updated: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Incident not found") - previous_assignee = str(getattr(existing, "assignee", "") or "").strip() - next_assignee = str(getattr(updated, "assignee", "") or "").strip() - assignee_changed = previous_assignee != next_assignee - if assignee_changed: - actor_label = current_user.username or current_user.user_id - assignment_note = ( - f"{actor_label} assigned incident to {next_assignee}" - if next_assignee - else f"{actor_label} unassigned this incident" - ) - try: - await run_in_threadpool( - storage_service.update_incident, - incident_id, - current_user.tenant_id, - AlertIncidentUpdateRequest.model_validate({"note": assignment_note}), - actor=IncidentActorContext( - user_id=current_user.user_id, - group_ids=group_ids, - ), - ) - except SQLAlchemyError: - logger.exception("Failed to record assignment note for incident %s", incident_id) - await sync_note_to_jira_comment( - updated, - tenant_id=current_user.tenant_id, - current_user=current_user, - note_text=assignment_note, - ) - if next_assignee: - await move_incident_ticket_to_in_progress( - updated, - tenant_id=current_user.tenant_id, - current_user=current_user, - ) - - recipient_email = _recipient_email(next_assignee) - if recipient_email: - background_tasks.add_task( - notification_service.send_incident_assignment_email, - recipient_email=recipient_email, - incident_title=updated.alert_name, - incident_status=updated.status, - incident_severity=updated.severity, - actor=current_user.username or current_user.user_id, - ) - else: - logger.warning( - "Skipping assignment email for incident=%s because assignee is not an email address: %s", - incident_id, - next_assignee, - ) - - if payload.note: - await sync_note_to_jira_comment( - updated, - tenant_id=current_user.tenant_id, - current_user=current_user, - note_text=payload.note, - ) - - previous_status = str(existing.status or "").lower() - updated_status = str(updated.status or "").lower() - actor_label = current_user.username or current_user.user_id - if previous_status != updated_status: - status_note = None - if updated_status == "resolved": - status_note = f"{actor_label} marked this incident as resolved" - elif updated_status == "open": - status_note = f"{actor_label} reopened this incident" - if status_note: - await sync_note_to_jira_comment( - updated, - tenant_id=current_user.tenant_id, - current_user=current_user, - note_text=status_note, - ) - if updated_status == "resolved": - await move_incident_ticket_to_done( - updated, - tenant_id=current_user.tenant_id, - current_user=current_user, - ) - - if previous_status == "resolved" and updated_status == "open": - await move_incident_ticket_to_todo( - updated, - tenant_id=current_user.tenant_id, - current_user=current_user, - ) + await _record_assignment_change(updated, existing, current_user, background_tasks) + await _sync_status_side_effects(payload, existing, updated, current_user) return cast(AlertIncident, updated) diff --git a/routers/observability/jira/__init__.py b/routers/observability/jira/__init__.py index 2b9626d..e9a0afa 100644 --- a/routers/observability/jira/__init__.py +++ b/routers/observability/jira/__init__.py @@ -1,5 +1,10 @@ """ Jira integration routers split by resource domain. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from fastapi import APIRouter diff --git a/routers/observability/jira/config.py b/routers/observability/jira/config.py index 459c977..a5afc64 100644 --- a/routers/observability/jira/config.py +++ b/routers/observability/jira/config.py @@ -1,3 +1,16 @@ +""" +Config and helper functions for Jira integration in the observability notifier router. +This includes functions to determine if an alert is suppressed based on its status information, +which is crucial for filtering out alerts that should not trigger notifications or actions in Jira. +The logic checks for specific fields and values that indicate suppression, such as "state" being +"suppressed" or the presence of "silencedBy" or "inhibitedBy" fields in the alert status. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +""" + from fastapi import APIRouter, Body, Depends from custom_types.json import JSONDict @@ -6,7 +19,11 @@ from middleware.openapi import BAD_REQUEST_ERRORS, NOT_FOUND_ERRORS, merge_responses from models.access.auth_models import Permission, TokenData from models.alerting.requests import JiraConfigUpdateRequest -from services.alerting.integration_security_service import load_tenant_jira_config, save_tenant_jira_config +from services.alerting.integration_security_service import ( + JiraTenantConfigUpdate, + load_tenant_jira_config, + save_tenant_jira_config, +) router = APIRouter(tags=["alertmanager-jira"]) @@ -53,10 +70,12 @@ async def update_jira_config( ) -> JSONDict: saved = save_tenant_jira_config( current_user.tenant_id, - enabled=bool(payload.enabled), - base_url=payload.baseUrl, - email=payload.email, - api_token=payload.apiToken, - bearer=payload.bearerToken, + JiraTenantConfigUpdate( + enabled=bool(payload.enabled), + base_url=payload.baseUrl, + email=payload.email, + api_token=payload.apiToken, + bearer=payload.bearerToken, + ), ) return _jira_config_payload(saved) diff --git a/routers/observability/jira/discovery.py b/routers/observability/jira/discovery.py index 2aafd1d..382fae3 100644 --- a/routers/observability/jira/discovery.py +++ b/routers/observability/jira/discovery.py @@ -1,3 +1,19 @@ +""" +Discovery endpoints for Jira integration in the observability notifier router. +These endpoints allow clients to list available Jira projects and issue types +based on either tenant-level Jira configuration or specific Jira integrations. +The endpoints handle authentication and permissions, and they return structured +responses that indicate whether Jira is enabled for the tenant and what projects +and issue types are available. This functionality is essential for users to +configure their Jira integrations effectively when setting up incident management +workflows in the observability platform. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +""" + from fastapi import APIRouter, Depends, HTTPException, Query, status from custom_types.json import JSONDict diff --git a/routers/observability/jira/incident_links.py b/routers/observability/jira/incident_links.py index a78a70e..b62fbc4 100644 --- a/routers/observability/jira/incident_links.py +++ b/routers/observability/jira/incident_links.py @@ -1,3 +1,18 @@ +""" +Incident link discovery endpoints for Jira integration in the observability notifier router. +These endpoints allow clients to list available Jira projects and issue types based on either +tenant-level Jira configuration or specific Jira integrations. The endpoints handle authentication +and permissions, and they return structured responses that indicate whether Jira is enabled for the +tenant and what projects and issue types are available. This functionality is essential for users to +configure their Jira integrations effectively when setting up incident management workflows in the +observability platform. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +""" + import logging from typing import cast @@ -22,7 +37,8 @@ map_severity_to_jira_priority, ) from services.jira.helpers import resolve_incident_jira_credentials -from services.jira_service import JiraError, jira_service +from services.jira_service import JiraError, JiraIssueCreateOptions, JiraIssueCreateRequest, jira_service +from services.storage.incidents import IncidentAccessContext from .shared import SUPPORTED_INCIDENT_JIRA_ISSUE_TYPES, storage_service @@ -50,9 +66,7 @@ async def create_incident_link( storage_service.get_incident_for_user, incident_id, current_user.tenant_id, - current_user.user_id, - group_ids, - True, + IncidentAccessContext(user_id=current_user.user_id, group_ids=group_ids, require_write=True), ) if not incident: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Incident not found") @@ -85,12 +99,16 @@ async def create_incident_link( try: response = await jira_service.create_issue( - project_key=project, - summary=(payload.summary or incident.alert_name or "Incident").strip(), - description=format_incident_description(incident, payload.description), - issue_type="Bug" if requested_issue_type.lower() == "bug" else "Task", - priority=map_severity_to_jira_priority(getattr(incident, "severity", None)), - credentials=jira_integration_credentials(integration), + request=JiraIssueCreateRequest( + project_key=project, + summary=(payload.summary or incident.alert_name or "Incident").strip(), + options=JiraIssueCreateOptions( + description=format_incident_description(incident, payload.description), + issue_type="Bug" if requested_issue_type.lower() == "bug" else "Task", + priority=map_severity_to_jira_priority(getattr(incident, "severity", None)), + ), + credentials=jira_integration_credentials(integration), + ) ) except JiraError as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc @@ -156,9 +174,7 @@ async def sync_incident_notes( storage_service.get_incident_for_user, incident_id, current_user.tenant_id, - current_user.user_id, - group_ids, - True, + IncidentAccessContext(user_id=current_user.user_id, group_ids=group_ids, require_write=True), ) if not incident: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Incident not found") @@ -216,8 +232,7 @@ async def list_incident_comments( storage_service.get_incident_for_user, incident_id, current_user.tenant_id, - current_user.user_id, - group_ids, + IncidentAccessContext(user_id=current_user.user_id, group_ids=group_ids), ) if not incident: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Incident not found") diff --git a/routers/observability/jira/integrations.py b/routers/observability/jira/integrations.py index 86cffbf..4455f7f 100644 --- a/routers/observability/jira/integrations.py +++ b/routers/observability/jira/integrations.py @@ -1,3 +1,18 @@ +""" +Integrations management endpoints for Jira integration in the observability notifier router. +These endpoints allow users to create, update, delete, and list Jira integrations that are used +for incident management workflows. The endpoints handle authentication, permissions, and validation +of Jira credentials, and they ensure that sensitive information is encrypted when stored and masked +when returned in responses. Additionally, there is functionality to toggle the visibility of shared +Jira integrations for individual users. This module is crucial for managing the connections between +the observability platform and Jira for effective incident tracking and resolution. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +""" + import uuid from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, status diff --git a/routers/observability/jira/shared.py b/routers/observability/jira/shared.py index 498aecc..567624b 100644 --- a/routers/observability/jira/shared.py +++ b/routers/observability/jira/shared.py @@ -1,5 +1,13 @@ """ -Shared Jira router state and helpers. +Shared models and utilities for Jira integration in the observability notifier router. This includes common data models, +constants, and helper functions that are used across multiple Jira-related endpoints, such as those for incident link +discovery and integration management. By centralizing these shared components, we can ensure consistency and reduce code +duplication across the Jira integration features in the notifier router. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from pydantic import BaseModel diff --git a/services/__init__.py b/services/__init__.py index a645fb6..1140959 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ __all__ = [] diff --git a/services/alerting/__init__.py b/services/alerting/__init__.py index a645fb6..1140959 100644 --- a/services/alerting/__init__.py +++ b/services/alerting/__init__.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ __all__ = [] diff --git a/services/alerting/alerts_ops.py b/services/alerting/alerts_ops.py index b4e1300..c8e58b8 100644 --- a/services/alerting/alerts_ops.py +++ b/services/alerting/alerts_ops.py @@ -2,16 +2,16 @@ Alerting-related operations for interacting with Alertmanager and Mimir, including fetching metrics, listing alerts and groups, posting new alerts, and deleting alerts via silences. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any @@ -28,6 +28,14 @@ QueryParamMapping = Mapping[str, QueryParamValue | Sequence[QueryParamValue]] +@dataclass(frozen=True) +class AlertQuery: + filter_labels: dict[str, str] = field(default_factory=dict) + active: bool | None = None + silenced: bool | None = None + inhibited: bool | None = None + + async def list_metric_names(service: AlertManagerService, org_id: str) -> list[str]: response = await service.mimir_http_client.get( f"{config.mimir_url.rstrip('/')}/prometheus/api/v1/label/__name__/values", @@ -162,21 +170,19 @@ async def evaluate_promql( async def get_alerts( service: AlertManagerService, - filter_labels: dict[str, str] | None = None, - active: bool | None = None, - silenced: bool | None = None, - inhibited: bool | None = None, + query: AlertQuery | None = None, ) -> list[Alert]: + effective_query = query or AlertQuery() params: dict[str, QueryParamValue | Sequence[QueryParamValue]] = {} - if filter_labels: - params["filter"] = [f'{k}="{v}"' for k, v in filter_labels.items()] - if active is not None: - params["active"] = str(active).lower() - if silenced is not None: - params["silenced"] = str(silenced).lower() - if inhibited is not None: - params["inhibited"] = str(inhibited).lower() + if effective_query.filter_labels: + params["filter"] = [f'{key}="{value}"' for key, value in effective_query.filter_labels.items()] + if effective_query.active is not None: + params["active"] = str(effective_query.active).lower() + if effective_query.silenced is not None: + params["silenced"] = str(effective_query.silenced).lower() + if effective_query.inhibited is not None: + params["inhibited"] = str(effective_query.inhibited).lower() try: response = await service.alertmanager_http_client.get( diff --git a/services/alerting/channels_ops.py b/services/alerting/channels_ops.py index 57c1c2a..8e09fa9 100644 --- a/services/alerting/channels_ops.py +++ b/services/alerting/channels_ops.py @@ -2,16 +2,16 @@ Channel operations for alerting, including processing incoming alerts from Alertmanager, determining notification channels, and sending notifications based on alert status and configuration. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations import logging +from dataclasses import dataclass from datetime import UTC, datetime from typing import TYPE_CHECKING @@ -19,6 +19,7 @@ from custom_types.json import JSONDict from models.alerting.alerts import Alert, AlertState, AlertStatus +from models.alerting.channels import NotificationChannel from models.alerting.receivers import AlertManagerStatus from services.alerting.suppression import is_suppressed_status from services.notification_service import NotificationService @@ -51,14 +52,99 @@ def _optional_string(value: object) -> str | None: return text or None +@dataclass(frozen=True) +class NotificationDispatchContext: + service: AlertManagerService + tenant_id: str + storage_service: DatabaseStorageService + notification_service: NotificationService + + +def _resolve_channels_and_rule( + context: NotificationDispatchContext, + alertname: str, + org_id: str | None, +) -> tuple[list[NotificationChannel], object | None]: + channels = context.storage_service.get_notification_channels_for_rule_name( + context.tenant_id, + alertname, + org_id=org_id, + ) + matched_rule = context.storage_service.get_alert_rule_by_name_for_delivery( + context.tenant_id, + alertname, + org_id=org_id, + ) + return channels, matched_rule + + +def _status_object(raw_status: object) -> tuple[AlertStatus, bool]: + silenced: list[str] = [] + inhibited: list[str] = [] + if isinstance(raw_status, dict): + state_value = raw_status.get("state") + silenced = _string_list(raw_status.get("silencedBy")) + inhibited = _string_list(raw_status.get("inhibitedBy")) + else: + state_value = raw_status if isinstance(raw_status, str) else None + is_active = bool(state_value) and str(state_value).lower() in {"active", "firing"} + state_enum = AlertState.ACTIVE if is_active else AlertState.UNPROCESSED + return AlertStatus(state=state_enum, silencedBy=silenced, inhibitedBy=inhibited), is_active + + +def _enriched_alert_annotations( + annotations: dict[str, str], + labels: dict[str, str], + matched_rule: object | None, +) -> dict[str, str]: + if not matched_rule: + return annotations + enriched_annotations = dict(annotations) + corr = str(getattr(matched_rule, "group", "") or "") + enriched_annotations.setdefault("watchdogCorrelationId", corr) + enriched_annotations.setdefault("WatchdogCorrelationId", corr) + created_by = str(getattr(matched_rule, "created_by", "") or "") + enriched_annotations.setdefault("watchdogCreatedBy", created_by) + enriched_annotations.setdefault("WatchdogCreatedBy", created_by) + rule_annotations = _string_dict(getattr(matched_rule, "annotations", {}) or {}) + created_by_username = ( + rule_annotations.get("watchdogCreatedByUsername") + or rule_annotations.get("createdByUsername") + or rule_annotations.get("created_by_username") + ) + if created_by_username: + enriched_annotations.setdefault("watchdogCreatedByUsername", str(created_by_username)) + enriched_annotations.setdefault("WatchdogCreatedByUsername", str(created_by_username)) + rule_name = str(getattr(matched_rule, "name", "") or "") + enriched_annotations.setdefault("watchdogRuleName", rule_name) + enriched_annotations.setdefault("WatchdogRuleName", rule_name) + product_name = ( + rule_annotations.get("watchdogProductName") + or rule_annotations.get("productName") + or rule_annotations.get("product_name") + or labels.get("product") + ) + if product_name: + enriched_annotations.setdefault("watchdogProductName", str(product_name)) + enriched_annotations.setdefault("WatchdogProductName", str(product_name)) + return enriched_annotations + + +async def _dispatch_alert_to_channels( + context: NotificationDispatchContext, + channels: list[NotificationChannel], + alert_model: Alert, + action: str, +) -> None: + for channel in channels: + sent = await context.notification_service.send_notification(channel, alert_model, action) + logger.info("Sent notification to channel %s ok=%s", channel.name, sent) + + async def notify_for_alerts( - service: AlertManagerService, - tenant_id: str, + context: NotificationDispatchContext, alerts_list: list[JSONDict], - storage_service: DatabaseStorageService, - notification_service: NotificationService, ) -> None: - _ = service for incoming_alert in alerts_list: labels = _string_dict(incoming_alert.get("labels") or {}) alertname = labels.get("alertname") @@ -67,16 +153,7 @@ async def notify_for_alerts( continue org_id = labels.get("org_id") or labels.get("orgId") or labels.get("tenant") or labels.get("product") - channels = storage_service.get_notification_channels_for_rule_name( - tenant_id, - alertname, - org_id=org_id, - ) - matched_rule = storage_service.get_alert_rule_by_name_for_delivery( - tenant_id, - alertname, - org_id=org_id, - ) + channels, matched_rule = _resolve_channels_and_rule(context, alertname, org_id) if not channels: logger.info( "No deliverable notification channels for rule=%s org=%s " @@ -91,53 +168,13 @@ async def notify_for_alerts( raw_status = incoming_alert.get("status") or {} if _is_suppressed(raw_status): - logger.info("Skipping notification for suppressed alert=%s tenant=%s", alertname, tenant_id) + logger.info("Skipping notification for suppressed alert=%s tenant=%s", alertname, context.tenant_id) continue - silenced: list[str] = [] - inhibited: list[str] = [] - if isinstance(raw_status, dict): - state_value = raw_status.get("state") - silenced = _string_list(raw_status.get("silencedBy")) - inhibited = _string_list(raw_status.get("inhibitedBy")) - else: - state_value = raw_status if isinstance(raw_status, str) else None - - is_active = state_value and str(state_value).lower() in {"active", "firing"} - state_enum = AlertState.ACTIVE if is_active else AlertState.UNPROCESSED - status_obj = AlertStatus(state=state_enum, silencedBy=silenced, inhibitedBy=inhibited) + status_obj, is_active = _status_object(raw_status) labels = _string_dict(incoming_alert.get("labels") or {}) annotations = _string_dict(incoming_alert.get("annotations") or {}) - if matched_rule: - enriched_annotations = dict(annotations) - corr = str(getattr(matched_rule, "group", "") or "") - enriched_annotations.setdefault("watchdogCorrelationId", corr) - enriched_annotations.setdefault("WatchdogCorrelationId", corr) - created_by = str(getattr(matched_rule, "created_by", "") or "") - enriched_annotations.setdefault("watchdogCreatedBy", created_by) - enriched_annotations.setdefault("WatchdogCreatedBy", created_by) - rule_annotations = _string_dict(getattr(matched_rule, "annotations", {}) or {}) - created_by_username = ( - rule_annotations.get("watchdogCreatedByUsername") - or rule_annotations.get("createdByUsername") - or rule_annotations.get("created_by_username") - ) - if created_by_username: - enriched_annotations.setdefault("watchdogCreatedByUsername", str(created_by_username)) - enriched_annotations.setdefault("WatchdogCreatedByUsername", str(created_by_username)) - rule_name = str(getattr(matched_rule, "name", "") or "") - enriched_annotations.setdefault("watchdogRuleName", rule_name) - enriched_annotations.setdefault("WatchdogRuleName", rule_name) - product_name = ( - rule_annotations.get("watchdogProductName") - or rule_annotations.get("productName") - or rule_annotations.get("product_name") - or labels.get("product") - ) - if product_name: - enriched_annotations.setdefault("watchdogProductName", str(product_name)) - enriched_annotations.setdefault("WatchdogProductName", str(product_name)) - annotations = enriched_annotations + annotations = _enriched_alert_annotations(annotations, labels, matched_rule) alert_model = Alert( labels=labels, @@ -151,9 +188,7 @@ async def notify_for_alerts( ) action = "firing" if is_active else "resolved" - for channel in channels: - sent = await notification_service.send_notification(channel, alert_model, action) - logger.info("Sent notification to channel %s ok=%s", channel.name, sent) + await _dispatch_alert_to_channels(context, channels, alert_model, action) async def get_status(service: AlertManagerService) -> AlertManagerStatus | None: diff --git a/services/alerting/integration_security_service.py b/services/alerting/integration_security_service.py index d05561e..f7c2f38 100644 --- a/services/alerting/integration_security_service.py +++ b/services/alerting/integration_security_service.py @@ -2,16 +2,16 @@ Integration security service for managing Jira integration configurations, including credential storage, access control, and synchronization of Jira comments to incident notes. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import logging import os from collections.abc import Mapping, Sequence +from dataclasses import dataclass from urllib.parse import urlparse from cryptography.fernet import Fernet, InvalidToken @@ -194,7 +194,7 @@ def decrypt_tenant_secret(value: str | None) -> str | None: return None text = str(value) if not text.startswith("enc:"): - logger.warning("Encountered legacy plaintext Jira secret; migration is required") + logger.warning("Encountered plaintext Jira secret; migration is required") return text if not config.data_encryption_key: return None @@ -221,21 +221,22 @@ def load_tenant_jira_config(tenant_id: str) -> dict[str, object]: } -def save_tenant_jira_config( - tenant_id: str, - *, - enabled: bool, - base_url: str | None, - email: str | None, - api_token: str | None, - bearer: str | None, -) -> dict[str, object]: - normalized_url = str(base_url or "").strip() or None - normalized_email = str(email or "").strip() or None - normalized_api_token = str(api_token or "").strip() or None - normalized_bearer = str(bearer or "").strip() or None - - if enabled: +@dataclass(frozen=True) +class JiraTenantConfigUpdate: + enabled: bool + base_url: str | None + email: str | None + api_token: str | None + bearer: str | None + + +def save_tenant_jira_config(tenant_id: str, update: JiraTenantConfigUpdate) -> dict[str, object]: + normalized_url = str(update.base_url or "").strip() or None + normalized_email = str(update.email or "").strip() or None + normalized_api_token = str(update.api_token or "").strip() or None + normalized_bearer = str(update.bearer or "").strip() or None + + if update.enabled: if not is_safe_http_url(normalized_url): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jira base URL is missing or invalid") if not (normalized_bearer or (normalized_email and normalized_api_token)): @@ -250,7 +251,7 @@ def save_tenant_jira_config( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") settings = _tenant_settings_copy(tenant) jira_cfg: JSONDict = { - "enabled": bool(enabled), + "enabled": bool(update.enabled), "base_url": normalized_url, "email": normalized_email, "api_token": encrypt_tenant_secret(normalized_api_token), diff --git a/services/alerting/rule_import_service.py b/services/alerting/rule_import_service.py index 725e408..ee4d4c4 100644 --- a/services/alerting/rule_import_service.py +++ b/services/alerting/rule_import_service.py @@ -2,11 +2,10 @@ Rule import service for parsing and normalizing alert rules from YAML content, supporting various input formats, default values, and error handling. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/services/alerting/ruler_yaml.py b/services/alerting/ruler_yaml.py index e31191d..604633e 100644 --- a/services/alerting/ruler_yaml.py +++ b/services/alerting/ruler_yaml.py @@ -2,11 +2,10 @@ Rule import and alert processing logic for Alertmanager integration, including parsing incoming alerts, determining notification channels, and sending notifications based on alert status and configuration. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from config import config diff --git a/services/alerting/rules_ops.py b/services/alerting/rules_ops.py index 8788195..acc872e 100644 --- a/services/alerting/rules_ops.py +++ b/services/alerting/rules_ops.py @@ -2,11 +2,10 @@ Rule operations for synchronizing alert rules with Mimir, including resolving organization IDs, listing existing rules, and upserting new rules based on the desired configuration. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/services/alerting/silence_metadata.py b/services/alerting/silence_metadata.py index 3680167..cd6630a 100644 --- a/services/alerting/silence_metadata.py +++ b/services/alerting/silence_metadata.py @@ -2,11 +2,10 @@ Silence metadata encoding and decoding for Alertmanager silences, allowing storage of visibility and shared group information within the silence comment field. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import json diff --git a/services/alerting/silences_ops.py b/services/alerting/silences_ops.py index 2684112..796a097 100644 --- a/services/alerting/silences_ops.py +++ b/services/alerting/silences_ops.py @@ -2,11 +2,10 @@ Silences operations for managing Alertmanager silences, including fetching, creating, updating, and deleting silences, as well as applying metadata and access control based on user permissions. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/services/alerting/suppression.py b/services/alerting/suppression.py index a31db5b..9b0b48a 100644 --- a/services/alerting/suppression.py +++ b/services/alerting/suppression.py @@ -1,5 +1,12 @@ """ -Helpers for identifying suppressed Alertmanager alerts. +Helpers for determining whether an alert is currently suppressed based on +its status information. This filters suppressed alerts out of notifications +and other processing by checking common suppression indicators. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/services/alertmanager_service.py b/services/alertmanager_service.py index 003761d..a903a91 100644 --- a/services/alertmanager_service.py +++ b/services/alertmanager_service.py @@ -2,11 +2,10 @@ Service for managing interactions with AlertManager, providing functions to retrieve and manage alerts, silences, and notification channels. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import json @@ -22,12 +21,15 @@ from config import config from database import get_db_session from db_models import PurgedSilence -from middleware.dependencies import enforce_public_endpoint_security +from middleware.dependencies import PublicEndpointSecurityConfig, enforce_public_endpoint_security from middleware.resilience import with_retry, with_timeout from models.access.auth_models import TokenData from models.alerting.alerts import Alert from models.alerting.rules import AlertRule from models.alerting.silences import Silence +from services.alerting.alerts_ops import ( + AlertQuery, +) from services.alerting.alerts_ops import ( delete_alerts as delete_alerts_ops, ) @@ -178,10 +180,12 @@ def user_scope(self, current_user: TokenData) -> tuple[str, str, list[str]]: def enforce_webhook_security(self, request: Request, *, scope: str) -> None: enforce_public_endpoint_security( request, - scope=scope, - limit=config.rate_limit_public_per_minute, - window_seconds=60, - allowlist=config.webhook_ip_allowlist, + PublicEndpointSecurityConfig( + scope=scope, + limit=config.rate_limit_public_per_minute, + window_seconds=60, + allowlist=config.webhook_ip_allowlist, + ), ) expected = config.inbound_webhook_token if not expected: @@ -240,6 +244,8 @@ def __getattr__(self, name: str) -> Any: raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}") async def _async_bound(*args: object, **kwargs: object) -> Any: + if name == "notify_for_alerts": + return await op(*args, **kwargs) return await op(self, *args, **kwargs) return _async_bound @@ -248,12 +254,9 @@ async def _async_bound(*args: object, **kwargs: object) -> Any: @with_timeout() async def get_alerts( self, - filter_labels: dict[str, str] | None = None, - active: bool | None = None, - silenced: bool | None = None, - inhibited: bool | None = None, + query: AlertQuery | None = None, ) -> list[Alert]: - return await get_alerts_ops(self, filter_labels, active, silenced, inhibited) + return await get_alerts_ops(self, query) async def delete_silence(self, silence_id: str) -> bool: if not await delete_silence_ops(self, silence_id): diff --git a/services/common/__init__.py b/services/common/__init__.py index d0acfc6..75c2fc7 100644 --- a/services/common/__init__.py +++ b/services/common/__init__.py @@ -2,6 +2,5 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ diff --git a/services/common/access.py b/services/common/access.py index 3ff8e9a..712444d 100644 --- a/services/common/access.py +++ b/services/common/access.py @@ -1,14 +1,14 @@ """ Access control utilities for checking user permissions and resolving group memberships for tenant-based resources. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import logging +from dataclasses import dataclass from fastapi import HTTPException, status from sqlalchemy.exc import IntegrityError @@ -19,14 +19,35 @@ logger = logging.getLogger(__name__) -def _resolve_groups( - db: Session, - tenant_id: str, - group_ids: list[str], - *, - actor_group_ids: list[str] | None = None, - enforce_membership: bool = True, -) -> list[Group]: +@dataclass(frozen=True) +class GroupResolveRequest: + tenant_id: str + group_ids: list[str] + actor_group_ids: list[str] | None = None + enforce_membership: bool = True + + +@dataclass(frozen=True) +class SharedGroupAssignment: + tenant_id: str + visibility: str + group_ids: list[str] | None + actor_group_ids: list[str] | None + + +@dataclass(frozen=True) +class AccessCheck: + visibility: str + created_by: str | None + user_id: str + shared_group_ids: list[str] + user_group_ids: list[str] + require_write: bool = False + + +def _resolve_groups(db: Session, request: GroupResolveRequest) -> list[Group]: + tenant_id = request.tenant_id + group_ids = request.group_ids normalized = [s for gid in (group_ids or []) if gid is not None and (s := str(gid).strip())] if not normalized: return [] @@ -53,8 +74,8 @@ def _resolve_groups( groups = db.query(Group).filter(Group.tenant_id == tenant_id, Group.id.in_(normalized)).all() present_ids = {g.id for g in groups} - if enforce_membership: - actor_groups = set(actor_group_ids or []) + if request.enforce_membership: + actor_groups = set(request.actor_group_ids or []) unauthorized = [gid for gid in normalized if gid in present_ids and gid not in actor_groups] if unauthorized: raise HTTPException( @@ -68,42 +89,36 @@ def _resolve_groups( def assign_shared_groups( db_obj: AlertRule | NotificationChannel, db: Session, - tenant_id: str, - visibility: str, - group_ids: list[str] | None, - *, - actor_group_ids: list[str] | None, + assignment: SharedGroupAssignment, ) -> None: - if visibility != "group": + if assignment.visibility != "group": db_obj.shared_groups = [] return - if group_ids is None: + if assignment.group_ids is None: raise ValueError("group_ids is required when visibility is 'group'") db_obj.shared_groups = _resolve_groups( db, - tenant_id, - group_ids, - actor_group_ids=actor_group_ids, + GroupResolveRequest( + tenant_id=assignment.tenant_id, + group_ids=assignment.group_ids, + actor_group_ids=assignment.actor_group_ids, + ), ) -def has_access( - visibility: str, - created_by: str | None, - user_id: str, - shared_group_ids: list[str], - user_group_ids: list[str], - require_write: bool = False, -) -> bool: - if created_by == user_id: +def has_access(check: AccessCheck) -> bool: + if check.created_by == check.user_id: return True - if require_write: + + if check.require_write: return False - if visibility in ("public", "tenant"): + + if check.visibility in ("public", "tenant"): return True - if visibility == "group": - return bool(set(shared_group_ids) & set(user_group_ids)) - if visibility == "private": - return False - logger.warning("Unknown visibility value %r encountered in access check", visibility) + + if check.visibility == "group": + return bool(set(check.shared_group_ids) & set(check.user_group_ids)) + + if check.visibility != "private": + logger.warning("Unknown visibility value %r encountered in access check", check.visibility) return False diff --git a/services/common/encryption.py b/services/common/encryption.py index 7d80bd8..db4ea64 100644 --- a/services/common/encryption.py +++ b/services/common/encryption.py @@ -4,11 +4,10 @@ such as API keys and channel credentials are stored securely in the database and can be recovered only with the correct encryption key. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import json diff --git a/services/common/http_client.py b/services/common/http_client.py index bbcc1b6..2ec1b56 100644 --- a/services/common/http_client.py +++ b/services/common/http_client.py @@ -4,11 +4,10 @@ Keycloak for user provisioning and token validation, abstracting away the details of the HTTP interactions and allowing for easier integration with different authentication providers. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import httpx diff --git a/services/common/meta.py b/services/common/meta.py index a87c9c1..ee41fb4 100644 --- a/services/common/meta.py +++ b/services/common/meta.py @@ -4,11 +4,10 @@ string format within the incident's annotations. This module provides functions to safely parse the metadata and extract shared group IDs while ensuring that only valid string group IDs are returned. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import json @@ -21,20 +20,19 @@ def parse_meta(annotations: object) -> JSONDict: - if not isinstance(annotations, dict): - return {} - raw = annotations.get(INCIDENT_META_KEY) - if is_json_object(raw): - return raw - if isinstance(raw, str): - try: - payload = json.loads(raw) + parsed: JSONDict = {} + if isinstance(annotations, dict): + raw = annotations.get(INCIDENT_META_KEY) + if is_json_object(raw): + parsed = raw + elif isinstance(raw, str): + try: + payload = json.loads(raw) + except JSONDecodeError: + payload = None if is_json_object(payload): - return payload - return {} - except JSONDecodeError: - return {} - return {} + parsed = payload + return parsed def _safe_group_ids(meta: Mapping[str, object]) -> list[str]: diff --git a/services/common/pagination.py b/services/common/pagination.py index 79051d5..174dd28 100644 --- a/services/common/pagination.py +++ b/services/common/pagination.py @@ -5,11 +5,10 @@ records returned in a single request while allowing clients to specify their desired pagination settings within those constraints. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from config import config as app_config diff --git a/services/common/tenants.py b/services/common/tenants.py index 832ba4a..2decf89 100644 --- a/services/common/tenants.py +++ b/services/common/tenants.py @@ -2,11 +2,10 @@ Tenants management utilities for ensuring tenant existence and handling tenant-related operations in a multi-tenant application context. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/services/common/url_utils.py b/services/common/url_utils.py index d8a174c..9a3c134 100644 --- a/services/common/url_utils.py +++ b/services/common/url_utils.py @@ -4,7 +4,7 @@ to external services. This module provides common URL-related utilities that can be used across different parts of the application when working with URLs for external services, such as authentication providers or API endpoints. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,35 +19,25 @@ def is_safe_http_url(value: str | None) -> bool: - if not value or not isinstance(value, str): - return False - - if len(value) > MAX_URL_LENGTH: - return False - - try: - parsed = urlparse(value.strip()) - except ValueError: - return False - - if parsed.scheme not in ALLOWED_SCHEMES: - return False - - hostname = parsed.hostname - if not hostname: - return False - - if hostname in ("localhost",) or hostname.endswith(".local"): - return False - - try: - ip = ipaddress.ip_address(hostname) - if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: - return False - except ValueError: - pass - - if not parsed.netloc or "." not in hostname: - return False - - return True + raw_value = value.strip() if isinstance(value, str) else "" + is_valid = bool(raw_value and len(raw_value) <= MAX_URL_LENGTH) + parsed = None + if is_valid: + try: + parsed = urlparse(raw_value) + except ValueError: + is_valid = False + + hostname = parsed.hostname if parsed else None + if is_valid and parsed: + is_valid = bool(parsed.scheme in ALLOWED_SCHEMES and hostname and parsed.netloc and "." in hostname) + if is_valid and hostname: + is_valid = hostname not in ("localhost",) and not hostname.endswith(".local") + + if is_valid and hostname: + try: + ip = ipaddress.ip_address(hostname) + is_valid = ip.is_global + except ValueError: + pass + return is_valid diff --git a/services/common/visibility.py b/services/common/visibility.py index 29521df..a838579 100644 --- a/services/common/visibility.py +++ b/services/common/visibility.py @@ -1,11 +1,10 @@ """ Normalization utilities for handling visibility settings on resources... -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/services/incidents/__init__.py b/services/incidents/__init__.py index bf9de02..22222af 100644 --- a/services/incidents/__init__.py +++ b/services/incidents/__init__.py @@ -1,9 +1,8 @@ """ Incident helper functions for Notifier service. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ diff --git a/services/incidents/helpers.py b/services/incidents/helpers.py index 8ba28fb..6eb5b8f 100644 --- a/services/incidents/helpers.py +++ b/services/incidents/helpers.py @@ -1,7 +1,7 @@ """ Incident helper functions for Notifier service - formatting descriptions, mapping severities, syncing notes to Jira, etc. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/services/jira/__init__.py b/services/jira/__init__.py index 51780f4..82a0583 100644 --- a/services/jira/__init__.py +++ b/services/jira/__init__.py @@ -2,9 +2,8 @@ Jira integration helper functions for fetching Jira projects and issue types via integration credentials, and resolving Jira credentials for incidents. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ diff --git a/services/jira/helpers.py b/services/jira/helpers.py index fcc4b0f..c499d87 100644 --- a/services/jira/helpers.py +++ b/services/jira/helpers.py @@ -2,11 +2,10 @@ Jira integration helper functions for resolving credentials, checking integration usability, and fetching Jira projects and issue types via integrations. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from urllib.parse import urlparse @@ -94,6 +93,7 @@ def resolve_incident_jira_credentials( tenant_id: str, current_user: TokenData, ) -> JSONDict | None: + credentials: JSONDict | None = None integration_id = str(getattr(incident, "jira_integration_id", "") or "").strip() if integration_id: integration: JSONDict | None @@ -106,18 +106,14 @@ def resolve_incident_jira_credentials( ) except HTTPException: integration = _find_integration(tenant_id, integration_id) - if not integration or not integration_is_usable(integration): - return None + if integration and integration_is_usable(integration): + try: + credentials = dict(jira_integration_credentials(integration)) + except (TypeError, ValueError): + credentials = None + elif jira_is_enabled_for_tenant(tenant_id): try: - credentials: JSONDict = dict(jira_integration_credentials(integration)) - return credentials + credentials = dict(get_effective_jira_credentials(tenant_id)) except (TypeError, ValueError): - return None - - if not jira_is_enabled_for_tenant(tenant_id): - return None - try: - default_credentials: JSONDict = dict(get_effective_jira_credentials(tenant_id)) - return default_credentials - except (TypeError, ValueError): - return None + credentials = None + return credentials diff --git a/services/jira_service.py b/services/jira_service.py index e2c3827..48a5245 100644 --- a/services/jira_service.py +++ b/services/jira_service.py @@ -5,11 +5,10 @@ Jira. The service ensures that the base URL for Jira is properly configured and validated, and it provides error handling for various scenarios that may arise when interacting with the Jira API. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import base64 @@ -40,6 +39,30 @@ class JiraIssueCreateOptions: priority: str | None = None +@dataclass(frozen=True) +class JiraRequest: + method: Literal["GET", "POST"] + path: str + credentials: Credentials = None + params: QueryParams | None = None + payload: JSONDict | None = None + + +@dataclass(frozen=True) +class JiraTransitionTarget: + target_names: set[str] + transition_names: set[str] + status_category_key: str + + +@dataclass(frozen=True) +class JiraIssueCreateRequest: + project_key: str + summary: str + options: JiraIssueCreateOptions = JiraIssueCreateOptions() + credentials: Credentials = None + + def _string_value(value: object) -> str: return value.strip() if isinstance(value, str) else "" @@ -54,35 +77,12 @@ def _json_dict_list(value: object) -> list[JSONDict]: def _coerce_issue_options( issue: JiraIssueCreateOptions | object | None, - legacy_kwargs: dict[str, object], ) -> JiraIssueCreateOptions: if isinstance(issue, JiraIssueCreateOptions): - description = issue.description - issue_type = issue.issue_type - priority = issue.priority - elif issue is not None: - description = str(issue) - issue_type = "Task" - priority = None - else: - description = None - issue_type = "Task" - priority = None - - if "description" in legacy_kwargs: - raw = legacy_kwargs.pop("description") - description = str(raw) if raw is not None else None - if "issue_type" in legacy_kwargs: - issue_type = str(legacy_kwargs.pop("issue_type") or "Task") - if "priority" in legacy_kwargs: - raw = legacy_kwargs.pop("priority") - priority = str(raw) if raw is not None else None - - return JiraIssueCreateOptions( - description=description, - issue_type=issue_type, - priority=priority, - ) + return issue + if issue is not None: + return JiraIssueCreateOptions(description=str(issue)) + return JiraIssueCreateOptions() class JiraError(Exception): @@ -144,76 +144,62 @@ def _build_url(self, path: str, credentials: Credentials = None) -> str: raise JiraError("JIRA_BASE_URL not configured or invalid") return f"{base_url}{path}" - async def _request( - self, - method: Literal["GET", "POST"], - path: str, - credentials: Credentials = None, - params: QueryParams | None = None, - payload: JSONDict | None = None, - ) -> JSONValue: - url = self._build_url(path, credentials) - headers = self._headers(credentials) + async def _request(self, request: JiraRequest) -> JSONValue: + url = self._build_url(request.path, request.credentials) + headers = self._headers(request.credentials) try: - if method == "GET": - response = await self._client.get(url, headers=headers, params=params) + if request.method == "GET": + response = await self._client.get(url, headers=headers, params=request.params) else: - response = await self._client.post(url, json=payload, headers=headers) + response = await self._client.post(url, json=request.payload, headers=headers) response.raise_for_status() result = response.json() if response.content else {} return result except httpx.HTTPStatusError as exc: - logger.warning("Jira %s failed: %s %s", method, exc.response.status_code, exc.response.text[:240]) + logger.warning("Jira %s failed: %s %s", request.method, exc.response.status_code, exc.response.text[:240]) detail = (exc.response.text or "").strip() if detail: raise JiraError(f"Jira API error: {exc.response.status_code} - {detail[:240]}") from exc raise JiraError(f"Jira API error: {exc.response.status_code}") from exc except httpx.TimeoutException as exc: host = urlparse(url).netloc or "jira" - logger.warning("Jira %s timeout contacting %s", method, host) + logger.warning("Jira %s timeout contacting %s", request.method, host) raise JiraError(f"Jira request timed out while contacting {host}") from exc except httpx.RequestError as exc: host = urlparse(url).netloc or "jira" - logger.warning("Jira %s connection failure contacting %s: %s", method, host, exc) + logger.warning("Jira %s connection failure contacting %s: %s", request.method, host, exc) raise JiraError(f"Unable to connect to Jira host {host}") from exc except RuntimeError as exc: host = urlparse(url).netloc or "jira" - logger.warning("Jira %s runtime transport failure contacting %s: %s", method, host, exc) + logger.warning("Jira %s runtime transport failure contacting %s: %s", request.method, host, exc) raise JiraError(f"Unable to connect to Jira host {host}") from exc except JiraError: raise except Exception as exc: - logger.exception("Unexpected Jira %s error", method) + logger.exception("Unexpected Jira %s error", request.method) raise JiraError("Failed to contact Jira API") from exc async def _get(self, path: str, credentials: Credentials = None, params: QueryParams | None = None) -> JSONValue: - return await self._request("GET", path, credentials, params=params) + return await self._request(JiraRequest(method="GET", path=path, credentials=credentials, params=params)) async def _post(self, path: str, payload: JSONDict, credentials: Credentials = None) -> JSONValue: - return await self._request("POST", path, credentials, payload=payload) + return await self._request(JiraRequest(method="POST", path=path, credentials=credentials, payload=payload)) - async def create_issue( - self, - project_key: str, - summary: str, - issue: JiraIssueCreateOptions | str | None = None, - credentials: Credentials = None, - **legacy_kwargs: object, - ) -> JSONDict: - options = _coerce_issue_options(issue, dict(legacy_kwargs)) + async def create_issue(self, request: JiraIssueCreateRequest) -> JSONDict: + issue_options = _coerce_issue_options(request.options) fields: JSONDict = { - "project": {"key": project_key}, - "summary": summary, - "description": options.description or "", - "issuetype": {"name": options.issue_type}, + "project": {"key": request.project_key}, + "summary": request.summary, + "description": issue_options.description or "", + "issuetype": {"name": issue_options.issue_type}, } - if options.priority: - fields["priority"] = {"name": str(options.priority).strip()} + if issue_options.priority: + fields["priority"] = {"name": str(issue_options.priority).strip()} payload: JSONDict = {"fields": fields} - data = await self._post("/rest/api/2/issue", payload, credentials) + data = await self._post("/rest/api/2/issue", payload, request.credentials) data_dict = _json_dict(data) key = data_dict.get("key") - base_url = self._resolve_base_url(credentials) + base_url = self._resolve_base_url(request.credentials) return { "key": key, "url": f"{base_url}/browse/{key}" if key else None, @@ -255,38 +241,41 @@ async def transition_issue( async def transition_issue_to_todo(self, issue_key: str, credentials: Credentials = None) -> bool: return await self._transition_issue_by_target( issue_key, - credentials=credentials, - target_names={"to do", "todo"}, - transition_names={"to do", "todo"}, - status_category_key="new", + JiraTransitionTarget( + target_names={"to do", "todo"}, + transition_names={"to do", "todo"}, + status_category_key="new", + ), + credentials, ) async def transition_issue_to_in_progress(self, issue_key: str, credentials: Credentials = None) -> bool: return await self._transition_issue_by_target( issue_key, - credentials=credentials, - target_names={"in progress", "in-progress", "doing"}, - transition_names={"start progress", "in progress", "start"}, - status_category_key="indeterminate", + JiraTransitionTarget( + target_names={"in progress", "in-progress", "doing"}, + transition_names={"start progress", "in progress", "start"}, + status_category_key="indeterminate", + ), + credentials, ) async def transition_issue_to_done(self, issue_key: str, credentials: Credentials = None) -> bool: return await self._transition_issue_by_target( issue_key, - credentials=credentials, - target_names={"done", "closed", "resolved"}, - transition_names={"done", "close issue", "resolve issue", "resolve"}, - status_category_key="done", + JiraTransitionTarget( + target_names={"done", "closed", "resolved"}, + transition_names={"done", "close issue", "resolve issue", "resolve"}, + status_category_key="done", + ), + credentials, ) async def _transition_issue_by_target( self, issue_key: str, - *, + target: JiraTransitionTarget, credentials: Credentials = None, - target_names: set[str], - transition_names: set[str], - status_category_key: str, ) -> bool: transitions = await self.list_transitions(issue_key, credentials) if not transitions: @@ -307,11 +296,20 @@ def _status_category(item: JSONDict) -> str: category: JSONDict = raw_category if isinstance(raw_category, dict) else {} return str(category.get("key") or "").strip().lower() - preferred: JSONDict | None = next((item for item in transitions if _target_name(item) in target_names), None) + preferred: JSONDict | None = next( + (item for item in transitions if _target_name(item) in target.target_names), + None, + ) if not preferred: - preferred = next((item for item in transitions if _name(item) in transition_names), None) + preferred = next( + (item for item in transitions if _name(item) in target.transition_names), + None, + ) if not preferred: - preferred = next((item for item in transitions if _status_category(item) == status_category_key), None) + preferred = next( + (item for item in transitions if _status_category(item) == target.status_category_key), + None, + ) if not preferred: return False diff --git a/services/notification/email_providers.py b/services/notification/email_providers.py index 4e4afca..fcacd8b 100644 --- a/services/notification/email_providers.py +++ b/services/notification/email_providers.py @@ -5,18 +5,16 @@ ensure that email sending operations are performed securely and efficiently, with proper logging and error handling to facilitate troubleshooting and monitoring of email delivery performance. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import logging from dataclasses import dataclass from email.message import EmailMessage from email.utils import parseaddr -from typing import cast import aiosmtplib import httpx @@ -38,65 +36,15 @@ class EmailDeliveryPayload: def _coerce_email_delivery_payload( - payload: EmailDeliveryPayload | None, - legacy_args: tuple[object, ...], + payload: EmailDeliveryPayload, ) -> EmailDeliveryPayload: - if payload is not None: - return payload - if len(legacy_args) < 4: - raise ValueError("subject, body, recipients, and smtp_from are required") - subject = str(legacy_args[0]) - body = str(legacy_args[1]) - recipients = cast(list[str], legacy_args[2]) - smtp_from = str(legacy_args[3]) - html_body = str(legacy_args[4]) if len(legacy_args) > 4 and legacy_args[4] is not None else None - return EmailDeliveryPayload( - subject=subject, - body=body, - recipients=recipients, - smtp_from=smtp_from, - html_body=html_body, - ) + return payload def _coerce_smtp_delivery_config( - smtp: transport.SmtpDeliveryConfig | object | None, - legacy_args: tuple[object, ...], - legacy_kwargs: dict[str, object], + smtp: transport.SmtpDeliveryConfig, ) -> transport.SmtpDeliveryConfig: - if isinstance(smtp, transport.SmtpDeliveryConfig): - return smtp - - values: list[object] = [] - if smtp is not None: - values.append(smtp) - values.extend(legacy_args) - - hostname_value = values[0] if values else legacy_kwargs.pop("hostname", "") - port_value = values[1] if len(values) > 1 else legacy_kwargs.pop("port", 0) - username_value = values[2] if len(values) > 2 else legacy_kwargs.pop("username", None) - password_value = values[3] if len(values) > 3 else legacy_kwargs.pop("password", None) - start_tls_value = values[4] if len(values) > 4 else legacy_kwargs.pop("start_tls", False) - use_tls_value = values[5] if len(values) > 5 else legacy_kwargs.pop("use_tls", False) - - hostname = str(hostname_value or "").strip() - if not hostname: - raise ValueError("SMTP hostname is required") - try: - port = int(cast(int | str | bytes | bytearray, port_value)) - except (TypeError, ValueError) as exc: - raise ValueError("SMTP port must be an integer") from exc - - username = str(username_value).strip() if username_value is not None else "" - - return transport.SmtpDeliveryConfig( - hostname=hostname, - port=port, - username=username or None, - password=str(password_value) if password_value is not None else None, - start_tls=bool(start_tls_value), - use_tls=bool(use_tls_value), - ) + return smtp def _is_valid_email(addr: str) -> bool: @@ -110,28 +58,24 @@ def _sanitize_recipients(recipients: list[str]) -> list[str]: return valid -def build_smtp_message( - subject: str, body: str, smtp_from: str, recipients: list[str], html_body: str | None = None -) -> EmailMessage: - recipients = _sanitize_recipients(recipients) +def build_smtp_message(payload: EmailDeliveryPayload) -> EmailMessage: + recipients = _sanitize_recipients(payload.recipients) msg = EmailMessage() - msg["Subject"] = subject - msg["From"] = smtp_from + msg["Subject"] = payload.subject + msg["From"] = payload.smtp_from msg["To"] = ", ".join(recipients) - msg.set_content(body) - if html_body: - msg.add_alternative(html_body, subtype="html") + msg.set_content(payload.body) + if payload.html_body: + msg.add_alternative(payload.html_body, subtype="html") return msg async def send_via_sendgrid( client: httpx.AsyncClient, api_key: str, - *delivery_args: object, + payload: EmailDeliveryPayload, ) -> bool: - payload = delivery_args[0] if delivery_args else None - legacy_args = delivery_args[1:] if isinstance(payload, EmailDeliveryPayload) else delivery_args - email = _coerce_email_delivery_payload(payload if isinstance(payload, EmailDeliveryPayload) else None, legacy_args) + email = _coerce_email_delivery_payload(payload) recipients = _sanitize_recipients(email.recipients) content_items: list[JSONDict] = [{"type": "text/plain", "value": email.body}] @@ -152,11 +96,13 @@ async def send_via_sendgrid( try: await transport.post_with_retry( - client, - "https://api.sendgrid.com/v3/mail/send", - json=request_payload, - headers=headers, - retry_on_status={429, 500, 502, 503, 504}, + transport.HttpPostRequest( + client=client, + url="https://api.sendgrid.com/v3/mail/send", + json=request_payload, + headers=headers, + retry_on_status={429, 500, 502, 503, 504}, + ) ) return True except httpx.HTTPStatusError as e: @@ -172,11 +118,9 @@ async def send_via_sendgrid( async def send_via_resend( client: httpx.AsyncClient, api_key: str, - *delivery_args: object, + payload: EmailDeliveryPayload, ) -> bool: - payload = delivery_args[0] if delivery_args else None - legacy_args = delivery_args[1:] if isinstance(payload, EmailDeliveryPayload) else delivery_args - email = _coerce_email_delivery_payload(payload if isinstance(payload, EmailDeliveryPayload) else None, legacy_args) + email = _coerce_email_delivery_payload(payload) recipients = _sanitize_recipients(email.recipients) request_payload: JSONDict = { @@ -195,11 +139,13 @@ async def send_via_resend( try: await transport.post_with_retry( - client, - "https://api.resend.com/emails", - json=request_payload, - headers=headers, - retry_on_status={429, 500, 502, 503, 504}, + transport.HttpPostRequest( + client=client, + url="https://api.resend.com/emails", + json=request_payload, + headers=headers, + retry_on_status={429, 500, 502, 503, 504}, + ) ) return True except httpx.HTTPStatusError as e: @@ -214,24 +160,17 @@ async def send_via_resend( async def send_via_smtp( message: EmailMessage, - *legacy_args: object, - smtp: transport.SmtpDeliveryConfig | object | None = None, - **legacy_kwargs: object, + smtp: transport.SmtpDeliveryConfig, ) -> bool: - smtp_config = _coerce_smtp_delivery_config(smtp, legacy_args, dict(legacy_kwargs)) + smtp_config = _coerce_smtp_delivery_config(smtp) if (smtp_config.username or smtp_config.password) and not (smtp_config.start_tls or smtp_config.use_tls): raise ValueError("SMTP authentication without TLS is insecure") try: await transport.send_smtp_with_retry( - message, - smtp_config.hostname, - smtp_config.port, - smtp_config.username, - smtp_config.password, - smtp_config.start_tls, - smtp_config.use_tls, + message=message, + smtp=smtp_config, ) return True except (aiosmtplib.errors.SMTPException, OSError, TimeoutError, ValueError) as exc: diff --git a/services/notification/payloads.py b/services/notification/payloads.py index 6b93b8a..fc3ea99 100644 --- a/services/notification/payloads.py +++ b/services/notification/payloads.py @@ -6,11 +6,10 @@ notifications are informative and properly formatted to facilitate quick understanding and response by recipients when alerts are triggered or resolved. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import logging diff --git a/services/notification/senders.py b/services/notification/senders.py index c027340..af82dcb 100644 --- a/services/notification/senders.py +++ b/services/notification/senders.py @@ -6,11 +6,10 @@ failures. The senders ensure that notifications are sent securely and efficiently, with proper logging of successes and failures to facilitate monitoring and troubleshooting of notification delivery. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import logging @@ -99,7 +98,9 @@ async def _send_json( return False try: - await transport.post_with_retry(client, url, json=payload, headers=headers) + await transport.post_with_retry( + transport.HttpPostRequest(client=client, url=url, json=payload, headers=headers) + ) return True except httpx.HTTPStatusError as exc: logger.warning("Webhook failed [%s]: %s", exc.response.status_code, url) diff --git a/services/notification/transport.py b/services/notification/transport.py index f245ba4..4832cd6 100644 --- a/services/notification/transport.py +++ b/services/notification/transport.py @@ -6,11 +6,10 @@ such as network errors or service unavailability, while also integrating with the overall notification system to provide reliable delivery of alerts and messages. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import asyncio @@ -43,46 +42,14 @@ class SmtpDeliveryConfig: use_tls: bool = False -def _coerce_smtp_config( - smtp: SmtpDeliveryConfig | object | None, - legacy_args: tuple[object, ...], - legacy_kwargs: Mapping[str, object], -) -> SmtpDeliveryConfig: - if isinstance(smtp, SmtpDeliveryConfig): - return smtp - - values: list[object] = [] - if smtp is not None: - values.append(smtp) - values.extend(legacy_args) - - kwargs = dict(legacy_kwargs) - hostname_value = values[0] if values else kwargs.pop("hostname", "") - port_value = values[1] if len(values) > 1 else kwargs.pop("port", 0) - username_value = values[2] if len(values) > 2 else kwargs.pop("username", None) - password_value = values[3] if len(values) > 3 else kwargs.pop("password", None) - start_tls_value = values[4] if len(values) > 4 else kwargs.pop("start_tls", False) - use_tls_value = values[5] if len(values) > 5 else kwargs.pop("use_tls", False) - - hostname = str(hostname_value or "").strip() - if not hostname: - raise ValueError("SMTP hostname is required") - - try: - port = int(cast(int | str | bytes | bytearray, port_value)) - except (TypeError, ValueError) as exc: - raise ValueError("SMTP port must be an integer") from exc - - username = str(username_value).strip() if username_value is not None else "" - - return SmtpDeliveryConfig( - hostname=hostname, - port=port, - username=username or None, - password=str(password_value) if password_value is not None else None, - start_tls=bool(start_tls_value), - use_tls=bool(use_tls_value), - ) +@dataclass(frozen=True) +class HttpPostRequest: + client: httpx.AsyncClient + url: str + json: Mapping[str, JSONValue] | None = None + headers: dict[str, str] | None = None + params: dict[str, QueryParamValue] | None = None + retry_on_status: frozenset[int] | set[int] = DEFAULT_RETRY_ON_STATUS def _is_transient_http(exc: BaseException, retry_on_status: frozenset[int]) -> bool: @@ -101,15 +68,8 @@ def _is_transient_smtp(exc: BaseException) -> bool: return False -async def post_with_retry( - client: httpx.AsyncClient, - url: str, - json: Mapping[str, JSONValue] | None = None, - headers: dict[str, str] | None = None, - params: dict[str, QueryParamValue] | None = None, - retry_on_status: frozenset[int] | set[int] = DEFAULT_RETRY_ON_STATUS, -) -> httpx.Response: - retry_set = frozenset(retry_on_status) +async def post_with_retry(request: HttpPostRequest) -> httpx.Response: + retry_set = frozenset(request.retry_on_status) @retry( retry=retry_if_exception(lambda exc: _is_transient_http(exc, retry_set)), @@ -119,11 +79,17 @@ async def post_with_retry( ) async def _attempt() -> httpx.Response: try: - resp = await client.post(url, json=json, headers=headers, params=params, timeout=config.default_timeout) + resp = await request.client.post( + request.url, + json=request.json, + headers=request.headers, + params=request.params, + timeout=config.default_timeout, + ) resp.raise_for_status() return resp except Exception as exc: - logger.warning("HTTP POST failed, retrying: %s", url, exc_info=exc) + logger.warning("HTTP POST failed, retrying: %s", request.url, exc_info=exc) raise return await _attempt() @@ -137,39 +103,20 @@ async def _attempt() -> httpx.Response: ) async def send_smtp_with_retry( message: EmailMessage, - *legacy_args: object, - smtp: SmtpDeliveryConfig | object | None = None, - **legacy_kwargs: object, + smtp: SmtpDeliveryConfig, ) -> object: - smtp_config = _coerce_smtp_config(smtp, legacy_args, legacy_kwargs) try: async with asyncio.timeout(config.default_timeout): smtp_send = cast(Any, aiosmtplib.send) - try: - return await smtp_send( - message, - hostname=smtp_config.hostname, - port=smtp_config.port, - username=smtp_config.username, - password=smtp_config.password, - start_tls=smtp_config.start_tls, - use_tls=smtp_config.use_tls, - ) - except TypeError as exc: - if "positional argument" not in str(exc): - raise - kwargs = { - "message": message, - "hostname": smtp_config.hostname, - "port": smtp_config.port, - "username": smtp_config.username, - "password": smtp_config.password, - "start_tls": smtp_config.start_tls, - "use_tls": smtp_config.use_tls, - } - return await smtp_send( - **kwargs, - ) + return await smtp_send( + message, + hostname=smtp.hostname, + port=smtp.port, + username=smtp.username, + password=smtp.password, + start_tls=smtp.start_tls, + use_tls=smtp.use_tls, + ) except Exception as exc: - logger.warning("SMTP send failed, retrying: %s:%s (%s)", smtp_config.hostname, smtp_config.port, exc) + logger.warning("SMTP send failed, retrying: %s:%s (%s)", smtp.hostname, smtp.port, exc) raise diff --git a/services/notification/validators.py b/services/notification/validators.py index 2cd3ea0..504fb6e 100644 --- a/services/notification/validators.py +++ b/services/notification/validators.py @@ -6,11 +6,10 @@ list of error messages if any issues are found with the channel configuration, allowing for proper feedback when setting up or updating notification channels. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import re @@ -45,76 +44,93 @@ def _as_int(value: object) -> int | None: return None +def _validate_smtp_email(cfg: JSONDict, errors: list[str]) -> None: + smtp_host = cfg.get("smtp_host") or cfg.get("smtpHost") + if not str(smtp_host or "").strip(): + errors.append("SMTP email channel requires 'smtp_host'") + + smtp_port = cfg.get("smtp_port") or cfg.get("smtpPort") + if smtp_port is not None: + port_num = _as_int(smtp_port) + if port_num is None: + errors.append("SMTP email channel 'smtp_port' must be a valid integer") + elif not 1 <= port_num <= 65535: + errors.append("SMTP email channel 'smtp_port' must be between 1 and 65535") + + auth_type = _as_text(cfg.get("smtp_auth_type") or cfg.get("smtpAuthType") or "password").strip().lower() + smtp_username = _as_text(cfg.get("smtp_username") or cfg.get("smtpUsername")).strip() + smtp_password = _as_text(cfg.get("smtp_password") or cfg.get("smtpPassword")).strip() + smtp_api_key = _as_text( + cfg.get("smtp_api_key") or cfg.get("smtpApiKey") or cfg.get("api_key") or cfg.get("apiKey") + ).strip() + if auth_type == "password": + if not smtp_username: + errors.append("SMTP email channel auth_type=password requires 'smtp_username'") + if not smtp_password: + errors.append("SMTP email channel auth_type=password requires 'smtp_password'") + return + if auth_type == "api_key": + if not smtp_api_key: + errors.append("SMTP email channel auth_type=api_key requires 'smtp_api_key'") + return + if auth_type != "none": + errors.append("SMTP email channel 'smtp_auth_type' must be one of: password, api_key, none") + + +def _validate_email_channel(cfg: JSONDict, errors: list[str]) -> None: + to_field = cfg.get("to") or cfg.get("recipient") + recipients = [item.strip() for item in re.split(r"[,;\s]+", str(to_field or "")) if item.strip()] + if not recipients: + errors.append("Email channel requires at least one recipient in 'to'") + + provider = _as_text(cfg.get("email_provider") or cfg.get("emailProvider") or "smtp").strip().lower() + if provider == "smtp": + _validate_smtp_email(cfg, errors) + return + if provider == "sendgrid": + api_key = cfg.get("sendgrid_api_key") or cfg.get("sendgridApiKey") or cfg.get("api_key") or cfg.get("apiKey") + if not str(api_key or "").strip(): + errors.append("SendGrid email channel requires 'sendgrid_api_key'") + return + if provider == "resend": + api_key = cfg.get("resend_api_key") or cfg.get("resendApiKey") or cfg.get("api_key") or cfg.get("apiKey") + if not str(api_key or "").strip(): + errors.append("Resend email channel requires 'resend_api_key'") + return + errors.append(f"Unsupported email provider '{provider}'") + + +def _validate_slack_channel(cfg: JSONDict, errors: list[str]) -> None: + webhook_url = _as_optional_url(cfg.get("webhook_url") or cfg.get("webhookUrl")) + if not is_safe_http_url(webhook_url): + errors.append("Slack channel requires a valid 'webhook_url'") + + +def _validate_teams_channel(cfg: JSONDict, errors: list[str]) -> None: + webhook_url = _as_optional_url(cfg.get("webhook_url") or cfg.get("webhookUrl")) + if not is_safe_http_url(webhook_url): + errors.append("Teams channel requires a valid 'webhook_url'") + + +def _validate_webhook_channel(cfg: JSONDict, errors: list[str]) -> None: + webhook_url = _as_optional_url(cfg.get("url") or cfg.get("webhook_url") or cfg.get("webhookUrl")) + if not is_safe_http_url(webhook_url): + errors.append("Webhook channel requires a valid URL") + + def validate_channel_config(channel_type: str, channel_config: JSONDict | None) -> list[str]: cfg = channel_config or {} normalized_type = str(channel_type or "").strip().lower() errors: list[str] = [] if normalized_type == "email": - to_field = cfg.get("to") or cfg.get("recipient") - recipients = [r.strip() for r in re.split(r"[,;\s]+", str(to_field or "")) if r.strip()] - if not recipients: - errors.append("Email channel requires at least one recipient in 'to'") - - provider = _as_text(cfg.get("email_provider") or cfg.get("emailProvider") or "smtp").strip().lower() - if provider == "smtp": - smtp_host = cfg.get("smtp_host") or cfg.get("smtpHost") - if not str(smtp_host or "").strip(): - errors.append("SMTP email channel requires 'smtp_host'") - - smtp_port = cfg.get("smtp_port") or cfg.get("smtpPort") - if smtp_port is not None: - port_num = _as_int(smtp_port) - if port_num is None: - errors.append("SMTP email channel 'smtp_port' must be a valid integer") - elif not 1 <= port_num <= 65535: - errors.append("SMTP email channel 'smtp_port' must be between 1 and 65535") - - auth_type = _as_text(cfg.get("smtp_auth_type") or cfg.get("smtpAuthType") or "password").strip().lower() - smtp_username = _as_text(cfg.get("smtp_username") or cfg.get("smtpUsername")).strip() - smtp_password = _as_text(cfg.get("smtp_password") or cfg.get("smtpPassword")).strip() - smtp_api_key = _as_text( - cfg.get("smtp_api_key") or cfg.get("smtpApiKey") or cfg.get("api_key") or cfg.get("apiKey") - ).strip() - - if auth_type == "password": - if not smtp_username: - errors.append("SMTP email channel auth_type=password requires 'smtp_username'") - if not smtp_password: - errors.append("SMTP email channel auth_type=password requires 'smtp_password'") - elif auth_type == "api_key": - if not smtp_api_key: - errors.append("SMTP email channel auth_type=api_key requires 'smtp_api_key'") - elif auth_type != "none": - errors.append("SMTP email channel 'smtp_auth_type' must be one of: password, api_key, none") - elif provider == "sendgrid": - api_key = ( - cfg.get("sendgrid_api_key") or cfg.get("sendgridApiKey") or cfg.get("api_key") or cfg.get("apiKey") - ) - if not str(api_key or "").strip(): - errors.append("SendGrid email channel requires 'sendgrid_api_key'") - elif provider == "resend": - api_key = cfg.get("resend_api_key") or cfg.get("resendApiKey") or cfg.get("api_key") or cfg.get("apiKey") - if not str(api_key or "").strip(): - errors.append("Resend email channel requires 'resend_api_key'") - else: - errors.append(f"Unsupported email provider '{provider}'") - + _validate_email_channel(cfg, errors) elif normalized_type == "slack": - webhook_url = _as_optional_url(cfg.get("webhook_url") or cfg.get("webhookUrl")) - if not is_safe_http_url(webhook_url): - errors.append("Slack channel requires a valid 'webhook_url'") - + _validate_slack_channel(cfg, errors) elif normalized_type == "teams": - webhook_url = _as_optional_url(cfg.get("webhook_url") or cfg.get("webhookUrl")) - if not is_safe_http_url(webhook_url): - errors.append("Teams channel requires a valid 'webhook_url'") - + _validate_teams_channel(cfg, errors) elif normalized_type == "webhook": - webhook_url = _as_optional_url(cfg.get("url") or cfg.get("webhook_url") or cfg.get("webhookUrl")) - if not is_safe_http_url(webhook_url): - errors.append("Webhook channel requires a valid URL") - + _validate_webhook_channel(cfg, errors) elif normalized_type == "pagerduty": routing_key = cfg.get("routing_key") or cfg.get("integrationKey") if not str(routing_key or "").strip(): diff --git a/services/notification_service.py b/services/notification_service.py index 663557f..99d3650 100644 --- a/services/notification_service.py +++ b/services/notification_service.py @@ -2,22 +2,20 @@ Service for managing notifications, providing functions to send notifications through various channels such as email, Slack, and Microsoft Teams. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import logging import re +from dataclasses import dataclass from datetime import UTC, datetime from email.message import EmailMessage from html import escape as html_escape from pathlib import Path from string import Template -from typing import cast - import aiosmtplib from config import config @@ -35,6 +33,15 @@ _EMAIL_TEMPLATE_ROOT = Path(__file__).resolve().parents[1] / "templates" / "emails" +@dataclass(frozen=True) +class IncidentAssignmentEmail: + recipient_email: str + incident_title: str + incident_status: str + incident_severity: str + actor: str + + def _render_html_template(template_name: str, values: dict[str, str]) -> str | None: path = _EMAIL_TEMPLATE_ROOT / template_name try: @@ -97,43 +104,11 @@ def validate_channel_config(self, channel_type: str, channel_config: JSONDict | async def _send_smtp_with_retry( self, message: EmailMessage, - *legacy_args: object, - smtp: notification_transport.SmtpDeliveryConfig | None = None, - **legacy_kwargs: object, + smtp: notification_transport.SmtpDeliveryConfig, ) -> object: - smtp_config = smtp - if smtp_config is None: - values = list(legacy_args) - hostname = str(values[0] if values else legacy_kwargs.get("hostname") or "").strip() - if not hostname: - raise ValueError("SMTP hostname is required") - try: - port_value = values[1] if len(values) > 1 else legacy_kwargs.get("port") or 0 - port = int(cast(int | str | bytes | bytearray, port_value)) - except (TypeError, ValueError) as exc: - raise ValueError("SMTP port must be an integer") from exc - smtp_config = notification_transport.SmtpDeliveryConfig( - hostname=hostname, - port=port, - username=(str(values[2] if len(values) > 2 else legacy_kwargs.get("username") or "").strip() or None), - password=( - str(values[3]) - if len(values) > 3 and values[3] is not None - else str(legacy_kwargs.get("password")) - if legacy_kwargs.get("password") is not None - else None - ), - start_tls=bool(values[4] if len(values) > 4 else legacy_kwargs.get("start_tls", False)), - use_tls=bool(values[5] if len(values) > 5 else legacy_kwargs.get("use_tls", False)), - ) return await notification_transport.send_smtp_with_retry( - message, - hostname=smtp_config.hostname, - port=smtp_config.port, - username=smtp_config.username, - password=smtp_config.password, - start_tls=smtp_config.start_tls, - use_tls=smtp_config.use_tls, + message=message, + smtp=smtp, ) async def send_notification(self, channel: NotificationChannel, alert: Alert, action: str = "firing") -> bool: @@ -154,57 +129,56 @@ async def send_notification(self, channel: NotificationChannel, alert: Alert, ac return False return await sender(channel, alert, action) - async def send_incident_assignment_email( - self, - recipient_email: str, - incident_title: str, - incident_status: str, - incident_severity: str, - actor: str, - ) -> bool: + def _incident_assignment_email_enabled(self) -> bool: enabled = str(config.get_secret("INCIDENT_ASSIGNMENT_EMAIL_ENABLED") or "false").strip().lower() in { "1", "true", "yes", "on", } - if not enabled: - return False + return enabled + + def _incident_assignment_smtp_config(self) -> notification_transport.SmtpDeliveryConfig | None: smtp_host = (config.get_secret("INCIDENT_ASSIGNMENT_SMTP_HOST") or "").strip() if not smtp_host: - logger.info("Incident assignment email skipped: INCIDENT_ASSIGNMENT_SMTP_HOST not set") - return False + return None try: smtp_port = int(config.get_secret("INCIDENT_ASSIGNMENT_SMTP_PORT") or "587") except ValueError: smtp_port = 587 - smtp_user = config.get_secret("INCIDENT_ASSIGNMENT_SMTP_USERNAME") - smtp_pass = config.get_secret("INCIDENT_ASSIGNMENT_SMTP_PASSWORD") + return notification_transport.SmtpDeliveryConfig( + hostname=smtp_host, + port=smtp_port, + username=config.get_secret("INCIDENT_ASSIGNMENT_SMTP_USERNAME"), + password=config.get_secret("INCIDENT_ASSIGNMENT_SMTP_PASSWORD"), + start_tls=self._as_bool(config.get_secret("INCIDENT_ASSIGNMENT_SMTP_STARTTLS") or "true"), + use_tls=self._as_bool(config.get_secret("INCIDENT_ASSIGNMENT_SMTP_USE_SSL") or "false"), + ) + + def _build_incident_assignment_message(self, payload: IncidentAssignmentEmail) -> EmailMessage: smtp_from = config.get_secret("INCIDENT_ASSIGNMENT_FROM") or config.default_admin_email - use_starttls = self._as_bool(config.get_secret("INCIDENT_ASSIGNMENT_SMTP_STARTTLS") or "true") - use_ssl = self._as_bool(config.get_secret("INCIDENT_ASSIGNMENT_SMTP_USE_SSL") or "false") msg = EmailMessage() - msg["Subject"] = f"[Incident Assigned] {incident_title}" + msg["Subject"] = f"[Incident Assigned] {payload.incident_title}" msg["From"] = smtp_from - msg["To"] = recipient_email + msg["To"] = payload.recipient_email timestamp = datetime.now(UTC).isoformat() msg.set_content( f"You have been assigned an incident in Watchdog.\n\n" - f"Title: {incident_title}\n" - f"Status: {incident_status}\n" - f"Severity: {incident_severity}\n" - f"Updated by: {actor}\n" + f"Title: {payload.incident_title}\n" + f"Status: {payload.incident_status}\n" + f"Severity: {payload.incident_severity}\n" + f"Updated by: {payload.actor}\n" f"Timestamp: {timestamp}\n" ) - theme = _incident_severity_theme(incident_severity) + theme = _incident_severity_theme(payload.incident_severity) html_body = _render_html_template( "incident_assignment.html", { - "incident_title": incident_title, - "incident_status": incident_status, - "incident_severity": incident_severity, - "incident_severity_upper": str(incident_severity or "info").upper(), - "actor": actor, + "incident_title": payload.incident_title, + "incident_status": payload.incident_status, + "incident_severity": payload.incident_severity, + "incident_severity_upper": str(payload.incident_severity or "info").upper(), + "actor": payload.actor, "timestamp": timestamp, "header_bg": theme["header_bg"], "header_fg": theme["header_fg"], @@ -214,39 +188,60 @@ async def send_incident_assignment_email( ) if html_body: msg.add_alternative(html_body, subtype="html") + return msg + + async def send_incident_assignment_email(self, payload: IncidentAssignmentEmail) -> bool: + enabled = self._incident_assignment_email_enabled() + if not enabled: + return False + smtp_config = self._incident_assignment_smtp_config() + if smtp_config is None: + logger.info("Incident assignment email skipped: INCIDENT_ASSIGNMENT_SMTP_HOST not set") + return False + msg = self._build_incident_assignment_message(payload) try: - await self._send_smtp_with_retry( - message=msg, - hostname=smtp_host, - port=smtp_port, - username=smtp_user, - password=smtp_pass, - start_tls=use_starttls, - use_tls=use_ssl, - ) - logger.info("Incident assignment email sent to %s", recipient_email) + await self._send_smtp_with_retry(message=msg, smtp=smtp_config) + logger.info("Incident assignment email sent to %s", payload.recipient_email) return True except (ValueError, TimeoutError, OSError, aiosmtplib.errors.SMTPException) as exc: - logger.warning("Failed to send incident assignment email to %s: %s", recipient_email, exc) + logger.warning("Failed to send incident assignment email to %s: %s", payload.recipient_email, exc) return False - async def _send_email(self, channel: NotificationChannel, alert: Alert, action: str) -> bool: + @staticmethod + def _email_delivery( + channel: NotificationChannel, + alert: Alert, + action: str, + ) -> tuple[notification_email.EmailDeliveryPayload, str]: cfg = channel.config or {} to_field = cfg.get("to") or cfg.get("recipient") - if not to_field: - logger.error("Email channel '%s' has no 'to' address configured", channel.name) - return False - recipients = [r.strip() for r in re.split(r"[,;\s]+", str(to_field)) if r.strip()] - if not recipients: - logger.error("No valid recipient addresses for channel %s", channel.name) - return False + recipients = [item.strip() for item in re.split(r"[,;\s]+", str(to_field or "")) if item.strip()] subject = f"[{action.upper()}] {alert.labels.get('alertname', 'Alert')}" body = notification_payloads.format_alert_body(alert, action) html_body = notification_payloads.format_alert_html(alert, action) - provider_value = cfg.get("email_provider") or cfg.get("emailProvider") or "smtp" - provider = str(provider_value).strip().lower() - smtp_from = str(cfg.get("smtp_from") or cfg.get("smtpFrom") or cfg.get("from") or config.default_admin_email) + smtp_from = str( + cfg.get("smtp_from") or cfg.get("smtpFrom") or cfg.get("from") or config.default_admin_email + ) + return ( + notification_email.EmailDeliveryPayload( + subject=subject, + body=body, + recipients=recipients, + smtp_from=smtp_from, + html_body=html_body, + ), + str(cfg.get("email_provider") or cfg.get("emailProvider") or "smtp").strip().lower(), + ) + async def _send_via_http_email_provider( + self, + provider: str, + channel: NotificationChannel, + email_payload: notification_email.EmailDeliveryPayload, + ) -> bool | None: + cfg = channel.config or {} + api_key = "" + sender = None if provider == "sendgrid": api_key = str( cfg.get("sendgrid_api_key") @@ -255,55 +250,36 @@ async def _send_email(self, channel: NotificationChannel, alert: Alert, action: or cfg.get("apiKey") or "" ) - if not api_key: - logger.error("SendGrid API key not configured for email channel %s", channel.name) - return False - sent = await notification_email.send_via_sendgrid( - self._client, - api_key, - notification_email.EmailDeliveryPayload( - subject=subject, - body=body, - recipients=recipients, - smtp_from=smtp_from, - html_body=html_body, - ), - ) - if sent: - logger.info("Email notification sent via SendGrid (channel=%s)", channel.name) - else: - logger.error("Failed SendGrid email for channel %s", channel.name) - return sent - + sender = notification_email.send_via_sendgrid if provider == "resend": api_key = str( - cfg.get("resend_api_key") or cfg.get("resendApiKey") or cfg.get("api_key") or cfg.get("apiKey") or "" - ) - if not api_key: - logger.error("Resend API key not configured for email channel %s", channel.name) - return False - sent = await notification_email.send_via_resend( - self._client, - api_key, - notification_email.EmailDeliveryPayload( - subject=subject, - body=body, - recipients=recipients, - smtp_from=smtp_from, - html_body=html_body, - ), + cfg.get("resend_api_key") + or cfg.get("resendApiKey") + or cfg.get("api_key") + or cfg.get("apiKey") + or "" ) - if sent: - logger.info("Email notification sent via Resend (channel=%s)", channel.name) - else: - logger.error("Failed Resend email for channel %s", channel.name) - return sent - - if provider != "smtp": - logger.error("Unsupported email provider '%s' for channel %s", provider, channel.name) + sender = notification_email.send_via_resend + if sender is None: + return None + if not api_key: + logger.error("%s API key not configured for email channel %s", provider.title(), channel.name) return False + sent = await sender(self._client, api_key, email_payload) + if sent: + logger.info("Email notification sent via %s (channel=%s)", provider.title(), channel.name) + else: + logger.error("Failed %s email for channel %s", provider.title(), channel.name) + return sent + def _smtp_delivery_config( + self, + channel: NotificationChannel, + ) -> notification_transport.SmtpDeliveryConfig | None: + cfg = channel.config or {} smtp_host = str(cfg.get("smtp_host") or cfg.get("smtpHost") or "") + if not smtp_host: + return None smtp_port = int(str(cfg.get("smtp_port") or cfg.get("smtpPort") or 0)) smtp_user = str(cfg.get("smtp_username") or cfg.get("smtpUsername") or cfg.get("username") or "") or None smtp_pass = str(cfg.get("smtp_password") or cfg.get("smtpPassword") or cfg.get("password") or "") or None @@ -316,13 +292,8 @@ async def _send_email(self, channel: NotificationChannel, alert: Alert, action: cfg.get("smtp_starttls") or cfg.get("smtpStartTLS") or cfg.get("starttls") or False ) use_ssl = self._as_bool(cfg.get("smtp_use_ssl") or cfg.get("smtpUseSSL") or False) - - if not smtp_host: - logger.error("SMTP host not configured for email channel %s", channel.name) - return False if smtp_port == 0: smtp_port = 465 if use_ssl else 587 if use_starttls else 25 - if smtp_auth_type == "none": smtp_user = None smtp_pass = None @@ -330,21 +301,44 @@ async def _send_email(self, channel: NotificationChannel, alert: Alert, action: smtp_user = smtp_user or "apikey" smtp_pass = smtp_api_key if not smtp_pass: - logger.error("SMTP API key not configured for email channel %s", channel.name) - return False + return None elif smtp_user and not smtp_pass and smtp_api_key: smtp_pass = smtp_api_key + return notification_transport.SmtpDeliveryConfig( + hostname=smtp_host, + port=smtp_port, + username=smtp_user, + password=smtp_pass, + start_tls=use_starttls, + use_tls=use_ssl, + ) - msg = notification_email.build_smtp_message(subject, body, smtp_from, recipients, html_body) - logger.info("Sending email to %s via %s:%s (channel=%s)", recipients, smtp_host, smtp_port, channel.name) + async def _send_email(self, channel: NotificationChannel, alert: Alert, action: str) -> bool: + email_payload, provider = self._email_delivery(channel, alert, action) + if not email_payload.recipients: + logger.error("Email channel '%s' has no 'to' address configured", channel.name) + return False + provider_result = await self._send_via_http_email_provider(provider, channel, email_payload) + if provider_result is not None: + return provider_result + if provider != "smtp": + logger.error("Unsupported email provider '%s' for channel %s", provider, channel.name) + return False + smtp_config = self._smtp_delivery_config(channel) + if smtp_config is None: + logger.error("SMTP configuration is invalid for email channel %s", channel.name) + return False + msg = notification_email.build_smtp_message(email_payload) + logger.info( + "Sending email to %s via %s:%s (channel=%s)", + email_payload.recipients, + smtp_config.hostname, + smtp_config.port, + channel.name, + ) sent = await notification_email.send_via_smtp( msg, - smtp_host, - smtp_port, - smtp_user, - smtp_pass, - use_starttls, - use_ssl, + smtp=smtp_config, ) if sent: logger.info("Email notification sent (channel=%s)", channel.name) diff --git a/services/secrets/provider.py b/services/secrets/provider.py index f6b4384..fec38cd 100644 --- a/services/secrets/provider.py +++ b/services/secrets/provider.py @@ -7,11 +7,10 @@ handling of sensitive information such as API keys, database credentials, and other configuration secrets within the application. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/services/secrets/vault_client.py b/services/secrets/vault_client.py index 6d99378..407c6be 100644 --- a/services/secrets/vault_client.py +++ b/services/secrets/vault_client.py @@ -6,11 +6,10 @@ class that can be used to retrieve secrets from Vault based on a specified key, with a configurable time-to-live (TTL) to ensure efficient access to secrets while minimizing the number of requests made to Vault. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/services/storage/__init__.py b/services/storage/__init__.py index e4574cf..9ebb337 100644 --- a/services/storage/__init__.py +++ b/services/storage/__init__.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from services.storage.channels import ChannelStorageService diff --git a/services/storage/channels.py b/services/storage/channels.py index d5211a8..625d1d6 100644 --- a/services/storage/channels.py +++ b/services/storage/channels.py @@ -2,17 +2,17 @@ Storage service for managing notification channels, including CRUD operations, access control, and testing functionality. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations import logging import uuid +from dataclasses import dataclass, field from sqlalchemy.orm import joinedload @@ -22,7 +22,7 @@ from db_models import AlertRule as AlertRuleDB from db_models import NotificationChannel as NotificationChannelDB from models.alerting.channels import NotificationChannel, NotificationChannelCreate -from services.common.access import assign_shared_groups, has_access +from services.common.access import AccessCheck, SharedGroupAssignment, assign_shared_groups, has_access from services.common.encryption import decrypt_config, encrypt_config from services.common.pagination import cap_pagination from services.common.tenants import ensure_tenant_exists @@ -32,6 +32,19 @@ logger = logging.getLogger(__name__) +@dataclass(frozen=True, slots=True) +class _ChannelView: + id: str | None + name: str + type: object + enabled: bool + config: JSONDict + created_by: str | None + visibility: object + shared_groups: list[object] + is_hidden: bool + + def _shared_group_ids(db_obj: NotificationChannelDB) -> list[str]: return [g.id for g in db_obj.shared_groups] if db_obj.shared_groups else [] @@ -49,7 +62,51 @@ def _config_dict(channel: NotificationChannelDB) -> JSONDict: return raw_config if isinstance(raw_config, dict) else {} +def _channel_view(channel: NotificationChannelDB, raw_config: JSONDict) -> _ChannelView: + return _ChannelView( + id=channel.id, + name=channel.name, + type=channel.type, + enabled=channel.enabled, + config=raw_config, + created_by=channel.created_by, + visibility=channel.visibility, + shared_groups=list(getattr(channel, "shared_groups", None) or []), + is_hidden=bool(getattr(channel, "is_hidden", False)), + ) + + +@dataclass(frozen=True) +class ChannelAccessContext: + user_id: str + group_ids: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class PageRequest: + limit: int | None = None + offset: int = 0 + + class ChannelStorageService: + def __init__(self, *_args: object, **_kwargs: object) -> None: + return + + @staticmethod + def _access_context( + access: ChannelAccessContext | str, + group_ids: list[str] | None = None, + ) -> ChannelAccessContext: + if isinstance(access, ChannelAccessContext): + return access + return ChannelAccessContext(user_id=str(access), group_ids=list(group_ids or [])) + + @staticmethod + def _page_request(value: PageRequest | None) -> PageRequest: + if isinstance(value, PageRequest): + return value + return PageRequest() + @staticmethod def _rule_channel_compatible(rule: AlertRuleDB, channel: NotificationChannelDB) -> bool: rule_visibility = normalize_storage_visibility(getattr(rule, "visibility", None)) @@ -76,16 +133,17 @@ def _rule_channel_compatible(rule: AlertRuleDB, channel: NotificationChannelDB) # Tenant/public rules can trigger private, group, and public channels. return channel_visibility in {"private", "group", "public"} + @staticmethod def get_notification_channels( - self, tenant_id: str, - user_id: str, + access: ChannelAccessContext | str, + page: PageRequest | None = None, group_ids: list[str] | None = None, - limit: int | None = None, - offset: int = 0, ) -> list[NotificationChannel]: - group_ids = group_ids or [] - capped_limit, capped_offset = cap_pagination(limit, offset) + context = ChannelStorageService._access_context(access, group_ids=group_ids) + group_ids = list(context.group_ids or []) + paging = ChannelStorageService._page_request(page) + capped_limit, capped_offset = cap_pagination(paging.limit, paging.offset) with get_db_session() as db: channels = ( @@ -100,27 +158,29 @@ def get_notification_channels( results: list[NotificationChannel] = [] for ch in channels: if not has_access( - _visibility_of(ch), - _creator_of(ch), - user_id, - _shared_group_ids(ch), - group_ids, + AccessCheck( + visibility=_visibility_of(ch), + created_by=_creator_of(ch), + user_id=context.user_id, + shared_group_ids=_shared_group_ids(ch), + user_group_ids=group_ids, + ) ): continue raw_cfg = decrypt_config(_config_dict(ch)) - ch.config = raw_cfg - results.append(channel_to_pydantic_for_viewer(ch, user_id)) + results.append(channel_to_pydantic_for_viewer(_channel_view(ch, raw_cfg), context.user_id)) return results - def get_notification_channel( - self, + @staticmethod + def get_notification_channel( # pylint: disable=too-many-positional-arguments channel_id: str, tenant_id: str, - user_id: str, + access: ChannelAccessContext | str, group_ids: list[str] | None = None, include_sensitive: bool = False, ) -> NotificationChannel | None: - group_ids = group_ids or [] + context = ChannelStorageService._access_context(access, group_ids=group_ids) + group_ids = list(context.group_ids or []) with get_db_session() as db: ch = ( db.query(NotificationChannelDB) @@ -131,30 +191,36 @@ def get_notification_channel( if not ch: return None if not has_access( - _visibility_of(ch), - _creator_of(ch), - user_id, - _shared_group_ids(ch), - group_ids, + AccessCheck( + visibility=_visibility_of(ch), + created_by=_creator_of(ch), + user_id=context.user_id, + shared_group_ids=_shared_group_ids(ch), + user_group_ids=group_ids, + ) ): return None raw_cfg = decrypt_config(_config_dict(ch)) - ch.config = raw_cfg - return channel_to_pydantic_for_viewer(ch, user_id, include_sensitive=include_sensitive) + return channel_to_pydantic_for_viewer( + _channel_view(ch, raw_cfg), + context.user_id, + include_sensitive=include_sensitive, + ) + @staticmethod def create_notification_channel( - self, channel_create: NotificationChannelCreate, tenant_id: str, - user_id: str, + access: ChannelAccessContext | str, group_ids: list[str] | None = None, ) -> NotificationChannel: + context = ChannelStorageService._access_context(access, group_ids=group_ids) with get_db_session() as db: ensure_tenant_exists(db, tenant_id) ch = NotificationChannelDB( id=str(uuid.uuid4()), tenant_id=tenant_id, - created_by=user_id, + created_by=context.user_id, name=channel_create.name, type=channel_create.type, config=encrypt_config(channel_create.config or {}), @@ -164,28 +230,31 @@ def create_notification_channel( assign_shared_groups( ch, db, - tenant_id, - _visibility_of(ch), - channel_create.shared_group_ids, - actor_group_ids=group_ids, + SharedGroupAssignment( + tenant_id=tenant_id, + visibility=_visibility_of(ch), + group_ids=channel_create.shared_group_ids, + actor_group_ids=context.group_ids, + ), ) db.add(ch) db.flush() logger.info("Created channel %s (%s) visibility=%s", ch.name, ch.id, ch.visibility) cfg = decrypt_config(_config_dict(ch)) - ch.config = cfg - return channel_to_pydantic_for_viewer(ch, user_id) + return channel_to_pydantic_for_viewer(_channel_view(ch, cfg), context.user_id) + @staticmethod def update_notification_channel( - self, channel_id: str, channel_update: NotificationChannelCreate, tenant_id: str, - user_id: str, + access: ChannelAccessContext | str, + *, group_ids: list[str] | None = None, ) -> NotificationChannel | None: - group_ids = group_ids or [] + context = ChannelStorageService._access_context(access, group_ids=group_ids) + group_ids = list(context.group_ids or []) with get_db_session() as db: ch = ( db.query(NotificationChannelDB) @@ -193,7 +262,7 @@ def update_notification_channel( .filter(NotificationChannelDB.id == channel_id, NotificationChannelDB.tenant_id == tenant_id) .first() ) - if not ch or ch.created_by != user_id: + if not ch or ch.created_by != context.user_id: return None ch.name = channel_update.name @@ -204,20 +273,28 @@ def update_notification_channel( assign_shared_groups( ch, db, - tenant_id, - _visibility_of(ch), - channel_update.shared_group_ids, - actor_group_ids=group_ids, + SharedGroupAssignment( + tenant_id=tenant_id, + visibility=_visibility_of(ch), + group_ids=channel_update.shared_group_ids, + actor_group_ids=group_ids, + ), ) db.flush() logger.info("Updated channel %s (%s)", ch.name, channel_id) cfg = decrypt_config(_config_dict(ch)) - ch.config = cfg - return channel_to_pydantic_for_viewer(ch, user_id) + return channel_to_pydantic_for_viewer(_channel_view(ch, cfg), context.user_id) - def delete_notification_channel(self, channel_id: str, tenant_id: str, user_id: str) -> bool: + @staticmethod + def delete_notification_channel( + channel_id: str, + tenant_id: str, + access: ChannelAccessContext | str, + group_ids: list[str] | None = None, + ) -> bool: + context = ChannelStorageService._access_context(access, group_ids=group_ids) with get_db_session() as db: ch = ( db.query(NotificationChannelDB) @@ -225,13 +302,15 @@ def delete_notification_channel(self, channel_id: str, tenant_id: str, user_id: .filter(NotificationChannelDB.id == channel_id, NotificationChannelDB.tenant_id == tenant_id) .first() ) - if not ch or ch.created_by != user_id: + if not ch or ch.created_by != context.user_id: return False db.delete(ch) logger.info("Deleted channel %s", channel_id) return True - def is_notification_channel_owner(self, channel_id: str, tenant_id: str, user_id: str) -> bool: + @staticmethod + def is_notification_channel_owner(channel_id: str, tenant_id: str, access: ChannelAccessContext | str) -> bool: + context = ChannelStorageService._access_context(access) with get_db_session() as db: ch = ( db.query(NotificationChannelDB) @@ -241,16 +320,16 @@ def is_notification_channel_owner(self, channel_id: str, tenant_id: str, user_id ) .first() ) - return bool(ch and ch.created_by == user_id) + return bool(ch and ch.created_by == context.user_id) + @staticmethod def test_notification_channel( - self, channel_id: str, tenant_id: str, - user_id: str, - group_ids: list[str] | None = None, + access: ChannelAccessContext | str, ) -> dict[str, object]: - channel = self.get_notification_channel(channel_id, tenant_id, user_id, group_ids) + context = ChannelStorageService._access_context(access) + channel = ChannelStorageService.get_notification_channel(channel_id, tenant_id, context) if not channel: return {"success": False, "error": "Channel not found"} logger.info("Testing channel: %s (%s)", channel.name, channel.type) @@ -259,8 +338,8 @@ def test_notification_channel( "message": f"Test notification would be sent to {channel.type} channel: {channel.name}", } + @staticmethod def get_notification_channels_for_rule_name( - self, tenant_id: str, rule_name: str, org_id: str | None = None, @@ -317,12 +396,11 @@ def get_notification_channels_for_rule_name( for ch in candidate_channels: if ch.id in seen_ids: continue - if not self._rule_channel_compatible(r, ch): + if not ChannelStorageService._rule_channel_compatible(r, ch): compatible_skipped += 1 continue raw_cfg = decrypt_config(_config_dict(ch)) - ch.config = raw_cfg - results.append(channel_to_pydantic(ch)) + results.append(channel_to_pydantic(_channel_view(ch, raw_cfg))) seen_ids.add(str(ch.id)) if compatible_skipped: debug_notes.append(f"rule={r.id}:incompatible_skipped={compatible_skipped}") diff --git a/services/storage/hidden_entity_storage.py b/services/storage/hidden_entity_storage.py index c907dfa..d4f0f7e 100644 --- a/services/storage/hidden_entity_storage.py +++ b/services/storage/hidden_entity_storage.py @@ -1,4 +1,11 @@ -"""Persistence for per-user hidden silences, channels, and Jira integration preferences.""" +""" +Persistence for per-user hidden silences, channels, and Jira integration preferences. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +""" from __future__ import annotations @@ -10,7 +17,8 @@ class HiddenEntityStorageService: - def get_hidden_silence_ids(self, tenant_id: str, user_id: str) -> list[str]: + @staticmethod + def get_hidden_silence_ids(tenant_id: str, user_id: str) -> list[str]: with get_db_session() as db: rows = ( db.query(HiddenSilence.silence_id) @@ -22,7 +30,8 @@ def get_hidden_silence_ids(self, tenant_id: str, user_id: str) -> list[str]: ) return [str(silence_id) for (silence_id,) in rows] - def toggle_silence_hidden(self, tenant_id: str, user_id: str, silence_id: str, hidden: bool) -> bool: + @staticmethod + def toggle_silence_hidden(tenant_id: str, user_id: str, silence_id: str, hidden: bool) -> bool: with get_db_session() as db: existing = ( db.query(HiddenSilence) @@ -48,7 +57,8 @@ def toggle_silence_hidden(self, tenant_id: str, user_id: str, silence_id: str, h db.delete(existing) return True - def get_hidden_channel_ids(self, tenant_id: str, user_id: str) -> list[str]: + @staticmethod + def get_hidden_channel_ids(tenant_id: str, user_id: str) -> list[str]: with get_db_session() as db: rows = ( db.query(HiddenNotificationChannel.channel_id) @@ -60,7 +70,8 @@ def get_hidden_channel_ids(self, tenant_id: str, user_id: str) -> list[str]: ) return [str(channel_id) for (channel_id,) in rows] - def toggle_channel_hidden(self, tenant_id: str, user_id: str, channel_id: str, hidden: bool) -> bool: + @staticmethod + def toggle_channel_hidden(tenant_id: str, user_id: str, channel_id: str, hidden: bool) -> bool: with get_db_session() as db: existing = ( db.query(HiddenNotificationChannel) @@ -85,8 +96,8 @@ def toggle_channel_hidden(self, tenant_id: str, user_id: str, channel_id: str, h db.delete(existing) return True + @staticmethod def prune_removed_member_group_shares( - self, tenant_id: str, group_id: str, removed_user_ids: list[str] | None = None, @@ -101,7 +112,8 @@ def prune_removed_member_group_shares( removed_usernames=removed_usernames or [], ) - def get_hidden_jira_integration_ids(self, tenant_id: str, user_id: str) -> list[str]: + @staticmethod + def get_hidden_jira_integration_ids(tenant_id: str, user_id: str) -> list[str]: with get_db_session() as db: rows = ( db.query(HiddenJiraIntegration.integration_id) @@ -113,8 +125,8 @@ def get_hidden_jira_integration_ids(self, tenant_id: str, user_id: str) -> list[ ) return [str(integration_id) for (integration_id,) in rows] + @staticmethod def toggle_jira_integration_hidden( - self, tenant_id: str, user_id: str, integration_id: str, diff --git a/services/storage/incidents.py b/services/storage/incidents.py index cecdcd5..50fdc02 100644 --- a/services/storage/incidents.py +++ b/services/storage/incidents.py @@ -2,11 +2,10 @@ Incidents management service for handling alert incidents, including synchronization with incoming alerts, access control, and integration with Jira for issue tracking. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -26,7 +25,7 @@ from db_models import AlertIncident as AlertIncidentDB from db_models import AlertRule as AlertRuleDB from models.alerting.incidents import AlertIncident, AlertIncidentUpdateRequest -from services.common.access import has_access +from services.common.access import AccessCheck, has_access from services.common.meta import INCIDENT_META_KEY, _safe_group_ids, parse_meta from services.common.pagination import cap_pagination from services.common.tenants import ensure_tenant_exists @@ -38,6 +37,7 @@ _shared_group_ids, ) from services.storage.incidents_sync import ( + AlertSyncContext, _resolve_incidents_without_active_alerts, _sync_single_alert_into_incidents, ) @@ -68,51 +68,34 @@ class IncidentActorContext: user_email: str | None = None -def _coerce_incident_list_filters( - filters_or_group_ids: IncidentListFilters | list[str] | None, - legacy_kwargs: dict[str, object], -) -> IncidentListFilters: - if isinstance(filters_or_group_ids, IncidentListFilters): - filters = filters_or_group_ids - else: - filters = IncidentListFilters(group_ids=filters_or_group_ids or []) - - group_ids_raw = legacy_kwargs.pop("group_ids", filters.group_ids) - group_ids = [str(group_id).strip() for group_id in cast(list[object], group_ids_raw or []) if str(group_id).strip()] - - status = legacy_kwargs.pop("status", filters.status) - visibility = legacy_kwargs.pop("visibility", filters.visibility) - group_id = legacy_kwargs.pop("group_id", filters.group_id) - limit_raw = legacy_kwargs.pop("limit", filters.limit) - offset_raw = legacy_kwargs.pop("offset", filters.offset) - - limit: int | None - if limit_raw is None: - limit = None - else: - try: - limit = int(cast(int | str | bytes | bytearray, limit_raw)) - except (TypeError, ValueError): - limit = filters.limit - - try: - offset = int(cast(int | str | bytes | bytearray, offset_raw)) - except (TypeError, ValueError): - offset = filters.offset +@dataclass(frozen=True) +class IncidentAccessContext: + user_id: str + group_ids: list[str] = field(default_factory=list) + require_write: bool = False + +def _coerce_incident_list_filters( # pylint: disable=too-many-arguments,too-many-positional-arguments + group_ids: list[str] | None, + status: str | None, + visibility: str | None, + group_id: str | None, + limit: int | None, + offset: int, +) -> IncidentListFilters: return IncidentListFilters( - group_ids=group_ids, - status=cast(str | None, str(status) if isinstance(status, str) else status), - visibility=cast(str | None, str(visibility) if isinstance(visibility, str) else visibility), - group_id=cast(str | None, str(group_id) if isinstance(group_id, str) else group_id), + group_ids=[str(value).strip() for value in cast(list[object], group_ids or []) if str(value).strip()], + status=str(status) if isinstance(status, str) else status, + visibility=str(visibility) if isinstance(visibility, str) else visibility, + group_id=str(group_id) if isinstance(group_id, str) else group_id, limit=limit, offset=offset, ) class IncidentStorageService: + @staticmethod def unlink_jira_integration_from_incidents( - self, tenant_id: str, integration_id: str, ) -> int: @@ -141,8 +124,8 @@ def unlink_jira_integration_from_incidents( return updated_count + @staticmethod def get_incident_summary( - self, tenant_id: str, user_id: str, group_ids: list[str] | None = None, @@ -166,11 +149,13 @@ def get_incident_summary( creator_id = str(meta.get("created_by") or "") or None if not _incident_access_allowed( - visibility=inc_visibility, - creator_id=creator_id, - user_id=user_id, - shared_group_ids=_safe_group_ids(meta), - user_group_ids=group_ids, + AccessCheck( + visibility=inc_visibility, + created_by=creator_id, + user_id=user_id, + shared_group_ids=_safe_group_ids(meta), + user_group_ids=group_ids, + ) ): continue @@ -201,25 +186,41 @@ def get_incident_summary( "by_visibility": by_visibility, } - def sync_incidents_from_alerts(self, tenant_id: str, alerts: list[JSONDict], resolve_missing: bool = True) -> None: + @staticmethod + def sync_incidents_from_alerts(tenant_id: str, alerts: list[JSONDict], resolve_missing: bool = True) -> None: now = datetime.now(UTC) active_incident_tokens: set[str] = set() with get_db_session() as db: ensure_tenant_exists(db, tenant_id) for alert in alerts or []: - _sync_single_alert_into_incidents(db, tenant_id, now, alert, active_incident_tokens) + _sync_single_alert_into_incidents( + db, + AlertSyncContext(tenant_id=tenant_id, now=now, alert=alert), + active_incident_tokens, + ) if resolve_missing: _resolve_incidents_without_active_alerts(db, tenant_id, now, active_incident_tokens) - def list_incidents( - self, + @staticmethod + def list_incidents( # pylint: disable=too-many-arguments,too-many-positional-arguments tenant_id: str, user_id: str, - filters_or_group_ids: IncidentListFilters | list[str] | None = None, - **legacy_kwargs: object, + group_ids: list[str] | None = None, + status: str | None = None, + visibility: str | None = None, + group_id: str | None = None, + limit: int | None = None, + offset: int = 0, ) -> list[AlertIncident]: - filters = _coerce_incident_list_filters(filters_or_group_ids, dict(legacy_kwargs)) + filters = _coerce_incident_list_filters( + group_ids=group_ids, + status=status, + visibility=visibility, + group_id=group_id, + limit=limit, + offset=offset, + ) group_ids = filters.group_ids capped_limit, capped_offset = cap_pagination(filters.limit, filters.offset) @@ -254,11 +255,13 @@ def list_incidents( continue if not _incident_access_allowed( - visibility=inc_visibility, - creator_id=str(creator_id or "") or None, - user_id=user_id, - shared_group_ids=shared_group_ids, - user_group_ids=group_ids, + AccessCheck( + visibility=inc_visibility, + created_by=str(creator_id or "") or None, + user_id=user_id, + shared_group_ids=shared_group_ids, + user_group_ids=group_ids, + ) ): continue @@ -266,15 +269,15 @@ def list_incidents( return result + @staticmethod def get_incident_for_user( - self, incident_id: str, tenant_id: str, - user_id: str | None = None, - group_ids: list[str] | None = None, - require_write: bool = False, + context: IncidentAccessContext | None = None, ) -> AlertIncident | None: - group_ids = group_ids or [] + user_id = context.user_id if context is not None else None + group_ids = context.group_ids if context is not None else [] + require_write = context.require_write if context is not None else False with get_db_session() as db: incident = ( db.query(AlertIncidentDB) @@ -290,23 +293,24 @@ def get_incident_for_user( inc_visibility = "public" creator_id = str(meta.get("created_by") or "") or None if not _incident_access_allowed( - visibility=inc_visibility, - creator_id=creator_id, - user_id=user_id, - shared_group_ids=_safe_group_ids(meta), - user_group_ids=group_ids, - require_write=require_write, + AccessCheck( + visibility=inc_visibility, + created_by=creator_id, + user_id=user_id, + shared_group_ids=_safe_group_ids(meta), + user_group_ids=group_ids, + require_write=require_write, + ) ): return None return incident_to_pydantic(incident) + @staticmethod def _apply_incident_assignee( - self, payload: AlertIncidentUpdateRequest, incident: AlertIncidentDB, visibility: str, - user_id: str, - user_email: str | None, + actor: IncidentActorContext, ) -> None: fields_set = set(getattr(payload, "model_fields_set", set()) or []) if "assignee" not in fields_set: @@ -314,8 +318,8 @@ def _apply_incident_assignee( requested_assignee = str(payload.assignee or "").strip() or None normalized_assignee = str(requested_assignee or "").strip().lower() - normalized_user_id = str(user_id or "").strip().lower() - normalized_user_email = str(user_email or "").strip().lower() + normalized_user_id = str(actor.user_id or "").strip().lower() + normalized_user_email = str(actor.user_email or "").strip().lower() allowed_self_values = {v for v in (normalized_user_id, normalized_user_email) if v} if requested_assignee and visibility == "private" and normalized_assignee not in allowed_self_values: raise HTTPException( @@ -324,8 +328,8 @@ def _apply_incident_assignee( ) incident.assignee = requested_assignee + @staticmethod def _apply_incident_status( - self, payload: AlertIncidentUpdateRequest, incident: AlertIncidentDB, previous_status: str, @@ -355,7 +359,8 @@ def _apply_incident_status( return manual_manage_flag, resolved_note_text - def _apply_jira_meta_fields(self, payload: AlertIncidentUpdateRequest, meta: JSONDict) -> None: + @staticmethod + def _apply_jira_meta_fields(payload: AlertIncidentUpdateRequest, meta: JSONDict) -> None: for meta_key, payload_attr in [ ("jira_ticket_key", "jira_ticket_key"), ("jira_ticket_url", "jira_ticket_url"), @@ -370,8 +375,8 @@ def _apply_jira_meta_fields(self, payload: AlertIncidentUpdateRequest, meta: JSO else: meta.pop(meta_key, None) + @staticmethod def _apply_incident_metadata( - self, payload: AlertIncidentUpdateRequest, incident: AlertIncidentDB, user_id: str, @@ -393,13 +398,13 @@ def _apply_incident_metadata( elif hide_flag is False: meta.pop("hide_when_resolved", None) - self._apply_jira_meta_fields(payload, meta) + IncidentStorageService._apply_jira_meta_fields(payload, meta) meta["updated_by"] = user_id incident.annotations = {**annotations, INCIDENT_META_KEY: json.dumps(meta)} + @staticmethod def _apply_incident_notes( - self, payload: AlertIncidentUpdateRequest, incident: AlertIncidentDB, user_id: str, @@ -414,28 +419,15 @@ def _apply_incident_notes( if notes != list(incident.notes or []): incident.notes = notes - def update_incident( + def update_incident( # pylint: disable=too-many-arguments,too-many-positional-arguments self, incident_id: str, tenant_id: str, - payload_or_user_id: AlertIncidentUpdateRequest | str, - *legacy_args: object, - actor: IncidentActorContext | None = None, + user_id: str, + payload: AlertIncidentUpdateRequest, + group_ids: list[str] | None = None, + user_email: str | None = None, ) -> AlertIncident | None: - group_ids: list[str] | None - if actor is not None: - payload = cast(AlertIncidentUpdateRequest, payload_or_user_id) - user_id = str(actor.user_id) - group_ids = actor.group_ids - user_email = actor.user_email - else: - user_id = str(payload_or_user_id) - if not legacy_args: - raise TypeError("payload is required") - payload = cast(AlertIncidentUpdateRequest, legacy_args[0]) - group_ids = cast(list[str] | None, legacy_args[1] if len(legacy_args) > 1 else None) - user_email = cast(str | None, legacy_args[2] if len(legacy_args) > 2 else None) - user_group_ids = [str(g).strip() for g in (group_ids or []) if str(g).strip()] with get_db_session() as db: incident = ( @@ -453,27 +445,30 @@ def update_incident( visibility = normalize_storage_visibility(str(meta.get("visibility") or "public")) creator_id = str(meta.get("created_by") or "") or None if not _incident_access_allowed( - visibility=visibility, - creator_id=creator_id, - user_id=user_id, - shared_group_ids=_safe_group_ids(meta), - user_group_ids=user_group_ids, - require_write=True, + AccessCheck( + visibility=visibility, + created_by=creator_id, + user_id=user_id, + shared_group_ids=_safe_group_ids(meta), + user_group_ids=user_group_ids, + require_write=True, + ) ): return None - self._apply_incident_assignee(payload, incident, visibility, user_id, user_email) - manual_manage_flag, resolved_note_text = self._apply_incident_status( - payload, incident, previous_status, user_id + actor_context = IncidentActorContext(user_id=str(user_id), group_ids=user_group_ids, user_email=user_email) + IncidentStorageService._apply_incident_assignee(payload, incident, visibility, actor_context) + manual_manage_flag, resolved_note_text = IncidentStorageService._apply_incident_status( + payload, incident, previous_status, str(user_id) ) - self._apply_incident_metadata(payload, incident, user_id, manual_manage_flag) - self._apply_incident_notes(payload, incident, user_id, resolved_note_text) + IncidentStorageService._apply_incident_metadata(payload, incident, str(user_id), manual_manage_flag) + IncidentStorageService._apply_incident_notes(payload, incident, str(user_id), resolved_note_text) db.flush() return incident_to_pydantic(incident) + @staticmethod def filter_alerts_for_user( - self, tenant_id: str, user_id: str, group_ids: list[str] | None, @@ -515,11 +510,13 @@ def filter_alerts_for_user( if any( has_access( - normalize_storage_visibility(getattr(r, "visibility", None)), - getattr(r, "created_by", None), - user_id, - _shared_group_ids(r), - user_group_ids, + AccessCheck( + visibility=normalize_storage_visibility(getattr(r, "visibility", None)), + created_by=getattr(r, "created_by", None), + user_id=user_id, + shared_group_ids=_shared_group_ids(r), + user_group_ids=user_group_ids, + ) ) for r in candidates ): diff --git a/services/storage/incidents_core.py b/services/storage/incidents_core.py index 536f394..d676906 100644 --- a/services/storage/incidents_core.py +++ b/services/storage/incidents_core.py @@ -1,11 +1,10 @@ """ Shared incident helpers: keys, labels, access checks, and alert-name rule resolution. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -19,7 +18,7 @@ from db_models import AlertIncident as AlertIncidentDB from db_models import AlertRule as AlertRuleDB from services.alerting.suppression import is_suppressed_status -from services.common.access import has_access +from services.common.access import AccessCheck, has_access from services.common.meta import parse_meta logger = logging.getLogger(__name__) @@ -117,25 +116,18 @@ def _is_alert_suppressed(alert: JSONDict) -> bool: return is_suppressed_status(alert.get("status") or {}) -def _incident_access_allowed( - *, - visibility: str, - creator_id: str | None, - user_id: str, - shared_group_ids: list[str], - user_group_ids: list[str], - require_write: bool = False, -) -> bool: - _ = require_write - if visibility == "group": - return bool(set(shared_group_ids) & set(user_group_ids)) +def _incident_access_allowed(check: AccessCheck) -> bool: + if check.visibility == "group": + return bool(set(check.shared_group_ids) & set(check.user_group_ids)) return has_access( - visibility, - creator_id, - user_id, - shared_group_ids, - user_group_ids, - require_write=False, + AccessCheck( + visibility=check.visibility, + created_by=check.created_by, + user_id=check.user_id, + shared_group_ids=check.shared_group_ids, + user_group_ids=check.user_group_ids, + require_write=False, + ) ) diff --git a/services/storage/incidents_jira.py b/services/storage/incidents_jira.py index 13d56d2..679b943 100644 --- a/services/storage/incidents_jira.py +++ b/services/storage/incidents_jira.py @@ -1,11 +1,10 @@ """ Jira side effects for incident lifecycle (async bridge and credential resolution). -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/services/storage/incidents_sync.py b/services/storage/incidents_sync.py index 01804fd..ad62441 100644 --- a/services/storage/incidents_sync.py +++ b/services/storage/incidents_sync.py @@ -1,11 +1,10 @@ """ Alert-to-incident synchronization and deduplication. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -57,6 +56,22 @@ class AlertSyncPayload: rule: AlertRuleDB | None +@dataclass(frozen=True) +class IncidentLookupContext: + tenant_id: str + incident_key: str | None + fingerprint: str + labels: JSONDict + now: datetime + + +@dataclass(frozen=True) +class AlertSyncContext: + tenant_id: str + now: datetime + alert: object + + def _derive_fingerprint(alert_data: JSONDict, labels: JSONDict, annotations: JSONDict) -> str: fp = alert_data.get("fingerprint") or labels.get("fingerprint") if fp: @@ -138,35 +153,30 @@ def _resolve_duplicate_incidents_for_key( def _find_incident_by_key_or_fingerprint( db: Session, - tenant_id: str, - *, - incident_key: str | None, - fingerprint: str, - labels: JSONDict, - now: datetime, + context: IncidentLookupContext, ) -> AlertIncidentDB | None: - if incident_key: - alert_name = str(labels.get("alertname") or "").strip() + if context.incident_key: + alert_name = str(context.labels.get("alertname") or "").strip() candidates = ( db.query(AlertIncidentDB) .filter( - AlertIncidentDB.tenant_id == tenant_id, + AlertIncidentDB.tenant_id == context.tenant_id, AlertIncidentDB.alert_name == alert_name, ) .order_by(AlertIncidentDB.updated_at.desc()) .all() ) - matching = [item for item in candidates if incident_key_from_db_row(item) == incident_key] + matching = [item for item in candidates if incident_key_from_db_row(item) == context.incident_key] if matching: return _resolve_duplicate_incidents_for_key( - tenant_id=tenant_id, - incident_key=incident_key, + tenant_id=context.tenant_id, + incident_key=context.incident_key, matching_candidates=matching, - now=now, + now=context.now, ) return ( db.query(AlertIncidentDB) - .filter(AlertIncidentDB.tenant_id == tenant_id, AlertIncidentDB.fingerprint == fingerprint) + .filter(AlertIncidentDB.tenant_id == context.tenant_id, AlertIncidentDB.fingerprint == context.fingerprint) .first() ) @@ -265,12 +275,10 @@ def _apply_open_incident_update_from_alert( def _sync_single_alert_into_incidents( db: Session, - tenant_id: str, - now: datetime, - alert: object, + context: AlertSyncContext, active_incident_tokens: set[str], ) -> None: - alert_data = _json_dict(alert) + alert_data = _json_dict(context.alert) if _is_alert_suppressed(alert_data): return labels = _json_dict(alert_data.get("labels", {})) @@ -281,23 +289,25 @@ def _sync_single_alert_into_incidents( active_incident_tokens.add(f"k:{incident_key}" if incident_key else f"fp:{fingerprint}") incident = _find_incident_by_key_or_fingerprint( db, - tenant_id, - incident_key=incident_key, - fingerprint=fingerprint, - labels=labels, - now=now, + IncidentLookupContext( + tenant_id=context.tenant_id, + incident_key=incident_key, + fingerprint=fingerprint, + labels=labels, + now=context.now, + ), ) parsed_starts = _parse_starts_at_from_alert(alert_data) - rule = _resolve_rule_by_alertname(db, tenant_id, labels) + rule = _resolve_rule_by_alertname(db, context.tenant_id, labels) sync_payload = AlertSyncPayload( - tenant_id=tenant_id, + tenant_id=context.tenant_id, fingerprint=fingerprint, labels=labels, annotations=annotations, metric_state=metric_state, incident_key=incident_key, parsed_starts=parsed_starts, - now=now, + now=context.now, rule=rule, ) if not incident: diff --git a/services/storage/revocation.py b/services/storage/revocation.py index 4acfcbe..39c6c70 100644 --- a/services/storage/revocation.py +++ b/services/storage/revocation.py @@ -1,10 +1,14 @@ """ -Group-share revocation helpers for Notifier resources. +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations import json +from collections.abc import Callable from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm.attributes import flag_modified @@ -26,34 +30,13 @@ def _normalize_ids(values: list[str] | None) -> list[str]: return out -def prune_removed_member_group_shares( +def _prune_rule_shares( db: Session, - *, tenant_id: str, - group_id: str, - removed_user_ids: list[str] | None, - removed_usernames: list[str] | None = None, -) -> dict[str, int]: - target_group_id = str(group_id or "").strip() - removed_ids = set(_normalize_ids(removed_user_ids)) - removed_names = {name.lower() for name in _normalize_ids(removed_usernames)} - counts = { - "rules": 0, - "channels": 0, - "incidents": 0, - "jira_integrations": 0, - } - if not target_group_id or (not removed_ids and not removed_names): - return counts - - def _is_removed_actor(actor: object) -> bool: - candidate = str(actor or "").strip() - if not candidate: - return False - if candidate in removed_ids: - return True - return candidate.lower() in removed_names - + target_group_id: str, + is_removed_actor: Callable[[object], bool], +) -> int: + count = 0 rule_rows = ( db.query(AlertRule) .options(joinedload(AlertRule.shared_groups)) @@ -65,17 +48,26 @@ def _is_removed_actor(actor: object) -> bool: .all() ) for row in rule_rows: - if not _is_removed_actor(row.created_by): + if not is_removed_actor(row.created_by): continue - before = [str(g.id) for g in row.shared_groups] - after = [g for g in row.shared_groups if str(g.id) != target_group_id] + before = [str(group.id) for group in row.shared_groups] + after = [group for group in row.shared_groups if str(group.id) != target_group_id] if len(after) == len(before): continue row.shared_groups = after if not row.shared_groups: row.visibility = "private" - counts["rules"] += 1 + count += 1 + return count + +def _prune_channel_shares( + db: Session, + tenant_id: str, + target_group_id: str, + is_removed_actor: Callable[[object], bool], +) -> int: + count = 0 channel_rows = ( db.query(NotificationChannel) .options(joinedload(NotificationChannel.shared_groups)) @@ -87,64 +79,113 @@ def _is_removed_actor(actor: object) -> bool: .all() ) for channel_row in channel_rows: - if not _is_removed_actor(channel_row.created_by): + if not is_removed_actor(channel_row.created_by): continue - before = [str(g.id) for g in channel_row.shared_groups] - after = [g for g in channel_row.shared_groups if str(g.id) != target_group_id] + before = [str(group.id) for group in channel_row.shared_groups] + after = [group for group in channel_row.shared_groups if str(group.id) != target_group_id] if len(after) == len(before): continue channel_row.shared_groups = after if not channel_row.shared_groups: channel_row.visibility = "private" - counts["channels"] += 1 + count += 1 + return count + +def _prune_incident_shares( + db: Session, + tenant_id: str, + target_group_id: str, + is_removed_actor: Callable[[object], bool], +) -> int: + count = 0 incidents = db.query(AlertIncident).filter(AlertIncident.tenant_id == tenant_id).all() for incident in incidents: annotations = incident.annotations if isinstance(incident.annotations, dict) else {} meta = parse_meta(annotations) - creator_id = str(meta.get("created_by") or "").strip() - if not _is_removed_actor(creator_id): + if not is_removed_actor(str(meta.get("created_by") or "").strip()): continue - - visibility = str(meta.get("visibility") or "public").strip().lower() - if visibility != "group": + if str(meta.get("visibility") or "public").strip().lower() != "group": continue - shared_group_ids = _safe_group_ids(meta) if target_group_id not in shared_group_ids: continue - - remaining_group_ids = [gid for gid in shared_group_ids if gid != target_group_id] + remaining_group_ids = [group_id for group_id in shared_group_ids if group_id != target_group_id] meta["shared_group_ids"] = remaining_group_ids if not remaining_group_ids: meta["visibility"] = "private" incident.annotations = {**annotations, INCIDENT_META_KEY: json.dumps(meta)} - counts["incidents"] += 1 + count += 1 + return count + +def _prune_jira_integrations( + db: Session, + tenant_id: str, + target_group_id: str, + is_removed_actor: Callable[[object], bool], +) -> int: tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() settings: JSONDict = dict(tenant.settings) if tenant and isinstance(tenant.settings, dict) else {} jira_items = settings.get("jira_integrations") - if isinstance(jira_items, list): - changed = 0 - for item in jira_items: - if not isinstance(item, dict): - continue - creator_id = str(item.get("createdBy") or "").strip() - visibility = str(item.get("visibility") or "private").strip().lower() - if not _is_removed_actor(creator_id) or visibility != "group": - continue - shared = _normalize_ids(item.get("sharedGroupIds") if isinstance(item.get("sharedGroupIds"), list) else []) - if target_group_id not in shared: - continue - remaining = [gid for gid in shared if gid != target_group_id] - item["sharedGroupIds"] = remaining - if not remaining: - item["visibility"] = "private" - changed += 1 - if changed and tenant: - settings["jira_integrations"] = jira_items - tenant.settings = settings - flag_modified(tenant, "settings") - counts["jira_integrations"] = changed + if not isinstance(jira_items, list): + return 0 + + changed = 0 + for item in jira_items: + if not isinstance(item, dict): + continue + creator_id = str(item.get("createdBy") or "").strip() + visibility = str(item.get("visibility") or "private").strip().lower() + if not is_removed_actor(creator_id) or visibility != "group": + continue + shared = _normalize_ids(item.get("sharedGroupIds") if isinstance(item.get("sharedGroupIds"), list) else []) + if target_group_id not in shared: + continue + remaining = [group_id for group_id in shared if group_id != target_group_id] + item["sharedGroupIds"] = remaining + if not remaining: + item["visibility"] = "private" + changed += 1 + + if changed and tenant: + settings["jira_integrations"] = jira_items + tenant.settings = settings + flag_modified(tenant, "settings") + return changed + + +def prune_removed_member_group_shares( + db: Session, + *, + tenant_id: str, + group_id: str, + removed_user_ids: list[str] | None, + removed_usernames: list[str] | None = None, +) -> dict[str, int]: + target_group_id = str(group_id or "").strip() + removed_ids = set(_normalize_ids(removed_user_ids)) + removed_names = {name.lower() for name in _normalize_ids(removed_usernames)} + counts = { + "rules": 0, + "channels": 0, + "incidents": 0, + "jira_integrations": 0, + } + if not target_group_id or (not removed_ids and not removed_names): + return counts + + def _is_removed_actor(actor: object) -> bool: + candidate = str(actor or "").strip() + if not candidate: + return False + if candidate in removed_ids: + return True + return candidate.lower() in removed_names + + counts["rules"] = _prune_rule_shares(db, tenant_id, target_group_id, _is_removed_actor) + counts["channels"] = _prune_channel_shares(db, tenant_id, target_group_id, _is_removed_actor) + counts["incidents"] = _prune_incident_shares(db, tenant_id, target_group_id, _is_removed_actor) + counts["jira_integrations"] = _prune_jira_integrations(db, tenant_id, target_group_id, _is_removed_actor) return counts diff --git a/services/storage/rules.py b/services/storage/rules.py index 06e6097..bfa6ad9 100644 --- a/services/storage/rules.py +++ b/services/storage/rules.py @@ -1,17 +1,17 @@ """ Rules management service for handling alert rules, including CRUD operations, access control, and visibility management. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations import logging import uuid +from dataclasses import dataclass, field from sqlalchemy.orm import joinedload @@ -19,7 +19,7 @@ from db_models import AlertRule as AlertRuleDB from db_models import HiddenAlertRule from models.alerting.rules import AlertRule, AlertRuleCreate -from services.common.access import assign_shared_groups, has_access +from services.common.access import AccessCheck, SharedGroupAssignment, assign_shared_groups, has_access from services.common.pagination import cap_pagination from services.common.tenants import ensure_tenant_exists from services.storage.serializers import rule_to_pydantic @@ -39,9 +39,39 @@ def _creator_of(rule: AlertRuleDB) -> str: return str(rule.created_by or "") +@dataclass(frozen=True) +class RuleAccessContext: + user_id: str + group_ids: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class PageRequest: + limit: int | None = None + offset: int = 0 + + class RuleStorageService: + def __init__(self, *_args: object, **_kwargs: object) -> None: + return + + @staticmethod + def _access_context( + access: RuleAccessContext | str, + group_ids: list[str] | None = None, + ) -> RuleAccessContext: + if isinstance(access, RuleAccessContext): + return access + return RuleAccessContext(user_id=str(access), group_ids=list(group_ids or [])) + + @staticmethod + def _page_request(value: PageRequest | list[str] | None) -> PageRequest: + if isinstance(value, PageRequest): + return value + return PageRequest() + + @staticmethod def get_alert_rule_by_name_for_delivery( - self, tenant_id: str, rule_name: str, org_id: str | None = None, @@ -63,7 +93,8 @@ def get_alert_rule_by_name_for_delivery( rules = org_matched or [r for r in rules if not getattr(r, "org_id", None)] or rules return rule_to_pydantic(rules[0]) - def get_hidden_rule_ids(self, tenant_id: str, user_id: str) -> list[str]: + @staticmethod + def get_hidden_rule_ids(tenant_id: str, user_id: str) -> list[str]: with get_db_session() as db: rows = ( db.query(HiddenAlertRule.rule_id) @@ -75,7 +106,8 @@ def get_hidden_rule_ids(self, tenant_id: str, user_id: str) -> list[str]: ) return [str(rule_id) for (rule_id,) in rows] - def get_hidden_rule_names(self, tenant_id: str, user_id: str) -> list[str]: + @staticmethod + def get_hidden_rule_names(tenant_id: str, user_id: str) -> list[str]: with get_db_session() as db: rows = ( db.query(AlertRuleDB.name) @@ -89,7 +121,8 @@ def get_hidden_rule_names(self, tenant_id: str, user_id: str) -> list[str]: ) return [str(name) for (name,) in rows if str(name or "").strip()] - def toggle_rule_hidden(self, tenant_id: str, user_id: str, rule_id: str, hidden: bool) -> bool: + @staticmethod + def toggle_rule_hidden(tenant_id: str, user_id: str, rule_id: str, hidden: bool) -> bool: with get_db_session() as db: rule = ( db.query(AlertRuleDB) @@ -126,7 +159,8 @@ def toggle_rule_hidden(self, tenant_id: str, user_id: str, rule_id: str, hidden: db.delete(existing) return True - def get_public_alert_rules(self, tenant_id: str) -> list[AlertRule]: + @staticmethod + def get_public_alert_rules(tenant_id: str) -> list[AlertRule]: with get_db_session() as db: rules = ( db.query(AlertRuleDB) @@ -140,16 +174,19 @@ def get_public_alert_rules(self, tenant_id: str) -> list[AlertRule]: ) return [rule_to_pydantic(r) for r in rules] + @staticmethod def get_alert_rules( - self, tenant_id: str, - user_id: str, - group_ids: list[str] | None = None, - limit: int | None = None, - offset: int = 0, + access: RuleAccessContext | str, + page_or_group_ids: PageRequest | list[str] | None = None, ) -> list[AlertRule]: - group_ids = group_ids or [] - capped_limit, capped_offset = cap_pagination(limit, offset) + context = RuleStorageService._access_context( + access, + group_ids=page_or_group_ids if isinstance(page_or_group_ids, list) else None, + ) + group_ids = list(context.group_ids or []) + paging = RuleStorageService._page_request(page_or_group_ids) + capped_limit, capped_offset = cap_pagination(paging.limit, paging.offset) with get_db_session() as db: rules = ( @@ -162,11 +199,20 @@ def get_alert_rules( ) out: list[AlertRule] = [] for r in rules: - if has_access(_visibility_of(r), _creator_of(r), user_id, _shared_group_ids(r), group_ids): + if has_access( + AccessCheck( + visibility=_visibility_of(r), + created_by=_creator_of(r), + user_id=context.user_id, + shared_group_ids=_shared_group_ids(r), + user_group_ids=group_ids, + ) + ): out.append(rule_to_pydantic(r)) return out - def get_alert_rules_for_org(self, tenant_id: str, org_id: str) -> list[AlertRule]: + @staticmethod + def get_alert_rules_for_org(tenant_id: str, org_id: str) -> list[AlertRule]: with get_db_session() as db: rules = ( db.query(AlertRuleDB) @@ -176,16 +222,19 @@ def get_alert_rules_for_org(self, tenant_id: str, org_id: str) -> list[AlertRule ) return [rule_to_pydantic(r) for r in rules] + @staticmethod def get_alert_rules_with_owner( - self, tenant_id: str, - user_id: str, - group_ids: list[str] | None = None, - limit: int | None = None, - offset: int = 0, + access: RuleAccessContext | str, + page_or_group_ids: PageRequest | list[str] | None = None, ) -> list[tuple[AlertRule, str]]: - group_ids = group_ids or [] - capped_limit, capped_offset = cap_pagination(limit, offset) + context = RuleStorageService._access_context( + access, + group_ids=page_or_group_ids if isinstance(page_or_group_ids, list) else None, + ) + group_ids = list(context.group_ids or []) + paging = RuleStorageService._page_request(page_or_group_ids) + capped_limit, capped_offset = cap_pagination(paging.limit, paging.offset) with get_db_session() as db: rules = ( @@ -199,11 +248,20 @@ def get_alert_rules_with_owner( out: list[tuple[AlertRule, str]] = [] for r in rules: - if has_access(_visibility_of(r), _creator_of(r), user_id, _shared_group_ids(r), group_ids): + if has_access( + AccessCheck( + visibility=_visibility_of(r), + created_by=_creator_of(r), + user_id=context.user_id, + shared_group_ids=_shared_group_ids(r), + user_group_ids=group_ids, + ) + ): out.append((rule_to_pydantic(r), _creator_of(r))) return out - def get_alert_rule_raw(self, rule_id: str, tenant_id: str) -> AlertRuleDB | None: + @staticmethod + def get_alert_rule_raw(rule_id: str, tenant_id: str) -> AlertRuleDB | None: with get_db_session() as db: return ( db.query(AlertRuleDB) @@ -212,14 +270,15 @@ def get_alert_rule_raw(self, rule_id: str, tenant_id: str) -> AlertRuleDB | None .first() ) + @staticmethod def get_alert_rule( - self, rule_id: str, tenant_id: str, - user_id: str, + access: RuleAccessContext | str, group_ids: list[str] | None = None, ) -> AlertRule | None: - group_ids = group_ids or [] + context = RuleStorageService._access_context(access, group_ids=group_ids) + group_ids = list(context.group_ids or []) with get_db_session() as db: r = ( db.query(AlertRuleDB) @@ -229,23 +288,32 @@ def get_alert_rule( ) if not r: return None - if not has_access(_visibility_of(r), _creator_of(r), user_id, _shared_group_ids(r), group_ids): + if not has_access( + AccessCheck( + visibility=_visibility_of(r), + created_by=_creator_of(r), + user_id=context.user_id, + shared_group_ids=_shared_group_ids(r), + user_group_ids=group_ids, + ) + ): return None return rule_to_pydantic(r) + @staticmethod def create_alert_rule( - self, rule_create: AlertRuleCreate, tenant_id: str, - user_id: str, + access: RuleAccessContext | str, group_ids: list[str] | None = None, ) -> AlertRule: + context = RuleStorageService._access_context(access, group_ids=group_ids) with get_db_session() as db: ensure_tenant_exists(db, tenant_id) rule = AlertRuleDB( id=str(uuid.uuid4()), tenant_id=tenant_id, - created_by=user_id, + created_by=context.user_id, org_id=rule_create.org_id or None, name=rule_create.name, group=rule_create.group, @@ -261,10 +329,12 @@ def create_alert_rule( assign_shared_groups( rule, db, - tenant_id, - _visibility_of(rule), - rule_create.shared_group_ids, - actor_group_ids=group_ids, + SharedGroupAssignment( + tenant_id=tenant_id, + visibility=_visibility_of(rule), + group_ids=rule_create.shared_group_ids, + actor_group_ids=context.group_ids, + ), ) db.add(rule) db.flush() @@ -273,15 +343,17 @@ def create_alert_rule( ) return rule_to_pydantic(rule) + @staticmethod def update_alert_rule( - self, rule_id: str, rule_update: AlertRuleCreate, tenant_id: str, - user_id: str, + access: RuleAccessContext | str, + *, group_ids: list[str] | None = None, ) -> AlertRule | None: - group_ids = group_ids or [] + context = RuleStorageService._access_context(access, group_ids=group_ids) + group_ids = list(context.group_ids or []) with get_db_session() as db: r = ( db.query(AlertRuleDB) @@ -291,15 +363,25 @@ def update_alert_rule( ) if not r: return None - if not has_access(_visibility_of(r), _creator_of(r), user_id, _shared_group_ids(r), group_ids): + if not has_access( + AccessCheck( + visibility=_visibility_of(r), + created_by=_creator_of(r), + user_id=context.user_id, + shared_group_ids=_shared_group_ids(r), + user_group_ids=group_ids, + ) + ): return None if not has_access( - _visibility_of(r), - _creator_of(r), - user_id, - _shared_group_ids(r), - group_ids, - require_write=True, + AccessCheck( + visibility=_visibility_of(r), + created_by=_creator_of(r), + user_id=context.user_id, + shared_group_ids=_shared_group_ids(r), + user_group_ids=group_ids, + require_write=True, + ) ): return None @@ -318,23 +400,26 @@ def update_alert_rule( assign_shared_groups( r, db, - tenant_id, - _visibility_of(r), - rule_update.shared_group_ids, - actor_group_ids=group_ids, + SharedGroupAssignment( + tenant_id=tenant_id, + visibility=_visibility_of(r), + group_ids=rule_update.shared_group_ids, + actor_group_ids=group_ids, + ), ) db.flush() logger.info("Updated alert rule %s (%s) org_id=%s", r.name, rule_id, r.org_id) return rule_to_pydantic(r) + @staticmethod def delete_alert_rule( - self, rule_id: str, tenant_id: str, - user_id: str, + access: RuleAccessContext | str, group_ids: list[str] | None = None, ) -> bool: - group_ids = group_ids or [] + context = RuleStorageService._access_context(access, group_ids=group_ids) + group_ids = list(context.group_ids or []) with get_db_session() as db: r = ( db.query(AlertRuleDB) @@ -345,12 +430,14 @@ def delete_alert_rule( if not r: return False if not has_access( - _visibility_of(r), - _creator_of(r), - user_id, - _shared_group_ids(r), - group_ids, - require_write=True, + AccessCheck( + visibility=_visibility_of(r), + created_by=_creator_of(r), + user_id=context.user_id, + shared_group_ids=_shared_group_ids(r), + user_group_ids=group_ids, + require_write=True, + ) ): return False db.delete(r) diff --git a/services/storage/serializers.py b/services/storage/serializers.py index aee7733..5e3e2fe 100644 --- a/services/storage/serializers.py +++ b/services/storage/serializers.py @@ -2,11 +2,10 @@ Serializers for converting internal storage models to Pydantic models used in API responses, ensuring proper data formatting and handling of sensitive information based on user permissions. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -16,7 +15,6 @@ from db_models import AlertIncident as AlertIncidentDB from db_models import AlertRule as AlertRuleDB -from db_models import NotificationChannel as NotificationChannelDB from models.alerting.channels import NotificationChannel as NotificationChannelPydantic from models.alerting.incidents import AlertIncident as AlertIncidentPydantic from models.alerting.incidents import IncidentStatus @@ -48,6 +46,11 @@ def _sanitize_channel_config_for_response(raw_config: object) -> dict[str, objec return cleaned +def _shared_group_ids(ch: object) -> list[str]: + shared_groups = getattr(ch, "shared_groups", None) or [] + return [str(getattr(group, "id", "")) for group in shared_groups if str(getattr(group, "id", "")).strip()] + + def rule_to_pydantic(r: AlertRuleDB) -> AlertRulePydantic: payload = { "id": r.id, @@ -69,9 +72,7 @@ def rule_to_pydantic(r: AlertRuleDB) -> AlertRulePydantic: return AlertRulePydantic.model_validate(payload) -def channel_to_pydantic( - ch: NotificationChannelDB, *, include_sensitive: bool = True -) -> NotificationChannelPydantic: +def channel_to_pydantic(ch: object, *, include_sensitive: bool = True) -> NotificationChannelPydantic: return channel_to_pydantic_for_viewer( ch, viewer_user_id=getattr(ch, "created_by", None), @@ -80,23 +81,32 @@ def channel_to_pydantic( def channel_to_pydantic_for_viewer( - ch: NotificationChannelDB, + ch: object, viewer_user_id: object, *, include_sensitive: bool = False, ) -> NotificationChannelPydantic: - raw_config = getattr(ch, "config", None) or {} + channel_id = getattr(ch, "id", None) + channel_name = getattr(ch, "name") + channel_type = getattr(ch, "type") + channel_enabled = bool(getattr(ch, "enabled", False)) + channel_config = getattr(ch, "config", None) or {} + channel_created_by = getattr(ch, "created_by", None) + channel_visibility = getattr(ch, "visibility", None) or "private" + channel_is_hidden = bool(getattr(ch, "is_hidden", False)) + + raw_config = channel_config visible_config = raw_config if include_sensitive else _sanitize_channel_config_for_response(raw_config) payload = { - "id": ch.id, - "name": ch.name, - "type": ch.type, - "enabled": ch.enabled, - "config": visible_config if (getattr(ch, "created_by", None) and ch.created_by == viewer_user_id) else {}, - "createdBy": ch.created_by, - "visibility": ch.visibility or "private", - "sharedGroupIds": [g.id for g in ch.shared_groups] if getattr(ch, "shared_groups", None) else [], - "isHidden": bool(getattr(ch, "is_hidden", False)), + "id": channel_id, + "name": channel_name, + "type": channel_type, + "enabled": channel_enabled, + "config": visible_config if (channel_created_by and channel_created_by == viewer_user_id) else {}, + "createdBy": channel_created_by, + "visibility": channel_visibility, + "sharedGroupIds": _shared_group_ids(ch), + "isHidden": channel_is_hidden, } return NotificationChannelPydantic.model_validate(payload) diff --git a/services/storage_db_service.py b/services/storage_db_service.py index fa5f101..f185764 100644 --- a/services/storage_db_service.py +++ b/services/storage_db_service.py @@ -2,11 +2,10 @@ Storage service for managing alert incidents, rules, and notification channels, providing a unified interface for database operations and ensuring proper access control and data handling based on user permissions. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/tests/__init__.py b/tests/__init__.py index d0acfc6..75c2fc7 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,6 +2,5 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ diff --git a/tests/_env.py b/tests/_env.py index 30e9d12..668cc0e 100644 --- a/tests/_env.py +++ b/tests/_env.py @@ -1,4 +1,5 @@ """ + Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the @@ -6,17 +7,45 @@ http://www.apache.org/licenses/LICENSE-2.0 """ +import atexit import os +import shutil import sys +import tempfile from pathlib import Path +_TEST_SQLITE_DIR = Path(tempfile.mkdtemp(prefix="observantio-notifier-tests-")) +_TEST_SQLITE_URL = f"sqlite:///{_TEST_SQLITE_DIR / 'notifier.sqlite3'}" +_TEST_DATABASE_INITIALIZED = False + +atexit.register(shutil.rmtree, _TEST_SQLITE_DIR, ignore_errors=True) def ensure_test_env() -> None: + global _TEST_DATABASE_INITIALIZED + service_root = Path(__file__).resolve().parents[1] if str(service_root) not in sys.path: sys.path.insert(0, str(service_root)) - os.environ.setdefault("DATABASE_URL", "postgresql://safeuser:safePass_123@db:5432/watchdog") + + use_temp_sqlite = os.getenv("USE_TEMP_SQLITE_TEST_DB", "").strip().lower() in {"1", "true", "yes", "on"} + if use_temp_sqlite: + os.environ["DATABASE_URL"] = _TEST_SQLITE_URL + os.environ["NOTIFIER_DATABASE_URL"] = _TEST_SQLITE_URL + else: + os.environ.setdefault("DATABASE_URL", "postgresql://safeuser:safePass_123@db:5432/watchdog") + os.environ.setdefault("NOTIFIER_DATABASE_URL", os.environ["DATABASE_URL"]) + os.environ.setdefault("JWT_ALGORITHM", "RS256") os.environ.setdefault("JWT_PRIVATE_KEY", "test-private-key") os.environ.setdefault("JWT_PUBLIC_KEY", "test-public-key") os.environ.setdefault("JWT_AUTO_GENERATE_KEYS", "false") + + database_url = os.environ.get("NOTIFIER_DATABASE_URL", os.environ.get("DATABASE_URL", "")) + if _TEST_DATABASE_INITIALIZED or not database_url.startswith("sqlite"): + return + + from database import init_database, init_db + + init_database(database_url) + init_db() + _TEST_DATABASE_INITIALIZED = True diff --git a/tests/_regression_helpers.py b/tests/_regression_helpers.py index 4fe4d32..f9895e4 100644 --- a/tests/_regression_helpers.py +++ b/tests/_regression_helpers.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/tests/conftest.py b/tests/conftest.py index 49310d7..e45510f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import os diff --git a/tests/test_alert_ops.py b/tests/test_alert_ops.py index 757d994..8f231ce 100644 --- a/tests/test_alert_ops.py +++ b/tests/test_alert_ops.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import unittest diff --git a/tests/test_alerting_ops_and_transport.py b/tests/test_alerting_ops_and_transport.py index 6ef1248..972e980 100644 --- a/tests/test_alerting_ops_and_transport.py +++ b/tests/test_alerting_ops_and_transport.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -26,6 +25,7 @@ from models.alerting.alerts import Alert from models.alerting.rules import AlertRule from services.alerting import alerts_ops, rules_ops +from services.alerting.alerts_ops import AlertQuery from services.notification import transport as transport_mod @@ -94,7 +94,10 @@ async def fake_alert_get(*_args, **_kwargs): ) monkeypatch.setattr(service.alertmanager_http_client, "get", fake_alert_get, raising=False) - alerts = await alerts_ops.get_alerts(service, {"service": "api"}, active=True, silenced=False, inhibited=False) + alerts = await alerts_ops.get_alerts( + service, + AlertQuery(filter_labels={"service": "api"}, active=True, silenced=False, inhibited=False), + ) assert alerts[0].labels["alertname"] == "CPUHigh" async def fake_group_get(*_args, **_kwargs): @@ -270,22 +273,32 @@ async def post(self, *_args, **_kwargs): response = FakeResponse(status_code=200) assert ( - await transport_mod.post_with_retry(FakeClient(response), "https://example.test", json={"ok": True}) is response + await transport_mod.post_with_retry( + transport_mod.HttpPostRequest(FakeClient(response), "https://example.test", json={"ok": True}) + ) + is response ) with pytest.raises(httpx.HTTPStatusError): - await transport_mod.post_with_retry(FakeClient(FakeResponse(status_code=500)), "https://example.test") + await transport_mod.post_with_retry( + transport_mod.HttpPostRequest(FakeClient(FakeResponse(status_code=500)), "https://example.test") + ) sent = [] - async def fake_send(**kwargs): + async def fake_send(*_args, **kwargs): sent.append(kwargs) return {"accepted": ["user@example.com"]} monkeypatch.setattr(transport_mod.aiosmtplib, "send", fake_send) message = EmailMessage() message["To"] = "user@example.com" - assert (await transport_mod.send_smtp_with_retry(message, "smtp.example.com", 587))["accepted"] == [ + assert ( + await transport_mod.send_smtp_with_retry( + message, + smtp=transport_mod.SmtpDeliveryConfig(hostname="smtp.example.com", port=587), + ) + )["accepted"] == [ "user@example.com" ] assert sent[0]["hostname"] == "smtp.example.com" diff --git a/tests/test_alertmanager_stateful_workflows.py b/tests/test_alertmanager_stateful_workflows.py index 692a01b..4e5a53d 100644 --- a/tests/test_alertmanager_stateful_workflows.py +++ b/tests/test_alertmanager_stateful_workflows.py @@ -1,5 +1,8 @@ """ -Stateful workflow coverage for alertmanager routes. +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -83,10 +86,21 @@ def __init__(self) -> None: self._rule_counter = 0 self.incident_sync_calls = 0 + @staticmethod + def _access_parts(access: object) -> tuple[str, list[str]]: + if hasattr(access, "user_id"): + return str(getattr(access, "user_id", "")), list(getattr(access, "group_ids", []) or []) + return str(access), [] + def create_notification_channel( - self, channel_create: NotificationChannelCreate, tenant_id: str, user_id: str, group_ids + self, + channel_create: NotificationChannelCreate, + tenant_id: str, + access: object, + group_ids: list[str] | None = None, ): - _ = group_ids + user_id, context_groups = self._access_parts(access) + _ = group_ids, context_groups self._channel_counter += 1 channel_id = f"ch-{self._channel_counter}" channel = NotificationChannel.model_validate( @@ -104,8 +118,15 @@ def create_notification_channel( self.channels[f"{tenant_id}:{channel_id}"] = channel return channel - def create_alert_rule(self, rule_create: AlertRuleCreate, tenant_id: str, user_id: str, group_ids): - _ = group_ids + def create_alert_rule( + self, + rule_create: AlertRuleCreate, + tenant_id: str, + access: object, + group_ids: list[str] | None = None, + ): + user_id, context_groups = self._access_parts(access) + _ = group_ids, context_groups self._rule_counter += 1 rule_id = f"rule-{self._rule_counter}" rule = AlertRule.model_validate( @@ -131,8 +152,8 @@ def create_alert_rule(self, rule_create: AlertRuleCreate, tenant_id: str, user_i self.rules[f"{tenant_id}:{rule_id}"] = rule return rule - def get_alert_rule(self, rule_id: str, tenant_id: str, user_id: str, group_ids): - _ = user_id, group_ids + def get_alert_rule(self, rule_id: str, tenant_id: str, access: object, group_ids: list[str] | None = None): + _ = access, group_ids return self.rules.get(f"{tenant_id}:{rule_id}") def get_alert_rules_for_org(self, tenant_id: str, org_id: str): @@ -197,7 +218,10 @@ async def test_stateful_channel_rule_test_and_webhook_workflow(monkeypatch): fake_notification = _FakeNotificationService() current_user = _user() - async def _notify_for_alerts(tenant_id: str, alerts_list: list[dict[str, Any]], storage, notification): + async def _notify_for_alerts(context, alerts_list: list[dict[str, Any]]): + tenant_id = context.tenant_id + storage = context.storage_service + notification = context.notification_service for incoming in alerts_list: labels = incoming.get("labels") or {} alertname = str(labels.get("alertname") or "") @@ -214,12 +238,10 @@ async def _sync_incidents(tenant_id: str, alerts: list[dict[str, Any]], *, log_c _ = log_context fake_storage.sync_incidents_from_alerts(tenant_id, alerts, False) - # shared router mechanics monkeypatch.setattr(channels_router, "run_in_threadpool", _run_in_threadpool) monkeypatch.setattr(rules_router, "run_in_threadpool", _run_in_threadpool) monkeypatch.setattr(channels_router, "validate_channel", lambda *_args, **_kwargs: "slack") - # channel/rule routes share these collaborators monkeypatch.setattr(channels_router, "storage_service", fake_storage) monkeypatch.setattr(rules_router, "storage_service", fake_storage) monkeypatch.setattr(rules_router, "notification_service", fake_notification) @@ -235,7 +257,6 @@ async def _sync_incidents(tenant_id: str, alerts: list[dict[str, Any]], *, log_c ) monkeypatch.setattr(rules_router.alertmanager_service, "sync_mimir_rules_for_org", _sync_mimir) - # webhooks integration points monkeypatch.setattr(webhooks_router, "storage_service", fake_storage) monkeypatch.setattr(webhooks_router, "notification_service", fake_notification) monkeypatch.setattr( diff --git a/tests/test_alerts_ops_metrics_edges.py b/tests/test_alerts_ops_metrics_edges.py index fe055dc..8cefd61 100644 --- a/tests/test_alerts_ops_metrics_edges.py +++ b/tests/test_alerts_ops_metrics_edges.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -22,6 +21,7 @@ from models.alerting.alerts import Alert from services.alerting import alerts_ops +from services.alerting.alerts_ops import AlertQuery class _Response: @@ -214,7 +214,8 @@ async def post(self, url, json=None): ) alerts = await alerts_ops.get_alerts( - service, filter_labels={"alertname": "CPUHigh"}, active=True, silenced=False, inhibited=False + service, + AlertQuery(filter_labels={"alertname": "CPUHigh"}, active=True, silenced=False, inhibited=False), ) assert alerts and alerts[0].labels["alertname"] == "CPUHigh" @@ -243,7 +244,7 @@ async def _create_silence(_silence): assert await alerts_ops.delete_alerts(service, {"alertname": "CPUHigh"}) is True client.mode = "error" - assert await alerts_ops.get_alerts(service, {"a": "b"}) == [] + assert await alerts_ops.get_alerts(service, AlertQuery(filter_labels={"a": "b"})) == [] assert await alerts_ops.get_alert_groups(service, {"a": "b"}) == [] assert await alerts_ops.post_alerts(service, []) is False diff --git a/tests/test_channel_delivery_visibility.py b/tests/test_channel_delivery_visibility.py index 2d111a2..c2c07a0 100644 --- a/tests/test_channel_delivery_visibility.py +++ b/tests/test_channel_delivery_visibility.py @@ -3,7 +3,7 @@ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0l +http://www.apache.org/licenses/LICENSE-2.0 for details. """ from types import SimpleNamespace diff --git a/tests/test_channels_ops.py b/tests/test_channels_ops.py index d74f288..d9033fc 100644 --- a/tests/test_channels_ops.py +++ b/tests/test_channels_ops.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ try: @@ -28,11 +27,16 @@ async def test_notify_for_alerts_skips_suppressed_alerts(): get_alert_rule_by_name_for_delivery=lambda *args, **kwargs: None, ) notification_service = SimpleNamespace(send_notification=AsyncMock(return_value=True)) - - await channels_ops.notify_for_alerts( + context = channels_ops.NotificationDispatchContext( service=SimpleNamespace(), tenant_id="t1", - alerts_list=[ + storage_service=storage_service, + notification_service=notification_service, + ) + + await channels_ops.notify_for_alerts( + context, + [ { "labels": {"alertname": "DiskFull", "severity": "critical"}, "annotations": {"summary": "Disk almost full"}, @@ -41,8 +45,6 @@ async def test_notify_for_alerts_skips_suppressed_alerts(): "fingerprint": "fp-1", } ], - storage_service=storage_service, - notification_service=notification_service, ) notification_service.send_notification.assert_not_called() diff --git a/tests/test_channels_ops_edges.py b/tests/test_channels_ops_edges.py index 477e750..792fe57 100644 --- a/tests/test_channels_ops_edges.py +++ b/tests/test_channels_ops_edges.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -68,6 +67,12 @@ async def fake_send_notification(channel, alert_model, action): notification_service.send_notification = fake_send_notification monkeypatch.setattr(channels_ops, "is_suppressed_status", lambda raw_status: raw_status == {"state": "suppressed"}) + context = channels_ops.NotificationDispatchContext( + service=service, + tenant_id="tenant", + storage_service=storage_service, + notification_service=notification_service, + ) alerts = [ {}, @@ -89,7 +94,7 @@ async def fake_send_notification(channel, alert_model, action): }, ] - await channels_ops.notify_for_alerts(service, "tenant", alerts, storage_service, notification_service) + await channels_ops.notify_for_alerts(context, alerts) assert len(sent) == 4 assert sent[0][2] == "firing" assert sent[0][1].annotations["WatchdogCorrelationId"] == "infra" @@ -98,7 +103,7 @@ async def fake_send_notification(channel, alert_model, action): assert sent[2][2] == "resolved" storage_service.get_notification_channels_for_rule_name = lambda *_args, **_kwargs: [] - await channels_ops.notify_for_alerts(service, "tenant", alerts, storage_service, notification_service) + await channels_ops.notify_for_alerts(context, alerts) # cover matched-rule optional annotation branches and unmatched-rule path sent.clear() @@ -108,11 +113,8 @@ async def fake_send_notification(channel, alert_model, action): ] storage_service.get_alert_rule_by_name_for_delivery = lambda *_args, **_kwargs: sparse_rule await channels_ops.notify_for_alerts( - service, - "tenant", + context, [{"labels": {"alertname": "CPUHigh"}, "annotations": {}, "status": {"state": "active"}}], - storage_service, - notification_service, ) assert sent and "WatchdogCreatedByUsername" not in sent[-1][1].annotations assert "WatchdogProductName" not in sent[-1][1].annotations @@ -120,11 +122,8 @@ async def fake_send_notification(channel, alert_model, action): sent.clear() storage_service.get_alert_rule_by_name_for_delivery = lambda *_args, **_kwargs: None await channels_ops.notify_for_alerts( - service, - "tenant", + context, [{"labels": {"alertname": "CPUHigh"}, "annotations": {}, "status": "resolved"}], - storage_service, - notification_service, ) assert sent and sent[-1][2] == "resolved" diff --git a/tests/test_common_access_edges.py b/tests/test_common_access_edges.py index ec13905..bedcda3 100644 --- a/tests/test_common_access_edges.py +++ b/tests/test_common_access_edges.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -65,30 +64,50 @@ def rollback(self): def test_resolve_groups_handles_empty_missing_and_membership_paths(): db = FakeDB(groups=[]) - assert access._resolve_groups(db, "tenant", []) == [] + assert access._resolve_groups(db, access.GroupResolveRequest(tenant_id="tenant", group_ids=[])) == [] existing = [SimpleNamespace(id="g1", tenant_id="tenant")] db = FakeDB(groups=existing) - groups = access._resolve_groups(db, "tenant", [" g1 ", None, ""], actor_group_ids=["g1"]) + groups = access._resolve_groups( + db, + access.GroupResolveRequest(tenant_id="tenant", group_ids=[" g1 ", None, ""], actor_group_ids=["g1"]), + ) assert [group.id for group in groups] == ["g1"] db = FakeDB(groups=[]) - groups = access._resolve_groups(db, "tenant", ["g2"], actor_group_ids=["g2"]) + groups = access._resolve_groups( + db, + access.GroupResolveRequest(tenant_id="tenant", group_ids=["g2"], actor_group_ids=["g2"]), + ) assert [group.id for group in groups] == ["g2"] assert len(db.added) == 1 db = FakeDB(groups=[], flush_error=IntegrityError("stmt", {}, Exception("boom"))) - groups = access._resolve_groups(db, "tenant", ["g3"], actor_group_ids=["g3"]) + groups = access._resolve_groups( + db, + access.GroupResolveRequest(tenant_id="tenant", group_ids=["g3"], actor_group_ids=["g3"]), + ) assert [group.id for group in groups] == ["g3"] assert db.rolled_back is True db = FakeDB(groups=[SimpleNamespace(id="g4", tenant_id="tenant")]) with pytest.raises(HTTPException) as exc: - access._resolve_groups(db, "tenant", ["g4"], actor_group_ids=["other"]) + access._resolve_groups( + db, + access.GroupResolveRequest(tenant_id="tenant", group_ids=["g4"], actor_group_ids=["other"]), + ) assert exc.value.status_code == 403 db = FakeDB(groups=[SimpleNamespace(id="g5", tenant_id="tenant")]) - groups = access._resolve_groups(db, "tenant", ["g5"], actor_group_ids=[], enforce_membership=False) + groups = access._resolve_groups( + db, + access.GroupResolveRequest( + tenant_id="tenant", + group_ids=["g5"], + actor_group_ids=[], + enforce_membership=False, + ), + ) assert [group.id for group in groups] == ["g5"] @@ -97,19 +116,46 @@ def test_assign_shared_groups_and_access_matrix(monkeypatch): monkeypatch.setattr(access, "_resolve_groups", lambda *_args, **_kwargs: resolved) obj = SimpleNamespace(shared_groups=["old"]) - access.assign_shared_groups(obj, "db", "tenant", "private", ["g1"], actor_group_ids=["g1"]) + access.assign_shared_groups( + obj, + "db", + access.SharedGroupAssignment( + tenant_id="tenant", + visibility="private", + group_ids=["g1"], + actor_group_ids=["g1"], + ), + ) assert obj.shared_groups == [] with pytest.raises(ValueError): - access.assign_shared_groups(obj, "db", "tenant", "group", None, actor_group_ids=["g1"]) - - access.assign_shared_groups(obj, "db", "tenant", "group", ["g1"], actor_group_ids=["g1"]) + access.assign_shared_groups( + obj, + "db", + access.SharedGroupAssignment( + tenant_id="tenant", + visibility="group", + group_ids=None, + actor_group_ids=["g1"], + ), + ) + + access.assign_shared_groups( + obj, + "db", + access.SharedGroupAssignment( + tenant_id="tenant", + visibility="group", + group_ids=["g1"], + actor_group_ids=["g1"], + ), + ) assert obj.shared_groups == resolved - assert access.has_access("private", "u1", "u1", [], []) is True - assert access.has_access("tenant", "owner", "u2", [], [], require_write=False) is True - assert access.has_access("tenant", "owner", "u2", [], [], require_write=True) is False - assert access.has_access("group", "owner", "u2", ["g1"], ["g1"]) is True - assert access.has_access("group", "owner", "u2", ["g1"], ["g2"]) is False - assert access.has_access("private", "owner", "u2", [], []) is False - assert access.has_access("mystery", "owner", "u2", [], []) is False + assert access.has_access(access.AccessCheck("private", "u1", "u1", [], [])) is True + assert access.has_access(access.AccessCheck("tenant", "owner", "u2", [], [], require_write=False)) is True + assert access.has_access(access.AccessCheck("tenant", "owner", "u2", [], [], require_write=True)) is False + assert access.has_access(access.AccessCheck("group", "owner", "u2", ["g1"], ["g1"])) is True + assert access.has_access(access.AccessCheck("group", "owner", "u2", ["g1"], ["g2"])) is False + assert access.has_access(access.AccessCheck("private", "owner", "u2", [], [])) is False + assert access.has_access(access.AccessCheck("mystery", "owner", "u2", [], [])) is False diff --git a/tests/test_config_validation_edges.py b/tests/test_config_validation_edges.py index 9609a7e..4929ab2 100644 --- a/tests/test_config_validation_edges.py +++ b/tests/test_config_validation_edges.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -399,9 +398,9 @@ def test_config_validate_warns_when_jwt_secret_key_set(monkeypatch): with monkeypatch.context() as ctx: for key, value in _valid_dev_env().items(): ctx.setenv(key, value) - ctx.setenv("JWT_SECRET_KEY", "legacy-secret") + ctx.setenv("JWT_SECRET_KEY", "shared-secret") module = _reload_config_module() - assert module.config.jwt_secret_key == "legacy-secret" + assert module.config.jwt_secret_key == "shared-secret" def test_config_init_raises_when_vault_required_and_load_fails(monkeypatch): diff --git a/tests/test_group_share_revocation.py b/tests/test_group_share_revocation.py index eb32385..11b5398 100644 --- a/tests/test_group_share_revocation.py +++ b/tests/test_group_share_revocation.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import json diff --git a/tests/test_helper_surfaces.py b/tests/test_helper_surfaces.py index 3351d9b..72a708d 100644 --- a/tests/test_helper_surfaces.py +++ b/tests/test_helper_surfaces.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/tests/test_incident_aggregation.py b/tests/test_incident_aggregation.py index fd64699..4f87038 100644 --- a/tests/test_incident_aggregation.py +++ b/tests/test_incident_aggregation.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import json diff --git a/tests/test_incident_helpers_and_serializers.py b/tests/test_incident_helpers_and_serializers.py index 69687d4..0153da5 100644 --- a/tests/test_incident_helpers_and_serializers.py +++ b/tests/test_incident_helpers_and_serializers.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/tests/test_incidents_recipient_email.py b/tests/test_incidents_recipient_email.py index 538fe01..57efbf4 100644 --- a/tests/test_incidents_recipient_email.py +++ b/tests/test_incidents_recipient_email.py @@ -1,5 +1,8 @@ """ -Focused tests for incident assignee email normalization helper. +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/tests/test_incidents_router.py b/tests/test_incidents_router.py index 25f1152..5243705 100644 --- a/tests/test_incidents_router.py +++ b/tests/test_incidents_router.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import pytest @@ -94,9 +93,10 @@ async def test_patch_incident_sends_assignment_email(monkeypatch): assert result.assignee == "bob@example.com" assert len(background_tasks.tasks) == 1 task = background_tasks.tasks[0] - assert task.kwargs["recipient_email"] == "bob@example.com" - assert task.kwargs["incident_title"] == "Alert1" - assert str(task.kwargs["incident_severity"]) == "critical" + payload = task.args[0] + assert payload.recipient_email == "bob@example.com" + assert payload.incident_title == "Alert1" + assert str(payload.incident_severity) == "critical" @pytest.mark.asyncio @@ -150,17 +150,16 @@ def fake_update_incident(*args, **kwargs): payload = AlertIncidentUpdateRequest() await update_incident("i2", payload, background_tasks=BackgroundTasks(), current_user=user) - existing_write_access = ( - captured["existing_kwargs"].get("write_access") - if "write_access" in captured["existing_kwargs"] - else captured["existing_args"][4] - ) + existing_context = captured["existing_kwargs"].get("context") or captured["existing_args"][2] + existing_write_access = getattr(existing_context, "require_write", False) assert existing_write_access is True - actor = captured["update_kwargs"].get("actor") - assert actor is not None - update_group_ids = getattr(actor, "group_ids", None) - assert update_group_ids == ["g1"] + assert captured["update_kwargs"] == {} + assert captured["update_args"][0] == "i2" + assert captured["update_args"][1] == "t1" + assert captured["update_args"][2] == "u1" + assert captured["update_args"][4] == ["g1"] + assert captured["update_args"][5] is None @pytest.mark.asyncio diff --git a/tests/test_integration_security_and_jira_service.py b/tests/test_integration_security_and_jira_service.py index 1b456e1..2c05ecb 100644 --- a/tests/test_integration_security_and_jira_service.py +++ b/tests/test_integration_security_and_jira_service.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -26,7 +25,7 @@ from models.access.auth_models import Role, TokenData from services.alerting import integration_security_service as sec_mod -from services.jira_service import JiraError, JiraService, _extract_display_name +from services.jira_service import JiraError, JiraIssueCreateRequest, JiraRequest, JiraService, _extract_display_name class FakeQuery: @@ -76,7 +75,7 @@ def __init__(self, db): def __enter__(self): return self.db - def __exit__(self, exc_type, exc, tb): + def __exit__(self, *args): return False @@ -140,7 +139,7 @@ def test_tenant_resolution_and_inference(monkeypatch): assert sec_mod._alert_label_value({"a": "1", "b": "2"}, "x", "b") == "2" - monkeypatch.setattr(sec_mod, "tenant_id_from_scope_header", lambda header: "base-tenant") + monkeypatch.setattr(sec_mod, "tenant_id_from_scope_header", lambda *args, **kwargs: "base-tenant") assert sec_mod.infer_tenant_id_from_alerts("explicit", []) == "base-tenant" db = FakeDB([("tenant-a",)], [("tenant-b",), ("tenant-c",)]) @@ -193,7 +192,14 @@ def test_secret_storage_config_and_visibility_helpers(monkeypatch): db = FakeDB(tenant) monkeypatch.setattr(sec_mod, "get_db_session", lambda: FakeCtx(db)) result = sec_mod.save_tenant_jira_config( - "tenant-a", enabled=True, base_url="https://jira", email="a@b.c", api_token="secret", bearer=None + "tenant-a", + sec_mod.JiraTenantConfigUpdate( + enabled=True, + base_url="https://jira", + email="a@b.c", + api_token="secret", + bearer=None, + ), ) assert result["hasApiToken"] is True assert db.flushed == 1 @@ -202,7 +208,14 @@ def test_secret_storage_config_and_visibility_helpers(monkeypatch): monkeypatch.setattr(sec_mod, "get_db_session", lambda: FakeCtx(db)) with pytest.raises(HTTPException): sec_mod.save_tenant_jira_config( - "missing", enabled=False, base_url=None, email=None, api_token=None, bearer=None + "missing", + sec_mod.JiraTenantConfigUpdate( + enabled=False, + base_url=None, + email=None, + api_token=None, + bearer=None, + ), ) monkeypatch.setattr( @@ -388,7 +401,12 @@ async def post(self, url, json=None, headers=None): ) ) == {"key": "OPS-1"} created = await service.create_issue( - "OPS", "Summary", "Desc", credentials={"base_url": "https://jira", "authMode": "bearer", "bearer": "abc"} + JiraIssueCreateRequest( + project_key="OPS", + summary="Summary", + options="Desc", + credentials={"base_url": "https://jira", "authMode": "bearer", "bearer": "abc"}, + ) ) assert created["key"] == "OPS-1" @@ -442,12 +460,14 @@ async def post(self, url, json=None, headers=None): service._client = ErrClient() with pytest.raises(JiraError): await service._request( - "GET", "/rest/api/2/project", {"base_url": "https://jira", "authMode": "bearer", "bearer": "abc"} + JiraRequest("GET", "/rest/api/2/project", {"base_url": "https://jira", "authMode": "bearer", "bearer": "abc"}) ) with pytest.raises(JiraError): await service._request( - "POST", - "/rest/api/2/project", - {"base_url": "https://jira", "authMode": "bearer", "bearer": "abc"}, - payload={}, + JiraRequest( + "POST", + "/rest/api/2/project", + {"base_url": "https://jira", "authMode": "bearer", "bearer": "abc"}, + payload={}, + ) ) diff --git a/tests/test_integration_security_service.py b/tests/test_integration_security_service.py index 54aa2ad..b5efb5b 100644 --- a/tests/test_integration_security_service.py +++ b/tests/test_integration_security_service.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ import unittest @@ -54,7 +53,7 @@ def __init__(self, db): def __enter__(self): return self._db - def __exit__(self, exc_type, exc, tb): + def __exit__(self, *args): return False def test_normalize_visibility_maps_public_to_tenant(self): diff --git a/tests/test_jira_helpers_edges.py b/tests/test_jira_helpers_edges.py index fe9afe9..d8d5f93 100644 --- a/tests/test_jira_helpers_edges.py +++ b/tests/test_jira_helpers_edges.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/tests/test_main_entrypoint.py b/tests/test_main_entrypoint.py index 1accd45..dc951d5 100644 --- a/tests/test_main_entrypoint.py +++ b/tests/test_main_entrypoint.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/tests/test_main_startup_edges.py b/tests/test_main_startup_edges.py new file mode 100644 index 0000000..dbbd22c --- /dev/null +++ b/tests/test_main_startup_edges.py @@ -0,0 +1,138 @@ +""" +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +""" + +from __future__ import annotations + +import importlib +import os +import sys +from pathlib import Path + +import pytest +from cryptography.fernet import Fernet +from sqlalchemy.exc import SQLAlchemyError + +SERVICE_ROOT = Path(__file__).resolve().parents[1] +if str(SERVICE_ROOT) not in sys.path: + sys.path.insert(0, str(SERVICE_ROOT)) + +os.environ["DATABASE_URL"] = "postgresql://safeuser:safePass_123@db:5432/notifier" +os.environ["NOTIFIER_DATABASE_URL"] = "postgresql://safeuser:safePass_123@db:5432/notifier" +os.environ["HOST"] = "127.0.0.1" +os.environ["PORT"] = "4319" +os.environ["LOG_LEVEL"] = "info" +os.environ["ENABLE_API_DOCS"] = "true" +os.environ["CORS_ORIGINS"] = "http://localhost:5173" +os.environ["CORS_ALLOW_CREDENTIALS"] = "true" +os.environ["JWT_ALGORITHM"] = "RS256" +os.environ["JWT_AUTO_GENERATE_KEYS"] = "true" +os.environ["DATA_ENCRYPTION_KEY"] = Fernet.generate_key().decode("utf-8") + + +def _load_main() -> object: + if "main" in sys.modules: + del sys.modules["main"] + return importlib.import_module("main") + + +@pytest.mark.asyncio +async def test_startup_database_wraps_bootstrap_in_to_thread(monkeypatch): + main_module = _load_main() + calls: list[str] = [] + + async def fake_to_thread(func): + calls.append("to_thread") + return func() + + monkeypatch.setattr(main_module.asyncio, "to_thread", fake_to_thread) + monkeypatch.setattr(main_module, "_bootstrap_database", lambda: calls.append("bootstrap")) + + await main_module._startup_database() + + assert calls == ["to_thread", "bootstrap"] + + +def test_bootstrap_database_retries_then_succeeds(monkeypatch): + main_module = _load_main() + warnings: list[tuple[str, tuple[object, ...]]] = [] + calls: list[object] = [] + attempts = iter([0.0, 0.1]) + failure = SQLAlchemyError("database unavailable") + + monkeypatch.setenv("DATABASE_STARTUP_TIMEOUT", "30") + monkeypatch.setenv("DATABASE_STARTUP_RETRY_DELAY", "0") + monkeypatch.setattr(main_module.time, "monotonic", lambda: next(attempts)) + monkeypatch.setattr(main_module.time, "sleep", lambda seconds: calls.append(("sleep", seconds))) + monkeypatch.setattr( + main_module.logger, + "warning", + lambda message, *args: warnings.append((message, args)), + ) + + state = {"attempt": 0} + + def ensure_database_exists(database_url): + calls.append(("ensure", database_url)) + state["attempt"] += 1 + if state["attempt"] == 1: + raise failure + + def init_database(database_url, echo): + calls.append(("init", database_url, echo)) + + def init_db(): + calls.append("init_db") + + monkeypatch.setattr(main_module.database_module, "ensure_database_exists", ensure_database_exists) + monkeypatch.setattr(main_module.database_module, "init_database", init_database) + monkeypatch.setattr(main_module.database_module, "init_db", init_db) + + main_module._bootstrap_database() + + assert calls == [ + ("ensure", main_module.config.notifier_database_url), + ("sleep", 0.0), + ("ensure", main_module.config.notifier_database_url), + ("init", main_module.config.notifier_database_url, main_module.config.log_level == "debug"), + "init_db", + ] + assert warnings and warnings[0][0] == "Notifier database not ready (attempt %d, retrying in %.1fs): %s" + assert isinstance(warnings[0][1][2], SQLAlchemyError) + + +def test_bootstrap_database_times_out_on_sqlalchemy_error(monkeypatch): + main_module = _load_main() + attempts = iter([0.0, 1.0]) + + monkeypatch.setenv("DATABASE_STARTUP_TIMEOUT", "0") + monkeypatch.setenv("DATABASE_STARTUP_RETRY_DELAY", "0") + monkeypatch.setattr(main_module.time, "monotonic", lambda: next(attempts)) + monkeypatch.setattr(main_module.time, "sleep", lambda seconds: None) + monkeypatch.setattr( + main_module.database_module, + "ensure_database_exists", + lambda database_url: (_ for _ in ()).throw(SQLAlchemyError("still down")), + ) + monkeypatch.setattr(main_module.database_module, "init_database", lambda *args, **kwargs: None) + monkeypatch.setattr(main_module.database_module, "init_db", lambda: None) + + with pytest.raises(RuntimeError, match="Notifier database did not become ready before startup timeout") as exc_info: + main_module._bootstrap_database() + + assert isinstance(exc_info.value.__cause__, SQLAlchemyError) + + +@pytest.mark.parametrize("env_name", ["DATABASE_STARTUP_TIMEOUT", "DATABASE_STARTUP_RETRY_DELAY"]) +def test_bootstrap_database_rejects_invalid_float_env(monkeypatch, env_name): + main_module = _load_main() + monkeypatch.setenv("DATABASE_STARTUP_TIMEOUT", "30") + monkeypatch.setenv("DATABASE_STARTUP_RETRY_DELAY", "2") + monkeypatch.setenv(env_name, "not-a-number") + + with pytest.raises(RuntimeError, match=rf"Invalid value for {env_name}: 'not-a-number'"): + main_module._bootstrap_database() \ No newline at end of file diff --git a/tests/test_middleware_core.py b/tests/test_middleware_core.py index 9b70781..691e445 100644 --- a/tests/test_middleware_core.py +++ b/tests/test_middleware_core.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -26,6 +25,7 @@ general_exception_handler, handle_route_errors, http_exception_handler, + RouteErrorResponse, validation_exception_handler, ) from middleware.headers import _is_https_request, security_headers_middleware @@ -53,11 +53,11 @@ async def test_handle_route_errors_variants(): async def bad_request() -> str: raise ValueError("ignored") - @handle_route_errors(bad_gateway_detail="upstream") + @handle_route_errors(bad_gateway=RouteErrorResponse(detail="upstream", status_code=502)) async def bad_gateway() -> str: raise httpx.ReadError("boom") - @handle_route_errors(internal_detail=None) + @handle_route_errors(internal=RouteErrorResponse(detail=None, status_code=500)) async def raw_internal() -> str: raise RuntimeError("raw") diff --git a/tests/test_middleware_rate_limit_and_database_edges.py b/tests/test_middleware_rate_limit_and_database_edges.py index d2631b3..66e57d2 100644 --- a/tests/test_middleware_rate_limit_and_database_edges.py +++ b/tests/test_middleware_rate_limit_and_database_edges.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -152,7 +151,10 @@ def test_dependencies_public_allowlist_edges(monkeypatch): monkeypatch.setattr(config, "require_client_ip_for_public_endpoints", True) monkeypatch.setattr(dependencies, "client_ip", lambda _req: "unknown") with pytest.raises(HTTPException) as exc: - dependencies.enforce_public_endpoint_security(_request(), scope="public", limit=1, window_seconds=60) + dependencies.enforce_public_endpoint_security( + _request(), + dependencies.PublicEndpointSecurityConfig(scope="public", limit=1, window_seconds=60), + ) assert exc.value.status_code == 403 monkeypatch.setattr(config, "require_client_ip_for_public_endpoints", False) @@ -298,7 +300,6 @@ def hit(self, *_args, **_kwargs): assert deny.allowed is False and allow.allowed is True assert events - # Keep this assertion deterministic even if prior tests initialized a live engine. old_engine = db_mod._ENGINE db_mod._ENGINE = None try: @@ -316,7 +317,7 @@ def execution_options(self, **_kwargs): def __enter__(self): return self - def __exit__(self, exc_type, exc, tb): + def __exit__(self, *args): return False def execute(self, *_args, **_kwargs): diff --git a/tests/test_middleware_rate_limit_database_resilience_edges.py b/tests/test_middleware_rate_limit_database_resilience_edges.py index 62a9537..be2cf77 100644 --- a/tests/test_middleware_rate_limit_database_resilience_edges.py +++ b/tests/test_middleware_rate_limit_database_resilience_edges.py @@ -2,13 +2,13 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations import asyncio +import os from datetime import UTC, datetime, timedelta from types import SimpleNamespace from typing import Any, cast @@ -97,7 +97,7 @@ def execution_options(self, **_kwargs): def __enter__(self): return self - def __exit__(self, exc_type, exc, tb): + def __exit__(self, *args): return False def execute(self, stmt, params=None): @@ -122,81 +122,89 @@ def dispose(self): def test_database_module_paths(monkeypatch): - with pytest.raises(RuntimeError): - db_mod.ensure_database_exists("sqlite://") - - class _Url: - database = "appdb" - - def set(self, **_kwargs): - return "postgres-admin" - - fake_admin_conn = _FakeConn(exists=False) - fake_admin_engine = _FakeEngine(fake_admin_conn) - monkeypatch.setattr(db_mod, "make_url", lambda _u: _Url()) - monkeypatch.setattr(db_mod, "create_engine", lambda *_args, **_kwargs: fake_admin_engine) - db_mod.ensure_database_exists("postgresql://user:pass@localhost:5432/appdb") - assert fake_admin_engine.disposed == 1 - assert any("CREATE DATABASE" in str(stmt) for stmt, _ in fake_admin_conn.executed) - - db_mod.dispose_database() - engine = _FakeEngine(_FakeConn()) - monkeypatch.setattr(db_mod, "create_engine", lambda *_args, **_kwargs: engine) - monkeypatch.setattr(db_mod, "sessionmaker", lambda **_kwargs: lambda: _FakeSession()) - db_mod.init_database("postgresql://user:pass@localhost:5432/appdb") - db_mod.init_database("postgresql://user:pass@localhost:5432/appdb") - - with db_mod.get_db_session() as session: + active_database_url = os.environ.get("NOTIFIER_DATABASE_URL", os.environ.get("DATABASE_URL", "")) + try: + assert db_mod.ensure_database_exists("sqlite://") is None + + class _Url: + database = "appdb" + + def set(self, **_kwargs): + return "postgres-admin" + + fake_admin_conn = _FakeConn(exists=False) + fake_admin_engine = _FakeEngine(fake_admin_conn) + monkeypatch.setattr(db_mod, "make_url", lambda _u: _Url()) + monkeypatch.setattr(db_mod, "create_engine", lambda *_args, **_kwargs: fake_admin_engine) + db_mod.ensure_database_exists("postgresql://user:pass@localhost:5432/appdb") + assert fake_admin_engine.disposed == 1 + assert any("CREATE DATABASE" in str(stmt) for stmt, _ in fake_admin_conn.executed) + + db_mod.dispose_database() + engine = _FakeEngine(_FakeConn()) + monkeypatch.setattr(db_mod, "create_engine", lambda *_args, **_kwargs: engine) + monkeypatch.setattr(db_mod, "sessionmaker", lambda **_kwargs: lambda: _FakeSession()) + db_mod.init_database("postgresql://user:pass@localhost:5432/appdb") + db_mod.init_database("postgresql://user:pass@localhost:5432/appdb") + + with db_mod.get_db_session() as session: + assert isinstance(session, _FakeSession) + + with pytest.raises(RuntimeError), db_mod.get_db_session() as _session: + raise RuntimeError("boom") + + gen = db_mod.get_db() + session = next(gen) assert isinstance(session, _FakeSession) - - with pytest.raises(RuntimeError), db_mod.get_db_session() as _session: - raise RuntimeError("boom") - - gen = db_mod.get_db() - session = next(gen) - assert isinstance(session, _FakeSession) - with pytest.raises(StopIteration): - next(gen) - - gen = db_mod.get_db() - _ = next(gen) - with pytest.raises(RuntimeError): - gen.throw(RuntimeError("fail")) - - assert db_mod.connection_test() is True - db_mod._ENGINE = _FakeEngine(_FakeConn(execute_error=SQLAlchemyError("db"))) - assert db_mod.connection_test() is False - - db_mod.dispose_database() - assert db_mod._ENGINE is None - - # Cover guard paths where one side of initialization is missing. - db_mod._ENGINE = object() - db_mod._SESSION_LOCAL = None - with pytest.raises(RuntimeError), db_mod.get_db_session(): - pass - - db_mod._ENGINE = object() - db_mod._SESSION_LOCAL = None - with pytest.raises(RuntimeError): - next(db_mod.get_db()) - - with pytest.raises(RuntimeError): + with pytest.raises(StopIteration): + next(gen) + + gen = db_mod.get_db() + _ = next(gen) + with pytest.raises(RuntimeError): + gen.throw(RuntimeError("fail")) + + assert db_mod.connection_test() is True + db_mod._ENGINE = _FakeEngine(_FakeConn(execute_error=SQLAlchemyError("db"))) + assert db_mod.connection_test() is False + + db_mod.dispose_database() + assert db_mod._ENGINE is None + db_mod.dispose_database() + + # Cover guard paths where one side of initialization is missing. + db_mod._ENGINE = object() + db_mod._SESSION_LOCAL = None + with pytest.raises(RuntimeError), db_mod.get_db_session(): + pass + + db_mod._ENGINE = object() + db_mod._SESSION_LOCAL = None + with pytest.raises(RuntimeError): + next(db_mod.get_db()) + + with pytest.raises(RuntimeError): + db_mod.init_db() + + class _Meta: + def __init__(self): + self.called = 0 + + def create_all(self, bind=None): + self.called += 1 + assert bind is engine + + meta = _Meta() + monkeypatch.setattr(db_mod.Base, "metadata", meta) + db_mod._ENGINE = engine db_mod.init_db() - - class _Meta: - def __init__(self): - self.called = 0 - - def create_all(self, bind=None): - self.called += 1 - assert bind is engine - - meta = _Meta() - monkeypatch.setattr(db_mod.Base, "metadata", meta) - db_mod._ENGINE = engine - db_mod.init_db() - assert meta.called == 1 + assert meta.called == 1 + finally: + monkeypatch.undo() + db_mod.dispose_database() + if active_database_url.startswith("sqlite"): + db_mod.init_database(active_database_url) + db_mod.init_db() def test_in_memory_and_hybrid_rate_limiters(monkeypatch): @@ -424,7 +432,10 @@ def test_dependencies_helpers_and_allowlist_paths(monkeypatch): monkeypatch.setattr(config, "require_client_ip_for_public_endpoints", True) monkeypatch.setattr(dependencies, "client_ip", lambda _request: "unknown") with pytest.raises(HTTPException): - dependencies.enforce_public_endpoint_security(_request(), scope="public", limit=10, window_seconds=60) + dependencies.enforce_public_endpoint_security( + _request(), + dependencies.PublicEndpointSecurityConfig(scope="public", limit=10, window_seconds=60), + ) monkeypatch.setattr(config, "require_client_ip_for_public_endpoints", False) monkeypatch.setattr(dependencies, "enforce_ip_rate_limit", lambda *_args, **_kwargs: None) @@ -433,16 +444,34 @@ def test_dependencies_helpers_and_allowlist_paths(monkeypatch): with pytest.raises(HTTPException): dependencies.enforce_public_endpoint_security( - _request(), scope="public", limit=10, window_seconds=60, allowlist="203.0.113.0/24,bad-cidr/" + _request(), + dependencies.PublicEndpointSecurityConfig( + scope="public", + limit=10, + window_seconds=60, + allowlist="203.0.113.0/24,bad-cidr/", + ), ) with pytest.raises(HTTPException): dependencies.enforce_public_endpoint_security( - _request("198.51.100.1"), scope="public", limit=10, window_seconds=60, allowlist="203.0.113.0/24" + _request("198.51.100.1"), + dependencies.PublicEndpointSecurityConfig( + scope="public", + limit=10, + window_seconds=60, + allowlist="203.0.113.0/24", + ), ) dependencies.enforce_public_endpoint_security( - _request("203.0.113.10"), scope="public", limit=10, window_seconds=60, allowlist="203.0.113.0/24" + _request("203.0.113.10"), + dependencies.PublicEndpointSecurityConfig( + scope="public", + limit=10, + window_seconds=60, + allowlist="203.0.113.0/24", + ), ) monkeypatch.setattr(config, "allowlist_fail_open", True) diff --git a/tests/test_notification_and_helper_edges_more.py b/tests/test_notification_and_helper_edges_more.py index 1a87d05..93949de 100644 --- a/tests/test_notification_and_helper_edges_more.py +++ b/tests/test_notification_and_helper_edges_more.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -264,7 +263,9 @@ async def test_email_providers_and_payload_helpers(monkeypatch): with pytest.raises(ValueError, match="No valid recipient"): email_providers._sanitize_recipients(["bad", " "]) - msg = email_providers.build_smtp_message("Subject", "Body", "from@example.com", [" ops@example.com "]) + msg = email_providers.build_smtp_message( + email_providers.EmailDeliveryPayload("Subject", "Body", [" ops@example.com "], "from@example.com") + ) assert msg["To"] == "ops@example.com" request = httpx.Request("POST", "https://example.com") @@ -282,21 +283,38 @@ async def raise_unexpected(*args, **kwargs): monkeypatch.setattr(email_providers.transport, "post_with_retry", raise_http_status) assert ( await email_providers.send_via_sendgrid( - SimpleNamespace(), "key", "subj", "body", ["ops@example.com"], "from@example.com" + SimpleNamespace(), + "key", + email_providers.EmailDeliveryPayload("subj", "body", ["ops@example.com"], "from@example.com"), ) is False ) monkeypatch.setattr(email_providers.transport, "post_with_retry", raise_http_error) assert ( await email_providers.send_via_resend( - SimpleNamespace(), "key", "subj", "body", ["ops@example.com"], "from@example.com" + SimpleNamespace(), + "key", + email_providers.EmailDeliveryPayload("subj", "body", ["ops@example.com"], "from@example.com"), ) is False ) monkeypatch.setattr(email_providers.transport, "send_smtp_with_retry", raise_unexpected) - assert await email_providers.send_via_smtp(msg, "smtp.example.com", 587, None, None, False, False) is False + assert await email_providers.send_via_smtp( + msg, + smtp=transport.SmtpDeliveryConfig(hostname="smtp.example.com", port=587), + ) is False with pytest.raises(ValueError, match="without TLS"): - await email_providers.send_via_smtp(msg, "smtp.example.com", 25, "user", "pass", False, False) + await email_providers.send_via_smtp( + msg, + smtp=transport.SmtpDeliveryConfig( + hostname="smtp.example.com", + port=25, + username="user", + password="pass", + start_tls=False, + use_tls=False, + ), + ) alert = _alert() assert payloads._status_text("test") == "TEST" @@ -509,12 +527,15 @@ class _SmtpPermanent(Exception): assert transport._is_transient_smtp(smtp_transient) is True assert transport._is_transient_smtp(smtp_permanent) is False - async def fail_send(**kwargs): + async def fail_send(*_args, **_kwargs): raise RuntimeError("smtp down") monkeypatch.setattr(transport.aiosmtplib, "send", fail_send) with pytest.raises(RuntimeError, match="smtp down"): - await transport.send_smtp_with_retry(SimpleNamespace(), "smtp.example.com", 25) + await transport.send_smtp_with_retry( + SimpleNamespace(), + smtp=transport.SmtpDeliveryConfig(hostname="smtp.example.com", port=25), + ) def test_rule_import_ruler_and_rules_ops_more_edges(): @@ -679,14 +700,18 @@ async def raise_status_error(*args, **kwargs): monkeypatch.setattr(email_providers.transport, "post_with_retry", raise_request_error) assert ( await email_providers.send_via_sendgrid( - SimpleNamespace(), "key", "subj", "body", ["ops@example.com"], "from@example.com" + SimpleNamespace(), + "key", + email_providers.EmailDeliveryPayload("subj", "body", ["ops@example.com"], "from@example.com"), ) is False ) monkeypatch.setattr(email_providers.transport, "post_with_retry", raise_status_error) assert ( await email_providers.send_via_resend( - SimpleNamespace(), "key", "subj", "body", ["ops@example.com"], "from@example.com" + SimpleNamespace(), + "key", + email_providers.EmailDeliveryPayload("subj", "body", ["ops@example.com"], "from@example.com"), ) is False ) @@ -694,10 +719,15 @@ async def raise_status_error(*args, **kwargs): async def smtp_os_error(*args, **kwargs): raise OSError("smtp down") - msg = email_providers.build_smtp_message("Subject", "Body", "from@example.com", ["ops@example.com"]) + msg = email_providers.build_smtp_message( + email_providers.EmailDeliveryPayload("Subject", "Body", ["ops@example.com"], "from@example.com") + ) original_send_smtp_with_retry = transport.send_smtp_with_retry monkeypatch.setattr(email_providers.transport, "send_smtp_with_retry", smtp_os_error) - assert await email_providers.send_via_smtp(msg, "smtp.example.com", 587, None, None, True, False) is False + assert await email_providers.send_via_smtp( + msg, + smtp=transport.SmtpDeliveryConfig(hostname="smtp.example.com", port=587, start_tls=True), + ) is False monkeypatch.setattr(transport, "send_smtp_with_retry", original_send_smtp_with_retry) assert senders._is_allowed_host(None, allowed_hosts=senders.SLACK_ALLOWED_HOSTS) is False # type: ignore[arg-type] @@ -739,7 +769,10 @@ async def smtp_type_error(*args, **kwargs): monkeypatch.setattr(transport.aiosmtplib, "send", smtp_type_error) with pytest.raises(TypeError, match="unexpected type"): - await transport.send_smtp_with_retry(SimpleNamespace(), "smtp.example.com", 25) + await transport.send_smtp_with_retry( + SimpleNamespace(), + smtp=transport.SmtpDeliveryConfig(hostname="smtp.example.com", port=25), + ) def test_payloads_remaining_line_paths(): diff --git a/tests/test_notification_email_providers.py b/tests/test_notification_email_providers.py index 35a359b..061fabc 100644 --- a/tests/test_notification_email_providers.py +++ b/tests/test_notification_email_providers.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ try: @@ -23,7 +22,7 @@ def test_build_smtp_message(): msg = email_providers.build_smtp_message( - "subj", "body", "from@example.com", ["a@b.com", "c@d.com"], "body" + email_providers.EmailDeliveryPayload("subj", "body", ["a@b.com", "c@d.com"], "from@example.com", "body") ) assert isinstance(msg, EmailMessage) assert msg["Subject"] == "subj" @@ -32,42 +31,43 @@ def test_build_smtp_message(): def test_send_via_sendgrid_and_resend_success_and_failure(monkeypatch): - async def ok_post(client, url, json=None, headers=None, params=None, **kwargs): + async def ok_post(_request): return httpx.Response(202) - async def fail_post(client, url, json=None, headers=None, params=None, **kwargs): + async def fail_post(_request): raise Exception("boom") monkeypatch.setattr(transport, "post_with_retry", ok_post) client = httpx.AsyncClient() - assert asyncio.run(email_providers.send_via_sendgrid(client, "key", "s", "b", ["x@x"], "from@f")) is True - assert asyncio.run(email_providers.send_via_resend(client, "key", "s", "b", ["x@x"], "from@f")) is True + payload = email_providers.EmailDeliveryPayload("s", "b", ["x@x"], "from@f") + assert asyncio.run(email_providers.send_via_sendgrid(client, "key", payload)) is True + assert asyncio.run(email_providers.send_via_resend(client, "key", payload)) is True monkeypatch.setattr(transport, "post_with_retry", fail_post) - assert asyncio.run(email_providers.send_via_sendgrid(client, "key", "s", "b", ["x@x"], "from@f")) is False - assert asyncio.run(email_providers.send_via_resend(client, "key", "s", "b", ["x@x"], "from@f")) is False + assert asyncio.run(email_providers.send_via_sendgrid(client, "key", payload)) is False + assert asyncio.run(email_providers.send_via_resend(client, "key", payload)) is False def test_send_via_smtp_calls_transport(monkeypatch): - async def fake_send(message, hostname, port, username=None, password=None, start_tls=False, use_tls=False): + async def fake_send(message, smtp): return True monkeypatch.setattr(transport, "send_smtp_with_retry", fake_send) - # updated helper no longer accepts a timeout parameter - assert asyncio.run(email_providers.send_via_smtp("m", "h", 25, None, None, False, False)) is True + smtp = transport.SmtpDeliveryConfig(hostname="h", port=25) + assert asyncio.run(email_providers.send_via_smtp("m", smtp=smtp)) is True async def fake_send_err(*args, **kwargs): raise Exception("fail") monkeypatch.setattr(transport, "send_smtp_with_retry", fake_send_err) - assert asyncio.run(email_providers.send_via_smtp("m", "h", 25, None, None, False, False)) is False + assert asyncio.run(email_providers.send_via_smtp("m", smtp=smtp)) is False def test_sendgrid_and_resend_include_html_payload_when_provided(monkeypatch): captured = {} - async def capture_post(client, url, json=None, headers=None, params=None, **kwargs): - captured[url] = json + async def capture_post(request): + captured[str(request.url)] = request.json return httpx.Response(202) monkeypatch.setattr(transport, "post_with_retry", capture_post) @@ -78,11 +78,13 @@ async def capture_post(client, url, json=None, headers=None, params=None, **kwar email_providers.send_via_sendgrid( client, "key", - "subject", - "body", - ["x@x.com"], - "from@example.com", - "html", + email_providers.EmailDeliveryPayload( + subject="subject", + body="body", + recipients=["x@x.com"], + smtp_from="from@example.com", + html_body="html", + ), ) ) is True @@ -94,11 +96,13 @@ async def capture_post(client, url, json=None, headers=None, params=None, **kwar email_providers.send_via_resend( client, "key", - "subject", - "body", - ["x@x.com"], - "from@example.com", - "html", + email_providers.EmailDeliveryPayload( + subject="subject", + body="body", + recipients=["x@x.com"], + smtp_from="from@example.com", + html_body="html", + ), ) ) is True @@ -114,10 +118,5 @@ def test_coerce_email_delivery_payload_accepts_payload_object(): smtp_from="from@example.com", html_body="
hello
", ) - result = email_providers._coerce_email_delivery_payload(payload, ()) + result = email_providers._coerce_email_delivery_payload(payload) assert result is payload - - -def test_coerce_email_delivery_payload_requires_legacy_arguments(): - with pytest.raises(ValueError, match="subject, body, recipients, and smtp_from are required"): - email_providers._coerce_email_delivery_payload(None, ("subj", "body", ["a@example.com"])) diff --git a/tests/test_notification_payloads.py b/tests/test_notification_payloads.py index 7a4668d..688d1e8 100644 --- a/tests/test_notification_payloads.py +++ b/tests/test_notification_payloads.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ try: @@ -125,7 +124,7 @@ def test_test_action_is_rendered_as_test_status(): def test_email_html_template_uses_severity_colors_and_test_green(): critical_alert = _make_alert(labels={"alertname": "DiskFull", "severity": "critical"}) critical_html = notification_payloads.format_alert_html(critical_alert, "firing") - assert "WATCHDOG ALERT" in critical_html + assert "WATCHDOG ALERT" in critical_html or "[FIRING] DiskFull" in critical_html assert "#dc2626" in critical_html test_alert = _make_alert(labels={"alertname": "DiskFull", "severity": "critical"}) @@ -147,7 +146,7 @@ def test_render_email_template_returns_none_when_file_missing(monkeypatch): monkeypatch.setattr( notification_payloads.Path, "read_text", - lambda self, encoding="utf-8": (_ for _ in ()).throw(OSError("missing")), + lambda *args, **kwargs: (_ for _ in ()).throw(OSError("missing")), ) assert notification_payloads._render_email_template("missing.html", {"name": "x"}) is None diff --git a/tests/test_notification_senders.py b/tests/test_notification_senders.py index d40c001..9bb345c 100644 --- a/tests/test_notification_senders.py +++ b/tests/test_notification_senders.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ try: @@ -33,9 +32,9 @@ def _make_alert(): def test_send_slack_calls_transport(monkeypatch): called = {} - async def fake_post(client, url, json=None, headers=None, params=None): - called["url"] = url - called["json"] = json + async def fake_post(request): + called["url"] = str(request.url) + called["json"] = request.json return httpx.Response(200) monkeypatch.setattr(transport, "post_with_retry", fake_post) @@ -56,8 +55,8 @@ def test_send_slack_invalid_url_returns_false(): def test_send_webhook_and_pagerduty(monkeypatch): calls = [] - async def fake_post(client, url, json=None, headers=None, params=None): - calls.append((url, json, headers)) + async def fake_post(request): + calls.append((str(request.url), request.json, request.headers)) return httpx.Response(200) monkeypatch.setattr(transport, "post_with_retry", fake_post) diff --git a/tests/test_notification_senders_error_handling.py b/tests/test_notification_senders_error_handling.py index 8fcb41c..9c94d39 100644 --- a/tests/test_notification_senders_error_handling.py +++ b/tests/test_notification_senders_error_handling.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ try: @@ -31,8 +30,8 @@ def _make_alert(): def test_send_webhook_handles_http_status_error(monkeypatch): - async def fake_post(client, url, json=None, headers=None, params=None): - req = httpx.Request("POST", url) + async def fake_post(request): + req = httpx.Request("POST", str(request.url)) resp = httpx.Response(405, request=req) raise httpx.HTTPStatusError("Client error", request=req, response=resp) @@ -44,7 +43,7 @@ async def fake_post(client, url, json=None, headers=None, params=None): def test_send_webhook_handles_request_error(monkeypatch): - async def fake_post(client, url, json=None, headers=None, params=None): + async def fake_post(request): raise httpx.RequestError("network down") monkeypatch.setattr(transport, "post_with_retry", fake_post) diff --git a/tests/test_notification_service.py b/tests/test_notification_service.py index 396e05f..de6d67c 100644 --- a/tests/test_notification_service.py +++ b/tests/test_notification_service.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ try: @@ -72,8 +71,8 @@ async def fake_send_via_resend(*args, **kwargs): called["rs"] = (api_key, recipients) return True - async def fake_send_via_smtp(message, hostname, port, username, password, start_tls, use_tls): - called["smtp"] = (hostname, port) + async def fake_send_via_smtp(message, smtp=None, **_kwargs): + called["smtp"] = (smtp.hostname, smtp.port) return True monkeypatch.setattr(notification_email, "send_via_sendgrid", fake_send_via_sendgrid) @@ -116,7 +115,7 @@ def fake_format(alert, action): monkeypatch.setattr(notification_payloads, "format_alert_body", fake_format) # avoid real email transport so test stays fast - async def fake_send_smtp(message, hostname, port, username=None, password=None, start_tls=False, use_tls=False): + async def fake_send_smtp(message, smtp=None, **_kwargs): return True monkeypatch.setattr(notification_email, "send_via_smtp", fake_send_smtp) @@ -135,16 +134,16 @@ async def fake_send_smtp(message, hostname, port, username=None, password=None, def test_send_email_uses_build_smtp_message(monkeypatch): captured = {} - def fake_build(subject, body, smtp_from, recipients, html_body=None): - captured["built"] = (subject, body, smtp_from, recipients, html_body) + def fake_build(payload): + captured["built"] = (payload.subject, payload.body, payload.smtp_from, payload.recipients, payload.html_body) from email.message import EmailMessage m = EmailMessage() - m["Subject"] = subject + m["Subject"] = payload.subject return m - async def fake_send_smtp(message, hostname, port, username=None, password=None, start_tls=False, use_tls=False): - captured["sent"] = (hostname, port) + async def fake_send_smtp(message, smtp=None, **_kwargs): + captured["sent"] = (smtp.hostname, smtp.port) return True monkeypatch.setattr(notification_email, "build_smtp_message", fake_build) diff --git a/tests/test_notification_service_and_encryption.py b/tests/test_notification_service_and_encryption.py index e263379..7d5c969 100644 --- a/tests/test_notification_service_and_encryption.py +++ b/tests/test_notification_service_and_encryption.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -24,6 +23,7 @@ from models.alerting.alerts import Alert, AlertState, AlertStatus from models.alerting.channels import ChannelType, NotificationChannel from services import notification_service as notif_mod +from services.notification_service import IncidentAssignmentEmail from services.common import encryption as enc_mod @@ -129,19 +129,26 @@ async def fake_send_smtp_with_retry(**kwargs): monkeypatch.setattr(service, "_send_smtp_with_retry", fake_send_smtp_with_retry) assert ( - await service.send_incident_assignment_email("user@example.com", "CPUHigh", "open", "critical", "alice") is True + await service.send_incident_assignment_email( + IncidentAssignmentEmail("user@example.com", "CPUHigh", "open", "critical", "alice") + ) + is True ) - assert smtp_calls[0]["port"] == 587 + assert smtp_calls[0]["smtp"].port == 587 secrets["INCIDENT_ASSIGNMENT_EMAIL_ENABLED"] = "false" assert ( - await service.send_incident_assignment_email("user@example.com", "CPUHigh", "open", "critical", "alice") + await service.send_incident_assignment_email( + IncidentAssignmentEmail("user@example.com", "CPUHigh", "open", "critical", "alice") + ) is False ) secrets["INCIDENT_ASSIGNMENT_EMAIL_ENABLED"] = "true" secrets["INCIDENT_ASSIGNMENT_SMTP_HOST"] = "" assert ( - await service.send_incident_assignment_email("user@example.com", "CPUHigh", "open", "critical", "alice") + await service.send_incident_assignment_email( + IncidentAssignmentEmail("user@example.com", "CPUHigh", "open", "critical", "alice") + ) is False ) secrets["INCIDENT_ASSIGNMENT_SMTP_HOST"] = "smtp.example.com" @@ -151,7 +158,9 @@ async def failing_send_smtp_with_retry(**kwargs): monkeypatch.setattr(service, "_send_smtp_with_retry", failing_send_smtp_with_retry) assert ( - await service.send_incident_assignment_email("user@example.com", "CPUHigh", "open", "critical", "alice") + await service.send_incident_assignment_email( + IncidentAssignmentEmail("user@example.com", "CPUHigh", "open", "critical", "alice") + ) is False ) @@ -169,12 +178,12 @@ async def test_notification_email_provider_paths(monkeypatch): monkeypatch.setattr( notif_mod.notification_email, "build_smtp_message", - lambda subject, body, from_addr, recipients, html_body=None: { - "subject": subject, - "body": body, - "from": from_addr, - "to": recipients, - "html": html_body, + lambda payload: { + "subject": payload.subject, + "body": payload.body, + "from": payload.smtp_from, + "to": payload.recipients, + "html": payload.html_body, }, ) @@ -202,8 +211,8 @@ async def fake_resend(client, api_key, *delivery_args, **_kwargs): resend_calls.append((api_key, recipients, from_addr)) return False - async def fake_smtp(message, host, port, user, password, starttls, use_ssl): - smtp_calls.append((message, host, port, user, password, starttls, use_ssl)) + async def fake_smtp(message, smtp=None, **_kwargs): + smtp_calls.append((message, smtp)) return True monkeypatch.setattr(notif_mod.notification_email, "send_via_sendgrid", fake_sendgrid) @@ -259,7 +268,9 @@ async def fake_smtp(message, host, port, user, password, starttls, use_ssl): } ) assert await service._send_email(smtp_channel, _alert(), "resolved") is True - assert smtp_calls[0][1:4] == ("smtp.example.com", 587, "apikey") + assert smtp_calls[0][1].hostname == "smtp.example.com" + assert smtp_calls[0][1].port == 587 + assert smtp_calls[0][1].username == "apikey" noauth_channel = _channel( config={"to": "ops@example.com", "smtp_host": "smtp.example.com", "smtp_port": 25, "smtp_auth_type": "none"} diff --git a/tests/test_notification_service_email_edges.py b/tests/test_notification_service_email_edges.py index 667f61f..c13dbd1 100644 --- a/tests/test_notification_service_email_edges.py +++ b/tests/test_notification_service_email_edges.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -21,6 +20,8 @@ from config import config from services import notification_service as notification_mod +from services.notification import transport +from services.notification_service import IncidentAssignmentEmail from services.notification_service import NotificationService @@ -40,10 +41,15 @@ async def fake_send_smtp_with_retry(*args, **kwargs): assert svc.validate_channel_config("email", {}) == ["err"] msg = EmailMessage() - result = await svc._send_smtp_with_retry( - msg, "smtp.example.com", 25, username="user", password="pw", start_tls=True + smtp = transport.SmtpDeliveryConfig( + hostname="smtp.example.com", + port=25, + username="user", + password="pw", + start_tls=True, ) - assert result["kwargs"]["hostname"] == "smtp.example.com" + result = await svc._send_smtp_with_retry(msg, smtp=smtp) + assert result["kwargs"]["smtp"].hostname == "smtp.example.com" @pytest.mark.asyncio @@ -52,7 +58,12 @@ async def test_incident_assignment_email_paths(monkeypatch): monkeypatch.setattr(config, "default_admin_email", "admin@example.com") monkeypatch.setattr(notification_mod.config, "get_secret", lambda key: None) - assert await svc.send_incident_assignment_email("u@example.com", "CPU", "open", "critical", "admin") is False + assert ( + await svc.send_incident_assignment_email( + IncidentAssignmentEmail("u@example.com", "CPU", "open", "critical", "admin") + ) + is False + ) monkeypatch.setattr( notification_mod.config, @@ -62,11 +73,16 @@ async def test_incident_assignment_email_paths(monkeypatch): "INCIDENT_ASSIGNMENT_SMTP_PORT": "bad-port", }.get(key), ) - assert await svc.send_incident_assignment_email("u@example.com", "CPU", "open", "critical", "admin") is False + assert ( + await svc.send_incident_assignment_email( + IncidentAssignmentEmail("u@example.com", "CPU", "open", "critical", "admin") + ) + is False + ) - async def fake_send(*, message, hostname, port, username=None, password=None, start_tls=False, use_tls=False): - assert hostname == "smtp.example.com" - assert port == 587 + async def fake_send(*, message, smtp, **_kwargs): + assert smtp.hostname == "smtp.example.com" + assert smtp.port == 587 assert message["To"] == "u@example.com" monkeypatch.setattr( @@ -80,20 +96,30 @@ async def fake_send(*, message, hostname, port, username=None, password=None, st }.get(key), ) monkeypatch.setattr(svc, "_send_smtp_with_retry", fake_send) - assert await svc.send_incident_assignment_email("u@example.com", "CPU", "open", "critical", "admin") is True + assert ( + await svc.send_incident_assignment_email( + IncidentAssignmentEmail("u@example.com", "CPU", "open", "critical", "admin") + ) + is True + ) async def fail_send(**_kwargs): raise OSError("smtp down") monkeypatch.setattr(svc, "_send_smtp_with_retry", fail_send) - assert await svc.send_incident_assignment_email("u@example.com", "CPU", "open", "critical", "admin") is False + assert ( + await svc.send_incident_assignment_email( + IncidentAssignmentEmail("u@example.com", "CPU", "open", "critical", "admin") + ) + is False + ) def test_notification_service_html_template_and_theme_paths(monkeypatch): monkeypatch.setattr( notification_mod.Path, "read_text", - lambda self, encoding="utf-8": (_ for _ in ()).throw(OSError("missing")), + lambda *args, **kwargs: (_ for _ in ()).throw(OSError("missing")), ) assert notification_mod._render_html_template("missing.html", {"x": "y"}) is None @@ -121,19 +147,21 @@ async def test_incident_assignment_email_skips_html_alternative_when_template_mi captured = {} - async def fake_send(*, message, hostname, port, username=None, password=None, start_tls=False, use_tls=False): - captured["hostname"] = hostname - captured["port"] = port + async def fake_send(*, message, smtp, **_kwargs): + captured["hostname"] = smtp.hostname + captured["port"] = smtp.port captured["is_multipart"] = message.is_multipart() monkeypatch.setattr(svc, "_send_smtp_with_retry", fake_send) result = await svc.send_incident_assignment_email( - "u@example.com", - "CPU", - "open", - "warning", - "admin", + IncidentAssignmentEmail( + "u@example.com", + "CPU", + "open", + "warning", + "admin", + ) ) assert result is True diff --git a/tests/test_notification_transport.py b/tests/test_notification_transport.py index e5705e0..56463f2 100644 --- a/tests/test_notification_transport.py +++ b/tests/test_notification_transport.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ try: @@ -31,7 +30,15 @@ async def fake_send(*args, **kwargs): result = asyncio.run( transport.send_smtp_with_retry( - message="m", hostname="h", port=25, username=None, password=None, start_tls=False, use_tls=False + message="m", + smtp=transport.SmtpDeliveryConfig( + hostname="h", + port=25, + username=None, + password=None, + start_tls=False, + use_tls=False, + ), ) ) assert result == "ok" diff --git a/tests/test_notification_validators.py b/tests/test_notification_validators.py index 5eca9d6..aab3e5e 100644 --- a/tests/test_notification_validators.py +++ b/tests/test_notification_validators.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ try: diff --git a/tests/test_observability_router_endpoints_edges.py b/tests/test_observability_router_endpoints_edges.py index 0f48a09..b263b87 100644 --- a/tests/test_observability_router_endpoints_edges.py +++ b/tests/test_observability_router_endpoints_edges.py @@ -1,5 +1,8 @@ """ -High-coverage router tests for observability endpoints. +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -227,7 +230,10 @@ def _security(_request: Request, scope: str): async def _sync(tenant_id: str, _alerts, log_context: str): synced.append(f"{tenant_id}:{log_context}") - async def _notify(_tenant_id, _alerts, _storage, _notification): + async def _notify(context, _alerts): + assert context.tenant_id == "tenant-a" + assert context.storage_service is webhooks_router.storage_service + assert context.notification_service is webhooks_router.notification_service return None monkeypatch.setattr(webhooks_router.alertmanager_service, "enforce_webhook_security", _security) @@ -412,7 +418,7 @@ async def test_alert_routes_and_channel_type_integrations(monkeypatch): monkeypatch.setattr(alerts_router, "run_in_threadpool", _run_in_threadpool) monkeypatch.setattr(alerts_router.alertmanager_service, "parse_filter_labels", lambda _value: {"alertname": "A"}) - async def _get_alerts(**_kwargs): + async def _get_alerts(*_args, **_kwargs): return [Alert.model_validate(_alert_dict("A")), Alert.model_validate(_alert_dict("HIDDEN"))] async def _sync(_tenant_id, _alerts, log_context: str): @@ -427,11 +433,17 @@ async def _sync(_tenant_id, _alerts, log_context: str): ) monkeypatch.setattr(alerts_router.storage_service, "get_hidden_rule_names", lambda *_args: ["HIDDEN"]) - items = await alerts_router.list_alerts(filter_labels='{"alertname":"A"}', show_hidden=False, current_user=_user()) + items = await alerts_router.list_alerts( + query=alerts_router.AlertListQuery(filter_labels='{"alertname":"A"}', show_hidden=False), + current_user=_user(), + ) assert len(items) == 1 assert items[0].labels["alertname"] == "A" - items = await alerts_router.list_alerts(filter_labels='{"alertname":"A"}', show_hidden=True, current_user=_user()) + items = await alerts_router.list_alerts( + query=alerts_router.AlertListQuery(filter_labels='{"alertname":"A"}', show_hidden=True), + current_user=_user(), + ) assert len(items) == 2 async def _groups(**_kwargs): @@ -493,9 +505,17 @@ async def test_channel_routes_cover_error_and_success_paths(monkeypatch): lambda *_args: [_channel("c1"), _channel("c2", owner="u2", visibility="group")], ) monkeypatch.setattr(channels_router.storage_service, "get_hidden_channel_ids", lambda *_args: ["c2"]) - visible = await channels_router.list_channels(request=_request(), show_hidden=False, current_user=_user()) + visible = await channels_router.list_channels( + request=_request(), + query=channels_router.ChannelListQuery(show_hidden="false"), + current_user=_user(), + ) assert [item.id for item in visible] == ["c1"] - all_items = await channels_router.list_channels(request=_request(), show_hidden=True, current_user=_user()) + all_items = await channels_router.list_channels( + request=_request(), + query=channels_router.ChannelListQuery(show_hidden="true"), + current_user=_user(), + ) assert len(all_items) == 2 monkeypatch.setattr(channels_router.storage_service, "get_notification_channel", lambda *_args: None) @@ -665,12 +685,16 @@ async def _silences(**_kwargs): monkeypatch.setattr(silences_router.storage_service, "get_hidden_silence_ids", lambda *_args: ["s1"]) visible = await silences_router.list_silences( - request=_request(), include_expired=False, show_hidden=False, current_user=_user() + request=_request(), + query=silences_router.SilenceListQuery(include_expired=False, show_hidden="false"), + current_user=_user(), ) assert visible == [] visible = await silences_router.list_silences( - request=_request(), show_hidden=True, include_expired=True, current_user=_user() + request=_request(), + query=silences_router.SilenceListQuery(include_expired=True, show_hidden="true"), + current_user=_user(), ) assert [item.id for item in visible] == ["s1"] @@ -867,9 +891,17 @@ def _raise_import(_yaml, _defaults): (_rule_model("r2", "RuleTwo", created_by="u2", org_id="org-b"), "u2"), ], ) - listed = await rules_router.list_rules(request=_request(), show_hidden=False, current_user=_user()) + listed = await rules_router.list_rules( + request=_request(), + query=rules_router.RuleListQuery(show_hidden="false"), + current_user=_user(), + ) assert len(listed) == 1 - listed_all = await rules_router.list_rules(request=_request(), show_hidden=True, current_user=_user()) + listed_all = await rules_router.list_rules( + request=_request(), + query=rules_router.RuleListQuery(show_hidden="true"), + current_user=_user(), + ) assert listed_all[1].org_id is None async def _list_metric_names(_org): @@ -1278,7 +1310,7 @@ async def _boom(**_kwargs): monkeypatch.setattr(jira_links_router, "format_incident_description", lambda _incident_obj, _desc: "desc") monkeypatch.setattr(jira_links_router, "map_severity_to_jira_priority", lambda _sev: "High") - async def _create_issue(**_kwargs): + async def _create_issue(*_args, **_kwargs): return {"key": "OPS-2", "url": "https://jira/browse/OPS-2"} async def _transition(**_kwargs): @@ -1376,7 +1408,10 @@ async def test_incidents_router_listing_and_patch_paths(monkeypatch): "list_incidents", lambda **_kwargs: [_incident("inc-1")], ) - listed = await incidents_router.list_incidents(current_user=_user()) + listed = await incidents_router.list_incidents( + query=incidents_router.IncidentListQuery(), + current_user=_user(), + ) assert listed[0].id == "inc-1" monkeypatch.setattr( @@ -1413,7 +1448,7 @@ class AlertObj: def __init__(self, labels): self.labels = labels - async def _active_alerts(**_kwargs): + async def _active_alerts(*_args, **_kwargs): return [AlertObj({"alertname": "CPUHigh"})] monkeypatch.setattr(incidents_router.alertmanager_service, "get_alerts", _active_alerts) @@ -1426,7 +1461,7 @@ async def _active_alerts(**_kwargs): ) assert exc.value.status_code == 400 - async def _boom_alerts(**_kwargs): + async def _boom_alerts(*_args, **_kwargs): import httpx raise httpx.RequestError("boom", request=httpx.Request("GET", "https://am")) @@ -1758,7 +1793,7 @@ async def _post_fail(_alerts): monkeypatch.setattr(alerts_router.alertmanager_service, "parse_filter_labels", lambda _value: {"alertname": "A"}) - async def _single_alerts(**_kwargs): + async def _single_alerts(*_args, **_kwargs): return [Alert.model_validate(_alert_dict("A"))] monkeypatch.setattr(alerts_router.alertmanager_service, "get_alerts", _single_alerts) @@ -1766,7 +1801,8 @@ async def _single_alerts(**_kwargs): monkeypatch.setattr(alerts_router.storage_service, "filter_alerts_for_user", lambda *_args: [_alert_dict("A")]) monkeypatch.setattr(alerts_router.storage_service, "get_hidden_rule_names", lambda *_args: []) visible_alerts = await alerts_router.list_alerts( - filter_labels='{"alertname":"A"}', show_hidden=False, current_user=_user() + query=alerts_router.AlertListQuery(filter_labels='{"alertname":"A"}', show_hidden=False), + current_user=_user(), ) assert len(visible_alerts) == 1 @@ -1932,9 +1968,12 @@ async def _none_silence(_sid): base_incident = _incident("inc-line", labels={}, fingerprint="fp-line", status=IncidentStatus.OPEN) monkeypatch.setattr(incidents_router.storage_service, "get_incident_for_user", lambda *_args: base_incident) - seen = {} + seen: dict[str, object] = {} - async def _alerts_by_fingerprint(**kwargs): + async def _alerts_by_fingerprint(*args, **kwargs): + if args: + query = args[0] + seen["filter_labels"] = getattr(query, "filter_labels", None) seen.update(kwargs) return [] @@ -1985,7 +2024,7 @@ async def _alerts_by_fingerprint(**kwargs): weird_updated = SimpleNamespace(status="investigating", assignee=None, alert_name="CPU", severity="warning") monkeypatch.setattr(incidents_router.storage_service, "update_incident", lambda *_args, **_kwargs: weird_updated) - monkeypatch.setattr(incidents_router.alertmanager_service, "get_alerts", lambda **_kwargs: []) + monkeypatch.setattr(incidents_router.alertmanager_service, "get_alerts", lambda *_args, **_kwargs: []) patched_weird = await incidents_router.update_incident( "inc-line", AlertIncidentUpdateRequest.model_validate({"status": "investigating"}), @@ -2117,7 +2156,11 @@ async def test_router_query_param_and_test_rule_remaining_branches(monkeypatch): } ) with pytest.raises(HTTPException) as exc: - await channels_router.list_channels(request=bad_channels_request, current_user=_user()) + await channels_router.list_channels( + request=bad_channels_request, + query=channels_router.ChannelListQuery(), + current_user=_user(), + ) assert exc.value.status_code == 400 bad_silences_list_request = Request( @@ -2133,7 +2176,11 @@ async def test_router_query_param_and_test_rule_remaining_branches(monkeypatch): } ) with pytest.raises(HTTPException) as exc: - await silences_router.list_silences(request=bad_silences_list_request, current_user=_user()) + await silences_router.list_silences( + request=bad_silences_list_request, + query=silences_router.SilenceListQuery(), + current_user=_user(), + ) assert exc.value.status_code == 400 bad_silence_get_request = Request( @@ -2223,7 +2270,11 @@ def _should_not_be_called(*_args, **_kwargs): monkeypatch.setattr(channels_router, "run_in_threadpool", _run_in_threadpool) monkeypatch.setattr(channels_router.storage_service, "get_notification_channels", lambda *_args: [_channel("c1")]) monkeypatch.setattr(channels_router.storage_service, "get_hidden_channel_ids", lambda *_args: []) - channels = await channels_router.list_channels(request=None, current_user=_user()) + channels = await channels_router.list_channels( + request=None, + query=channels_router.ChannelListQuery(), + current_user=_user(), + ) assert len(channels) == 1 monkeypatch.setattr(rules_router, "reject_unknown_query_params", _should_not_be_called) @@ -2234,7 +2285,11 @@ def _should_not_be_called(*_args, **_kwargs): "get_alert_rules_with_owner", lambda *_args: [(_rule_model("r1", "Rule One"), "u1")], ) - rules = await rules_router.list_rules(request=None, current_user=_user()) + rules = await rules_router.list_rules( + request=None, + query=rules_router.RuleListQuery(), + current_user=_user(), + ) assert len(rules) == 1 monkeypatch.setattr(silences_router, "reject_unknown_query_params", _should_not_be_called) @@ -2252,7 +2307,11 @@ async def _get_silence(*_args, **_kwargs): monkeypatch.setattr(silences_router.alertmanager_service, "apply_silence_metadata", lambda silence: silence) monkeypatch.setattr(silences_router.alertmanager_service, "silence_accessible", lambda *_args: True) monkeypatch.setattr(silences_router.storage_service, "get_hidden_silence_ids", lambda *_args: []) - silences = await silences_router.list_silences(request=None, current_user=_user()) + silences = await silences_router.list_silences( + request=None, + query=silences_router.SilenceListQuery(), + current_user=_user(), + ) assert len(silences) == 1 silence = await silences_router.get_silence("s1", request=None, current_user=_user()) assert silence.id == "s1" diff --git a/tests/test_openapi_middleware.py b/tests/test_openapi_middleware.py index 22da966..02c07b2 100644 --- a/tests/test_openapi_middleware.py +++ b/tests/test_openapi_middleware.py @@ -1,3 +1,10 @@ +""" +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +""" + from __future__ import annotations from fastapi import FastAPI @@ -95,18 +102,18 @@ def test_project_version_uses_pyproject_and_fallbacks(monkeypatch) -> None: monkeypatch.setattr( openapi_middleware.Path, "read_text", - lambda self, encoding="utf-8": "[project]\nversion = '1.2.3'\n", + lambda *args, **kwargs: "[project]\nversion = '1.2.3'\n", ) assert openapi_middleware._project_version() == "1.2.3" monkeypatch.setattr( openapi_middleware.Path, "read_text", - lambda self, encoding="utf-8": "[project]\nversion = ''\n", + lambda *args, **kwargs: "[project]\nversion = ''\n", ) assert openapi_middleware._project_version() == openapi_middleware.DEFAULT_APP_VERSION - def _raise_oserror(self, encoding="utf-8"): + def _raise_oserror(*args, **kwargs): raise OSError("missing") monkeypatch.setattr(openapi_middleware.Path, "read_text", _raise_oserror) diff --git a/tests/test_refactor_compat_coverage_edges.py b/tests/test_refactor_compat_coverage_edges.py index 7df8233..664ca21 100644 --- a/tests/test_refactor_compat_coverage_edges.py +++ b/tests/test_refactor_compat_coverage_edges.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the -License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -22,16 +21,17 @@ ensure_test_env() from middleware.error_handlers import handle_route_errors +from middleware.error_handlers import RouteErrorResponse from services import notification_service as notification_mod -from services.jira_service import JiraIssueCreateOptions, JiraService +from services.jira_service import JiraIssueCreateOptions, JiraIssueCreateRequest, JiraService from services.notification import email_providers, transport from services.notification_service import NotificationService from services.storage import incidents as incidents_mod @pytest.mark.asyncio -async def test_handle_route_errors_invalid_legacy_status_uses_default() -> None: - @handle_route_errors(bad_gateway_status_code="bad-status") +async def test_handle_route_errors_explicit_bad_gateway_response() -> None: + @handle_route_errors(bad_gateway=RouteErrorResponse(detail="Upstream request failed", status_code=502)) async def bad_gateway() -> str: raise httpx.ReadError("boom") @@ -41,7 +41,7 @@ async def bad_gateway() -> str: @pytest.mark.asyncio -async def test_jira_create_issue_dataclass_and_legacy_overrides() -> None: +async def test_jira_create_issue_dataclass_request() -> None: service = JiraService(timeout=1) captured: dict[str, object] = {} @@ -54,16 +54,16 @@ async def fake_post(path, payload, credentials=None): service._resolve_base_url = lambda credentials=None: "https://jira.example.com" created = await service.create_issue( - "OPS", - "Summary", - issue=JiraIssueCreateOptions(description="from-dataclass", issue_type="Task", priority="High"), - description=None, - issue_type="Bug", + JiraIssueCreateRequest( + project_key="OPS", + summary="Summary", + options=JiraIssueCreateOptions(description="from-dataclass", issue_type="Task", priority="High"), + ) ) fields = captured["payload"]["fields"] - assert fields["description"] == "" - assert fields["issuetype"]["name"] == "Bug" + assert fields["description"] == "from-dataclass" + assert fields["issuetype"]["name"] == "Task" assert fields["priority"]["name"] == "High" assert created["url"] == "https://jira.example.com/browse/OPS-42" @@ -79,13 +79,17 @@ async def fake_send_smtp_with_retry(*_args, **_kwargs): smtp_cfg = transport.SmtpDeliveryConfig(hostname="smtp.example.com", port=587, start_tls=True) assert await email_providers.send_via_smtp(message, smtp=smtp_cfg) is True - assert await email_providers.send_via_smtp(message, smtp="smtp.example.com", port=587) is True - with pytest.raises(ValueError, match="SMTP hostname is required"): - await email_providers.send_via_smtp(message, "", 587) - - with pytest.raises(ValueError, match="SMTP port must be an integer"): - await email_providers.send_via_smtp(message, "smtp.example.com", "bad") + insecure_cfg = transport.SmtpDeliveryConfig( + hostname="smtp.example.com", + port=25, + username="user", + password="pass", + start_tls=False, + use_tls=False, + ) + with pytest.raises(ValueError, match="without TLS"): + await email_providers.send_via_smtp(message, smtp=insecure_cfg) @pytest.mark.asyncio @@ -101,15 +105,6 @@ async def fake_aiosmtplib_send(*_args, **_kwargs): result = await transport.send_smtp_with_retry(message, smtp=smtp_cfg) assert result["accepted"] == ["ok@example.com"] - result_legacy = await transport.send_smtp_with_retry(message, smtp="smtp.example.com", port=587) - assert result_legacy["accepted"] == ["ok@example.com"] - - with pytest.raises(ValueError, match="SMTP hostname is required"): - await transport.send_smtp_with_retry(message, smtp="", port=587) - - with pytest.raises(ValueError, match="SMTP port must be an integer"): - await transport.send_smtp_with_retry(message, smtp="smtp.example.com", port="bad") - @pytest.mark.asyncio async def test_notification_service_smtp_helper_validation_edges(monkeypatch) -> None: @@ -117,7 +112,7 @@ async def test_notification_service_smtp_helper_validation_edges(monkeypatch) -> captured: dict[str, object] = {} async def fake_send_smtp_with_retry(*_args, **kwargs): - captured.update(kwargs) + captured["kwargs"] = kwargs return True monkeypatch.setattr(notification_mod.notification_transport, "send_smtp_with_retry", fake_send_smtp_with_retry) @@ -126,21 +121,19 @@ async def fake_send_smtp_with_retry(*_args, **kwargs): smtp_cfg = transport.SmtpDeliveryConfig(hostname="smtp.example.com", port=2525) assert await service._send_smtp_with_retry(message, smtp=smtp_cfg) is True - assert captured["hostname"] == "smtp.example.com" - - with pytest.raises(ValueError, match="SMTP hostname is required"): - await service._send_smtp_with_retry(message, hostname="") - - with pytest.raises(ValueError, match="SMTP port must be an integer"): - await service._send_smtp_with_retry(message, "smtp.example.com", "bad") + smtp_arg = captured["kwargs"]["smtp"] + assert smtp_arg.hostname == "smtp.example.com" def test_incident_filter_coercion_handles_dataclass_and_bad_numbers() -> None: - base_filters = incidents_mod.IncidentListFilters(group_ids=["g1"], limit=10, offset=7) - - preserved = incidents_mod._coerce_incident_list_filters(base_filters, {}) - assert preserved.group_ids == ["g1"] - - coerced = incidents_mod._coerce_incident_list_filters(base_filters, {"limit": "bad", "offset": "bad"}) - assert coerced.limit == 10 - assert coerced.offset == 7 + filters = incidents_mod._coerce_incident_list_filters( + group_ids=["g1", "", " g2 "], + status="open", + visibility="group", + group_id="g1", + limit=10, + offset=7, + ) + assert filters.group_ids == ["g1", "g2"] + assert filters.limit == 10 + assert filters.offset == 7 diff --git a/tests/test_regression_alert_delivery_notify_for_alerts_workflow.py b/tests/test_regression_alert_delivery_notify_for_alerts_workflow.py index 98555e6..581bc95 100644 --- a/tests/test_regression_alert_delivery_notify_for_alerts_workflow.py +++ b/tests/test_regression_alert_delivery_notify_for_alerts_workflow.py @@ -1,5 +1,8 @@ """ -Regression tests for alert delivery workflow orchestration. +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -45,6 +48,15 @@ async def send_notification(self, channel, alert, action: str): return True +def _context(storage: _StorageStub, notifier: _NotificationStub) -> channels_ops.NotificationDispatchContext: + return channels_ops.NotificationDispatchContext( + service=object(), + tenant_id="tenant-a", + storage_service=storage, + notification_service=notifier, + ) + + @pytest.mark.asyncio async def test_notify_for_alerts_skips_entries_without_alertname() -> None: storage = _StorageStub( @@ -53,13 +65,7 @@ async def test_notify_for_alerts_skips_entries_without_alertname() -> None: ) notifier = _NotificationStub() - await channels_ops.notify_for_alerts( - service=object(), - tenant_id="tenant-a", - alerts_list=[{"labels": {"severity": "critical"}}], - storage_service=storage, - notification_service=notifier, - ) + await channels_ops.notify_for_alerts(_context(storage, notifier), [{"labels": {"severity": "critical"}}]) assert storage.channel_calls == [] assert notifier.calls == [] @@ -71,11 +77,8 @@ async def test_notify_for_alerts_skips_when_no_channels_are_configured() -> None notifier = _NotificationStub() await channels_ops.notify_for_alerts( - service=object(), - tenant_id="tenant-a", - alerts_list=[{"labels": {"alertname": "DiskFull"}, "status": {"state": "active"}}], - storage_service=storage, - notification_service=notifier, + _context(storage, notifier), + [{"labels": {"alertname": "DiskFull"}, "status": {"state": "active"}}], ) assert storage.channel_calls == [("DiskFull", None)] @@ -91,16 +94,13 @@ async def test_notify_for_alerts_skips_suppressed_status() -> None: notifier = _NotificationStub() await channels_ops.notify_for_alerts( - service=object(), - tenant_id="tenant-a", - alerts_list=[ + _context(storage, notifier), + [ { "labels": {"alertname": "DiskFull"}, "status": {"state": "suppressed", "silencedBy": ["s-1"], "inhibitedBy": []}, } ], - storage_service=storage, - notification_service=notifier, ) assert notifier.calls == [] @@ -116,17 +116,14 @@ async def test_notify_for_alerts_sends_active_alert_to_all_channels() -> None: notifier = _NotificationStub() await channels_ops.notify_for_alerts( - service=object(), - tenant_id="tenant-a", - alerts_list=[ + _context(storage, notifier), + [ { "labels": {"alertname": "HighCpuUsage", "severity": "critical"}, "annotations": {"summary": "cpu"}, "status": {"state": "active", "silencedBy": [], "inhibitedBy": []}, } ], - storage_service=storage, - notification_service=notifier, ) assert len(notifier.calls) == 2 @@ -147,17 +144,14 @@ async def test_notify_for_alerts_enriches_rule_annotations_before_delivery() -> notifier = _NotificationStub() await channels_ops.notify_for_alerts( - service=object(), - tenant_id="tenant-a", - alerts_list=[ + _context(storage, notifier), + [ { "labels": {"alertname": "LatencyHigh", "product": "platform"}, "annotations": {"summary": "latency too high"}, "status": {"state": "resolved", "silencedBy": [], "inhibitedBy": []}, } ], - storage_service=storage, - notification_service=notifier, ) assert len(notifier.calls) == 1 diff --git a/tests/test_regression_channel_validators_no_gap_matrix.py b/tests/test_regression_channel_validators_no_gap_matrix.py index d6f42fe..586908f 100644 --- a/tests/test_regression_channel_validators_no_gap_matrix.py +++ b/tests/test_regression_channel_validators_no_gap_matrix.py @@ -1,5 +1,8 @@ """ -Regression tests for channel configuration validators across all channel types. +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/tests/test_regression_channels_test_endpoint_matrix.py b/tests/test_regression_channels_test_endpoint_matrix.py index 0590cd5..78ef833 100644 --- a/tests/test_regression_channels_test_endpoint_matrix.py +++ b/tests/test_regression_channels_test_endpoint_matrix.py @@ -1,5 +1,8 @@ """ -Regression tests for notification channel test endpoint behavior. +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations diff --git a/tests/test_regression_incident_assignment_background_tasks.py b/tests/test_regression_incident_assignment_background_tasks.py index 5ea52e0..7cb208d 100644 --- a/tests/test_regression_incident_assignment_background_tasks.py +++ b/tests/test_regression_incident_assignment_background_tasks.py @@ -1,5 +1,8 @@ """ -Regression tests for incident assignment email workflows. +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """ from __future__ import annotations @@ -73,7 +76,7 @@ async def _move(_incident, **_kwargs): assert "assigned incident to Bob