From 582f5c95e5365eacdcfa94a7de43b48e8bb3af92 Mon Sep 17 00:00:00 2001 From: stefankumaraaisnghe Date: Fri, 17 Apr 2026 23:09:36 +1000 Subject: [PATCH 01/20] Add notifier mutmut mutation-testing configuration Added notifier mutmut paths, selective test invocation settings, and updated the notifier changelog with an unreleased entry for mutation-testing support. --- CHANGELOG.md | 14 ++++++++++++++ pyproject.toml | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bfcc80..e9b1ab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. +## [Unreleased] - 2026-04-17 + +### 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. + ## [v0.0.4] - 2026-04-14 ### Added diff --git a/pyproject.toml b/pyproject.toml index 46b021e..bb8f919 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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/*"] From 89e61b16655ec1448224faee956956e125160803 Mon Sep 17 00:00:00 2001 From: stefankumaraaisnghe Date: Fri, 17 Apr 2026 23:12:46 +1000 Subject: [PATCH 02/20] Add Apache license headers and test maintenance updates for notifier regression tests Added license header blocks to notifier regression/middleware test files, removed a nondeterministic comment in the rate limit edge test, and made notification payload assertion handling more robust. --- tests/test_middleware_rate_limit_and_database_edges.py | 1 - tests/test_notification_payloads.py | 2 +- tests/test_observability_router_endpoints_edges.py | 6 +++++- tests/test_openapi_middleware.py | 8 ++++++++ ...egression_alert_delivery_notify_for_alerts_workflow.py | 6 +++++- tests/test_regression_channel_validators_no_gap_matrix.py | 6 +++++- tests/test_regression_channels_test_endpoint_matrix.py | 6 +++++- ...est_regression_incident_assignment_background_tasks.py | 6 +++++- tests/test_regression_incident_recipient_email_parsing.py | 6 +++++- tests/test_regression_incident_resolution_guardrails.py | 6 +++++- tests/test_regression_incident_status_and_jira_sync.py | 6 +++++- tests/test_regression_notification_senders_contracts.py | 6 +++++- ...est_regression_notification_service_dispatch_matrix.py | 6 +++++- ...gression_notification_service_email_provider_matrix.py | 6 +++++- 14 files changed, 64 insertions(+), 13 deletions(-) diff --git a/tests/test_middleware_rate_limit_and_database_edges.py b/tests/test_middleware_rate_limit_and_database_edges.py index d2631b3..2c62f97 100644 --- a/tests/test_middleware_rate_limit_and_database_edges.py +++ b/tests/test_middleware_rate_limit_and_database_edges.py @@ -298,7 +298,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: diff --git a/tests/test_notification_payloads.py b/tests/test_notification_payloads.py index 7a4668d..33f9d7d 100644 --- a/tests/test_notification_payloads.py +++ b/tests/test_notification_payloads.py @@ -125,7 +125,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"}) diff --git a/tests/test_observability_router_endpoints_edges.py b/tests/test_observability_router_endpoints_edges.py index 0f48a09..3de10ef 100644 --- a/tests/test_observability_router_endpoints_edges.py +++ b/tests/test_observability_router_endpoints_edges.py @@ -1,5 +1,9 @@ """ -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 diff --git a/tests/test_openapi_middleware.py b/tests/test_openapi_middleware.py index 22da966..8021aff 100644 --- a/tests/test_openapi_middleware.py +++ b/tests/test_openapi_middleware.py @@ -1,3 +1,11 @@ +""" +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 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..819a77c 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,9 @@ """ -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 diff --git a/tests/test_regression_channel_validators_no_gap_matrix.py b/tests/test_regression_channel_validators_no_gap_matrix.py index d6f42fe..b0723b4 100644 --- a/tests/test_regression_channel_validators_no_gap_matrix.py +++ b/tests/test_regression_channel_validators_no_gap_matrix.py @@ -1,5 +1,9 @@ """ -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..c7e9d4c 100644 --- a/tests/test_regression_channels_test_endpoint_matrix.py +++ b/tests/test_regression_channels_test_endpoint_matrix.py @@ -1,5 +1,9 @@ """ -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..9638dfc 100644 --- a/tests/test_regression_incident_assignment_background_tasks.py +++ b/tests/test_regression_incident_assignment_background_tasks.py @@ -1,5 +1,9 @@ """ -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 diff --git a/tests/test_regression_incident_recipient_email_parsing.py b/tests/test_regression_incident_recipient_email_parsing.py index 65a1f24..559ed9c 100644 --- a/tests/test_regression_incident_recipient_email_parsing.py +++ b/tests/test_regression_incident_recipient_email_parsing.py @@ -1,5 +1,9 @@ """ -Regression tests for recipient extraction in incident assignment workflow. +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_resolution_guardrails.py b/tests/test_regression_incident_resolution_guardrails.py index c42fa55..248fec3 100644 --- a/tests/test_regression_incident_resolution_guardrails.py +++ b/tests/test_regression_incident_resolution_guardrails.py @@ -1,5 +1,9 @@ """ -Regression tests for incident resolution guardrails against active alerts. +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_status_and_jira_sync.py b/tests/test_regression_incident_status_and_jira_sync.py index bd32d27..4dbdfd1 100644 --- a/tests/test_regression_incident_status_and_jira_sync.py +++ b/tests/test_regression_incident_status_and_jira_sync.py @@ -1,5 +1,9 @@ """ -Regression tests for incident status transitions and Jira sync side effects. +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_notification_senders_contracts.py b/tests/test_regression_notification_senders_contracts.py index f8e8d94..342a334 100644 --- a/tests/test_regression_notification_senders_contracts.py +++ b/tests/test_regression_notification_senders_contracts.py @@ -1,5 +1,9 @@ """ -Regression tests for sender contracts across Slack, Teams, Webhook, and PagerDuty. +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_notification_service_dispatch_matrix.py b/tests/test_regression_notification_service_dispatch_matrix.py index 7763c1c..672862e 100644 --- a/tests/test_regression_notification_service_dispatch_matrix.py +++ b/tests/test_regression_notification_service_dispatch_matrix.py @@ -1,5 +1,9 @@ """ -Regression tests for notification dispatch routing across 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_notification_service_email_provider_matrix.py b/tests/test_regression_notification_service_email_provider_matrix.py index 6eb8559..5b2eaa4 100644 --- a/tests/test_regression_notification_service_email_provider_matrix.py +++ b/tests/test_regression_notification_service_email_provider_matrix.py @@ -1,5 +1,9 @@ """ -Regression tests for email provider selection and SMTP auth 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 From 8d50f8d6dc709a5580dabfe25d6bf5365c9abe49 Mon Sep 17 00:00:00 2001 From: stefankumaraaisnghe Date: Sun, 19 Apr 2026 17:07:28 +1000 Subject: [PATCH 03/20] (pylint) making the linting stricter and cleaner, currently no issues --- CHANGELOG.md | 3 + middleware/dependencies.py | 32 +- middleware/rate_limit/ip.py | 8 +- pyproject.toml | 14 +- routers/observability/alerts/alerts_routes.py | 24 +- routers/observability/alerts/channels.py | 59 ++-- routers/observability/alerts/rules.py | 70 ++-- routers/observability/alerts/silences.py | 17 +- routers/observability/alerts/webhooks.py | 23 +- routers/observability/incidents.py | 310 +++++++++++------- routers/observability/jira/config.py | 18 +- routers/observability/jira/incident_links.py | 18 +- services/alerting/alerts_ops.py | 31 +- services/alerting/channels_ops.py | 158 +++++---- .../alerting/integration_security_service.py | 34 +- services/alertmanager_service.py | 36 +- services/common/access.py | 88 +++-- services/common/meta.py | 25 +- services/common/url_utils.py | 54 ++- services/jira/helpers.py | 23 +- services/jira_service.py | 134 ++++---- services/notification/email_providers.py | 40 +-- services/notification/senders.py | 4 +- services/notification/transport.py | 31 +- services/notification/validators.py | 139 ++++---- services/notification_service.py | 245 ++++++++------ services/storage/channels.py | 157 ++++++--- services/storage/hidden_entity_storage.py | 19 +- services/storage/incidents.py | 130 +++++--- services/storage/incidents_core.py | 31 +- services/storage/incidents_sync.py | 63 ++-- services/storage/revocation.py | 168 ++++++---- services/storage/rules.py | 201 ++++++++---- tests/test_alerting_ops_and_transport.py | 15 +- tests/test_alertmanager_stateful_workflows.py | 35 +- tests/test_alerts_ops_metrics_edges.py | 6 +- tests/test_channels_ops.py | 13 +- tests/test_channels_ops_edges.py | 20 +- tests/test_common_access_edges.py | 81 ++++- tests/test_incidents_router.py | 7 +- ...t_integration_security_and_jira_service.py | 39 ++- ...iddleware_rate_limit_and_database_edges.py | 5 +- ...re_rate_limit_database_resilience_edges.py | 29 +- ...test_notification_and_helper_edges_more.py | 8 +- tests/test_notification_email_providers.py | 10 +- tests/test_notification_senders.py | 10 +- ...est_notification_senders_error_handling.py | 6 +- tests/test_notification_service.py | 16 +- ...est_notification_service_and_encryption.py | 40 ++- .../test_notification_service_email_edges.py | 53 ++- ...st_observability_router_endpoints_edges.py | 82 ++++- tests/test_refactor_compat_coverage_edges.py | 18 +- ...ert_delivery_notify_for_alerts_workflow.py | 45 +-- ...gression_notification_senders_contracts.py | 16 +- ...ification_service_email_provider_matrix.py | 26 +- tests/test_security_dependencies.py | 20 +- tests/test_service_branch_closure_batch.py | 79 +++-- tests/test_storage_and_alertmanager_edges.py | 6 +- ...t_storage_incidents_helpers_and_service.py | 69 ++-- ...age_rules_channels_and_incident_helpers.py | 134 +++++--- tests/test_storage_tenant_isolation_matrix.py | 19 +- 61 files changed, 2066 insertions(+), 1248 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b1ab3..6c9bfc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ All notable changes to this project will be documented in this file. ### 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. ## [v0.0.4] - 2026-04-14 diff --git a/middleware/dependencies.py b/middleware/dependencies.py index 134a17e..4838e3e 100644 --- a/middleware/dependencies.py +++ b/middleware/dependencies.py @@ -16,6 +16,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 +210,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/rate_limit/ip.py b/middleware/rate_limit/ip.py index ae2222e..c2e44ca 100644 --- a/middleware/rate_limit/ip.py +++ b/middleware/rate_limit/ip.py @@ -41,17 +41,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/pyproject.toml b/pyproject.toml index bb8f919..fd2fa1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -166,7 +166,7 @@ exclude = [ ] [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/"] @@ -186,13 +186,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/observability/alerts/alerts_routes.py b/routers/observability/alerts/alerts_routes.py index b7978b2..51c075c 100644 --- a/routers/observability/alerts/alerts_routes.py +++ b/routers/observability/alerts/alerts_routes.py @@ -2,6 +2,7 @@ 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 @@ -15,6 +16,14 @@ 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 +38,15 @@ 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 + 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 +58,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..8ccb427 100644 --- a/routers/observability/alerts/channels.py +++ b/routers/observability/alerts/channels.py @@ -12,8 +12,9 @@ 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 @@ -23,6 +24,7 @@ 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 +39,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 +55,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 +89,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 +118,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 +158,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 +184,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 +207,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"} @@ -214,15 +233,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/rules.py b/routers/observability/alerts/rules.py index 61c48a9..e5b3a7c 100644 --- a/routers/observability/alerts/rules.py +++ b/routers/observability/alerts/rules.py @@ -16,12 +16,14 @@ 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, @@ -33,6 +35,7 @@ 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 +51,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 +92,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 +107,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 +146,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 +160,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 +187,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: @@ -319,7 +336,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( @@ -388,7 +406,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) @@ -417,15 +440,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 +478,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 +584,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/silences.py b/routers/observability/alerts/silences.py index 58fbdf0..9d18d18 100644 --- a/routers/observability/alerts/silences.py +++ b/routers/observability/alerts/silences.py @@ -10,6 +10,7 @@ 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 @@ -31,6 +32,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 +52,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 +70,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 diff --git a/routers/observability/alerts/webhooks.py b/routers/observability/alerts/webhooks.py index 98801db..67f7670 100644 --- a/routers/observability/alerts/webhooks.py +++ b/routers/observability/alerts/webhooks.py @@ -18,6 +18,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 +28,22 @@ router = APIRouter(tags=["alertmanager-webhooks"]) +async def _dispatch_notifications(tenant_id: str, alerts: list[JSONDict]) -> None: + try: + await alertmanager_service.notify_for_alerts( + NotificationDispatchContext(alertmanager_service, tenant_id, storage_service, notification_service), + alerts, + ) + except TypeError: + # Backward-compatibility path for tests and temporary adapters. + await alertmanager_service.notify_for_alerts( + tenant_id, + alerts, + storage_service, + notification_service, + ) + + @router.post( "/alerts/webhook", summary="Receive Alert Webhook", @@ -40,7 +57,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 +76,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 +93,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..3848f3a 100644 --- a/routers/observability/incidents.py +++ b/routers/observability/incidents.py @@ -10,6 +10,7 @@ """ import logging +from dataclasses import dataclass from email.utils import parseaddr from typing import cast @@ -31,9 +32,10 @@ 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 +47,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 +66,163 @@ 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 | None = None, + **legacy_kwargs: object, +) -> bool: + if payload is None: + payload = IncidentAssignmentEmail( + recipient_email=str(legacy_kwargs.get("recipient_email") or ""), + incident_title=str(legacy_kwargs.get("incident_title") or ""), + incident_status=str(legacy_kwargs.get("incident_status") or ""), + incident_severity=str(legacy_kwargs.get("incident_severity") or ""), + actor=str(legacy_kwargs.get("actor") or ""), + ) + 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(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", + ) + + +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, + 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 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, + 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 +234,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 +242,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 +289,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}) @@ -172,95 +316,7 @@ async def update_incident( 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/config.py b/routers/observability/jira/config.py index 459c977..1c6c130 100644 --- a/routers/observability/jira/config.py +++ b/routers/observability/jira/config.py @@ -6,7 +6,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 +57,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/incident_links.py b/routers/observability/jira/incident_links.py index a78a70e..b41750f 100644 --- a/routers/observability/jira/incident_links.py +++ b/routers/observability/jira/incident_links.py @@ -22,7 +22,7 @@ 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 .shared import SUPPORTED_INCIDENT_JIRA_ISSUE_TYPES, storage_service @@ -85,12 +85,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 diff --git a/services/alerting/alerts_ops.py b/services/alerting/alerts_ops.py index b4e1300..2a6915f 100644 --- a/services/alerting/alerts_ops.py +++ b/services/alerting/alerts_ops.py @@ -12,6 +12,7 @@ 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 +29,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 +171,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..1227ac9 100644 --- a/services/alerting/channels_ops.py +++ b/services/alerting/channels_ops.py @@ -12,6 +12,7 @@ from __future__ import annotations import logging +from dataclasses import dataclass from datetime import UTC, datetime from typing import TYPE_CHECKING @@ -19,6 +20,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 +53,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 +154,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 +169,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 +189,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..99c9e11 100644 --- a/services/alerting/integration_security_service.py +++ b/services/alerting/integration_security_service.py @@ -12,6 +12,7 @@ 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 @@ -221,21 +222,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 +252,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/alertmanager_service.py b/services/alertmanager_service.py index 003761d..4f134e4 100644 --- a/services/alertmanager_service.py +++ b/services/alertmanager_service.py @@ -22,12 +22,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 +181,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: @@ -248,12 +253,23 @@ 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, + **legacy_kwargs: object, ) -> list[Alert]: - return await get_alerts_ops(self, filter_labels, active, silenced, inhibited) + effective_query = query + if effective_query is None and legacy_kwargs: + filter_labels_raw = legacy_kwargs.get("filter_labels") + filter_labels = filter_labels_raw if isinstance(filter_labels_raw, dict) else {} + active_value = legacy_kwargs.get("active") + silenced_value = legacy_kwargs.get("silenced") + inhibited_value = legacy_kwargs.get("inhibited") + effective_query = AlertQuery( + filter_labels={str(key): str(value) for key, value in filter_labels.items()}, + active=active_value if isinstance(active_value, bool) else None, + silenced=silenced_value if isinstance(silenced_value, bool) else None, + inhibited=inhibited_value if isinstance(inhibited_value, bool) else None, + ) + return await get_alerts_ops(self, effective_query) async def delete_silence(self, silence_id: str) -> bool: if not await delete_silence_ops(self, silence_id): diff --git a/services/common/access.py b/services/common/access.py index 3ff8e9a..216792e 100644 --- a/services/common/access.py +++ b/services/common/access.py @@ -9,6 +9,7 @@ """ import logging +from dataclasses import dataclass from fastapi import HTTPException, status from sqlalchemy.exc import IntegrityError @@ -19,14 +20,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 +75,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 +90,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/meta.py b/services/common/meta.py index a87c9c1..52f2444 100644 --- a/services/common/meta.py +++ b/services/common/meta.py @@ -21,20 +21,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/url_utils.py b/services/common/url_utils.py index d8a174c..3f19ad1 100644 --- a/services/common/url_utils.py +++ b/services/common/url_utils.py @@ -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 = not (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved) + except ValueError: + pass + return is_valid diff --git a/services/jira/helpers.py b/services/jira/helpers.py index fcc4b0f..8fb18f2 100644 --- a/services/jira/helpers.py +++ b/services/jira/helpers.py @@ -94,6 +94,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 +107,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..617055a 100644 --- a/services/jira_service.py +++ b/services/jira_service.py @@ -40,6 +40,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 "" @@ -144,76 +168,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 +265,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 +320,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..40d63ce 100644 --- a/services/notification/email_providers.py +++ b/services/notification/email_providers.py @@ -110,17 +110,15 @@ 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 @@ -152,11 +150,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: @@ -195,11 +195,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: diff --git a/services/notification/senders.py b/services/notification/senders.py index c027340..12e3b35 100644 --- a/services/notification/senders.py +++ b/services/notification/senders.py @@ -99,7 +99,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..04f99ec 100644 --- a/services/notification/transport.py +++ b/services/notification/transport.py @@ -43,6 +43,16 @@ class SmtpDeliveryConfig: use_tls: bool = False +@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 _coerce_smtp_config( smtp: SmtpDeliveryConfig | object | None, legacy_args: tuple[object, ...], @@ -101,15 +111,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 +122,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() diff --git a/services/notification/validators.py b/services/notification/validators.py index 2cd3ea0..4d7b987 100644 --- a/services/notification/validators.py +++ b/services/notification/validators.py @@ -45,76 +45,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..3f8e8a3 100644 --- a/services/notification_service.py +++ b/services/notification_service.py @@ -11,6 +11,7 @@ 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 @@ -35,6 +36,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: @@ -154,57 +164,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 +223,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 +285,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 +327,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 +336,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/storage/channels.py b/services/storage/channels.py index d5211a8..4dbd435 100644 --- a/services/storage/channels.py +++ b/services/storage/channels.py @@ -13,6 +13,7 @@ import logging import uuid +from dataclasses import dataclass, field from sqlalchemy.orm import joinedload @@ -22,7 +23,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 @@ -49,7 +50,34 @@ def _config_dict(channel: NotificationChannelDB) -> JSONDict: return raw_config if isinstance(raw_config, dict) else {} +@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: + @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 | list[str] | 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 +104,19 @@ 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, - group_ids: list[str] | None = None, - limit: int | None = None, - offset: int = 0, + access: ChannelAccessContext | str, + page_or_group_ids: PageRequest | list[str] | None = None, ) -> list[NotificationChannel]: - group_ids = group_ids or [] - capped_limit, capped_offset = cap_pagination(limit, offset) + context = ChannelStorageService._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 = ChannelStorageService._page_request(page_or_group_ids) + capped_limit, capped_offset = cap_pagination(paging.limit, paging.offset) with get_db_session() as db: channels = ( @@ -100,27 +131,31 @@ 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(ch, context.user_id)) return results + @staticmethod def get_notification_channel( - self, channel_id: str, tenant_id: str, - user_id: str, - group_ids: list[str] | None = None, - include_sensitive: bool = False, + access: ChannelAccessContext | str, + include_sensitive: bool | list[str] | None = False, ) -> NotificationChannel | None: - group_ids = group_ids or [] + legacy_group_ids = include_sensitive if isinstance(include_sensitive, list) else None + include_sensitive_flag = bool(include_sensitive) if not isinstance(include_sensitive, list) else False + context = ChannelStorageService._access_context(access, group_ids=legacy_group_ids) + group_ids = list(context.group_ids or []) with get_db_session() as db: ch = ( db.query(NotificationChannelDB) @@ -131,30 +166,33 @@ 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(ch, context.user_id, include_sensitive=include_sensitive_flag) + @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,10 +202,12 @@ 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() @@ -175,17 +215,17 @@ def create_notification_channel( cfg = decrypt_config(_config_dict(ch)) ch.config = cfg - return channel_to_pydantic_for_viewer(ch, user_id) + return channel_to_pydantic_for_viewer(ch, context.user_id) + @staticmethod def update_notification_channel( - self, channel_id: str, channel_update: NotificationChannelCreate, tenant_id: str, - user_id: str, - group_ids: list[str] | None = None, + access: ChannelAccessContext | str, ) -> NotificationChannel | None: - group_ids = group_ids or [] + context = ChannelStorageService._access_context(access) + group_ids = list(context.group_ids or []) with get_db_session() as db: ch = ( db.query(NotificationChannelDB) @@ -193,7 +233,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,10 +244,12 @@ 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() @@ -215,9 +257,16 @@ def update_notification_channel( cfg = decrypt_config(_config_dict(ch)) ch.config = cfg - return channel_to_pydantic_for_viewer(ch, user_id) + return channel_to_pydantic_for_viewer(ch, 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 +274,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 +292,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 +310,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,7 +368,7 @@ 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)) diff --git a/services/storage/hidden_entity_storage.py b/services/storage/hidden_entity_storage.py index c907dfa..da4506b 100644 --- a/services/storage/hidden_entity_storage.py +++ b/services/storage/hidden_entity_storage.py @@ -10,7 +10,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 +23,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 +50,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 +63,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 +89,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 +105,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 +118,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..a96b785 100644 --- a/services/storage/incidents.py +++ b/services/storage/incidents.py @@ -26,7 +26,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 +38,7 @@ _shared_group_ids, ) from services.storage.incidents_sync import ( + AlertSyncContext, _resolve_incidents_without_active_alerts, _sync_single_alert_into_incidents, ) @@ -68,6 +69,13 @@ class IncidentActorContext: user_email: str | None = None +@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( filters_or_group_ids: IncidentListFilters | list[str] | None, legacy_kwargs: dict[str, object], @@ -111,8 +119,8 @@ def _coerce_incident_list_filters( class IncidentStorageService: + @staticmethod def unlink_jira_integration_from_incidents( - self, tenant_id: str, integration_id: str, ) -> int: @@ -141,8 +149,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 +174,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,19 +211,24 @@ 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) + @staticmethod def list_incidents( - self, tenant_id: str, user_id: str, filters_or_group_ids: IncidentListFilters | list[str] | None = None, @@ -254,11 +269,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 +283,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 +307,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 +332,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 +342,8 @@ def _apply_incident_assignee( ) incident.assignee = requested_assignee + @staticmethod def _apply_incident_status( - self, payload: AlertIncidentUpdateRequest, incident: AlertIncidentDB, previous_status: str, @@ -355,7 +373,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 +389,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 +412,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, @@ -453,27 +472,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( + actor_context = IncidentActorContext(user_id=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, 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, user_id, manual_manage_flag) + IncidentStorageService._apply_incident_notes(payload, incident, 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 +537,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..f1eb6b4 100644 --- a/services/storage/incidents_core.py +++ b/services/storage/incidents_core.py @@ -19,7 +19,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 +117,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_sync.py b/services/storage/incidents_sync.py index 01804fd..ce154fa 100644 --- a/services/storage/incidents_sync.py +++ b/services/storage/incidents_sync.py @@ -57,6 +57,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 +154,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 +276,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 +290,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..1fd843f 100644 --- a/services/storage/revocation.py +++ b/services/storage/revocation.py @@ -5,6 +5,7 @@ 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 +27,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 +45,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 +76,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..84342b2 100644 --- a/services/storage/rules.py +++ b/services/storage/rules.py @@ -12,6 +12,7 @@ import logging import uuid +from dataclasses import dataclass, field from sqlalchemy.orm import joinedload @@ -19,7 +20,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 +40,36 @@ 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: + @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 +91,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 +104,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 +119,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 +157,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 +172,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 +197,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 +220,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 +246,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 +268,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 +286,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 +327,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 +341,15 @@ 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, - group_ids: list[str] | None = None, + access: RuleAccessContext | str, ) -> AlertRule | None: - group_ids = group_ids or [] + context = RuleStorageService._access_context(access) + group_ids = list(context.group_ids or []) with get_db_session() as db: r = ( db.query(AlertRuleDB) @@ -291,15 +359,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 +396,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 +426,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/tests/test_alerting_ops_and_transport.py b/tests/test_alerting_ops_and_transport.py index 6ef1248..8aa356e 100644 --- a/tests/test_alerting_ops_and_transport.py +++ b/tests/test_alerting_ops_and_transport.py @@ -26,6 +26,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 +95,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,11 +274,16 @@ 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 = [] diff --git a/tests/test_alertmanager_stateful_workflows.py b/tests/test_alertmanager_stateful_workflows.py index 692a01b..62e690d 100644 --- a/tests/test_alertmanager_stateful_workflows.py +++ b/tests/test_alertmanager_stateful_workflows.py @@ -83,10 +83,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 +115,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 +149,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 +215,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 "") diff --git a/tests/test_alerts_ops_metrics_edges.py b/tests/test_alerts_ops_metrics_edges.py index fe055dc..57d337e 100644 --- a/tests/test_alerts_ops_metrics_edges.py +++ b/tests/test_alerts_ops_metrics_edges.py @@ -22,6 +22,7 @@ from models.alerting.alerts import Alert from services.alerting import alerts_ops +from services.alerting.alerts_ops import AlertQuery class _Response: @@ -214,7 +215,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 +245,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_channels_ops.py b/tests/test_channels_ops.py index d74f288..d06f77c 100644 --- a/tests/test_channels_ops.py +++ b/tests/test_channels_ops.py @@ -28,11 +28,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 +46,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..ec8e827 100644 --- a/tests/test_channels_ops_edges.py +++ b/tests/test_channels_ops_edges.py @@ -68,6 +68,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 +95,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 +104,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 +114,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 +123,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..07fbe25 100644 --- a/tests/test_common_access_edges.py +++ b/tests/test_common_access_edges.py @@ -65,30 +65,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 +117,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_incidents_router.py b/tests/test_incidents_router.py index 25f1152..3a11e14 100644 --- a/tests/test_incidents_router.py +++ b/tests/test_incidents_router.py @@ -150,11 +150,8 @@ 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") diff --git a/tests/test_integration_security_and_jira_service.py b/tests/test_integration_security_and_jira_service.py index 1b456e1..bf807dc 100644 --- a/tests/test_integration_security_and_jira_service.py +++ b/tests/test_integration_security_and_jira_service.py @@ -26,7 +26,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: @@ -193,7 +193,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 +209,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 +402,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 +461,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_middleware_rate_limit_and_database_edges.py b/tests/test_middleware_rate_limit_and_database_edges.py index 2c62f97..5f4914b 100644 --- a/tests/test_middleware_rate_limit_and_database_edges.py +++ b/tests/test_middleware_rate_limit_and_database_edges.py @@ -152,7 +152,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) diff --git a/tests/test_middleware_rate_limit_database_resilience_edges.py b/tests/test_middleware_rate_limit_database_resilience_edges.py index 62a9537..3ac9006 100644 --- a/tests/test_middleware_rate_limit_database_resilience_edges.py +++ b/tests/test_middleware_rate_limit_database_resilience_edges.py @@ -424,7 +424,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 +436,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..b761ea2 100644 --- a/tests/test_notification_and_helper_edges_more.py +++ b/tests/test_notification_and_helper_edges_more.py @@ -264,7 +264,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") @@ -694,7 +696,9 @@ 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 diff --git a/tests/test_notification_email_providers.py b/tests/test_notification_email_providers.py index 35a359b..c8217c1 100644 --- a/tests/test_notification_email_providers.py +++ b/tests/test_notification_email_providers.py @@ -23,7 +23,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,10 +32,10 @@ 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) @@ -66,8 +66,8 @@ async def fake_send_err(*args, **kwargs): 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) diff --git a/tests/test_notification_senders.py b/tests/test_notification_senders.py index d40c001..21c6541 100644 --- a/tests/test_notification_senders.py +++ b/tests/test_notification_senders.py @@ -33,9 +33,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 +56,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..ccab299 100644 --- a/tests/test_notification_senders_error_handling.py +++ b/tests/test_notification_senders_error_handling.py @@ -31,8 +31,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 +44,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..b5e5d51 100644 --- a/tests/test_notification_service.py +++ b/tests/test_notification_service.py @@ -72,8 +72,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 +116,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 +135,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..f2f4f13 100644 --- a/tests/test_notification_service_and_encryption.py +++ b/tests/test_notification_service_and_encryption.py @@ -24,6 +24,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 +130,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 +159,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 +179,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 +212,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 +269,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..4b4e889 100644 --- a/tests/test_notification_service_email_edges.py +++ b/tests/test_notification_service_email_edges.py @@ -21,6 +21,7 @@ from config import config from services import notification_service as notification_mod +from services.notification_service import IncidentAssignmentEmail from services.notification_service import NotificationService @@ -52,7 +53,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 +68,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,13 +91,23 @@ 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): @@ -121,19 +142,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_observability_router_endpoints_edges.py b/tests/test_observability_router_endpoints_edges.py index 3de10ef..e5c0e42 100644 --- a/tests/test_observability_router_endpoints_edges.py +++ b/tests/test_observability_router_endpoints_edges.py @@ -431,11 +431,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): @@ -497,9 +503,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) @@ -669,12 +683,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"] @@ -871,9 +889,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): @@ -1282,7 +1308,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): @@ -1380,7 +1406,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( @@ -1770,7 +1799,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 @@ -2121,7 +2151,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( @@ -2137,7 +2171,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( @@ -2227,7 +2265,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) @@ -2238,7 +2280,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) @@ -2256,7 +2302,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_refactor_compat_coverage_edges.py b/tests/test_refactor_compat_coverage_edges.py index 7df8233..79d0569 100644 --- a/tests/test_refactor_compat_coverage_edges.py +++ b/tests/test_refactor_compat_coverage_edges.py @@ -23,7 +23,7 @@ from middleware.error_handlers import handle_route_errors 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 @@ -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" 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 819a77c..7d25496 100644 --- a/tests/test_regression_alert_delivery_notify_for_alerts_workflow.py +++ b/tests/test_regression_alert_delivery_notify_for_alerts_workflow.py @@ -49,6 +49,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( @@ -57,13 +66,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 == [] @@ -75,11 +78,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)] @@ -95,16 +95,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 == [] @@ -120,17 +117,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 @@ -151,17 +145,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_notification_senders_contracts.py b/tests/test_regression_notification_senders_contracts.py index 342a334..50b4fda 100644 --- a/tests/test_regression_notification_senders_contracts.py +++ b/tests/test_regression_notification_senders_contracts.py @@ -49,10 +49,10 @@ async def test_send_teams_rejects_non_teams_webhook_host() -> None: async def test_send_webhook_forwards_only_allowed_headers(monkeypatch: pytest.MonkeyPatch) -> None: captured = {} - async def _post_with_retry(_client, url, *, json, headers=None): - captured["url"] = url - captured["json"] = json - captured["headers"] = headers or {} + async def _post_with_retry(request): + captured["url"] = str(request.url) + captured["json"] = request.json + captured["headers"] = request.headers or {} return None monkeypatch.setattr(senders.transport, "post_with_retry", _post_with_retry) @@ -104,10 +104,10 @@ async def _post_with_retry(*_args, **_kwargs): async def test_send_pagerduty_posts_to_events_api_with_resolve_action(monkeypatch: pytest.MonkeyPatch) -> None: captured = {} - async def _post_with_retry(_client, url, *, json, headers=None): - captured["url"] = url - captured["json"] = json - captured["headers"] = headers + async def _post_with_retry(request): + captured["url"] = str(request.url) + captured["json"] = request.json + captured["headers"] = request.headers return None monkeypatch.setattr(senders.transport, "post_with_retry", _post_with_retry) diff --git a/tests/test_regression_notification_service_email_provider_matrix.py b/tests/test_regression_notification_service_email_provider_matrix.py index 5b2eaa4..264f57e 100644 --- a/tests/test_regression_notification_service_email_provider_matrix.py +++ b/tests/test_regression_notification_service_email_provider_matrix.py @@ -138,18 +138,18 @@ async def test_send_email_smtp_api_key_mode_sets_default_username(monkeypatch: p ) captured = {} - def _build_message(_subject, _body, _from_addr, _recipients, _html_body): + def _build_message(_payload): msg = EmailMessage() msg["Subject"] = "subject" return msg - async def _send_smtp(_msg, host, port, username, password, start_tls, use_tls): - captured["host"] = host - captured["port"] = port - captured["username"] = username - captured["password"] = password - captured["start_tls"] = start_tls - captured["use_tls"] = use_tls + async def _send_smtp(_msg, smtp=None, **_kwargs): + captured["host"] = smtp.hostname + captured["port"] = smtp.port + captured["username"] = smtp.username + captured["password"] = smtp.password + captured["start_tls"] = smtp.start_tls + captured["use_tls"] = smtp.use_tls return True monkeypatch.setattr(notification_mod.notification_email, "build_smtp_message", _build_message) @@ -183,13 +183,13 @@ async def test_send_email_smtp_none_auth_clears_credentials_and_uses_default_por ) captured = {} - def _build_message(_subject, _body, _from_addr, _recipients, _html_body): + def _build_message(_payload): return EmailMessage() - async def _send_smtp(_msg, _host, port, username, password, _start_tls, _use_tls): - captured["port"] = port - captured["username"] = username - captured["password"] = password + async def _send_smtp(_msg, smtp=None, **_kwargs): + captured["port"] = smtp.port + captured["username"] = smtp.username + captured["password"] = smtp.password return True monkeypatch.setattr(notification_mod.notification_email, "build_smtp_message", _build_message) diff --git a/tests/test_security_dependencies.py b/tests/test_security_dependencies.py index 1183b4f..9b674fc 100644 --- a/tests/test_security_dependencies.py +++ b/tests/test_security_dependencies.py @@ -173,10 +173,12 @@ def test_public_endpoint_security_enforces_allowlist(monkeypatch): with pytest.raises(HTTPException) as exc: dependencies.enforce_public_endpoint_security( _request("198.51.100.1"), - scope="test", - limit=100, - window_seconds=60, - allowlist="203.0.113.10", + dependencies.PublicEndpointSecurityConfig( + scope="test", + limit=100, + window_seconds=60, + allowlist="203.0.113.10", + ), ) assert exc.value.status_code == 403 @@ -188,10 +190,12 @@ def test_public_endpoint_security_allows_allowlisted_ip(monkeypatch): dependencies.enforce_public_endpoint_security( _request("203.0.113.10"), - scope="test", - limit=100, - window_seconds=60, - allowlist="203.0.113.10", + dependencies.PublicEndpointSecurityConfig( + scope="test", + limit=100, + window_seconds=60, + allowlist="203.0.113.10", + ), ) diff --git a/tests/test_service_branch_closure_batch.py b/tests/test_service_branch_closure_batch.py index 907aaf1..16a2aeb 100644 --- a/tests/test_service_branch_closure_batch.py +++ b/tests/test_service_branch_closure_batch.py @@ -31,7 +31,14 @@ from services import notification_service as notif_mod from services.alerting import integration_security_service as sec_mod from services.alerting import silences_ops as sil_mod -from services.jira_service import JiraError, JiraService +from services.jira_service import ( + JiraError, + JiraIssueCreateOptions, + JiraIssueCreateRequest, + JiraRequest, + JiraService, + JiraTransitionTarget, +) from services.notification_service import NotificationService from services.storage import revocation as rev_mod @@ -224,7 +231,7 @@ async def post(self, _url, json=None, headers=None): svc._client = _StatusClient() with pytest.raises(JiraError, match="503"): - await svc._request("GET", "/rest/api/2/project", creds) + await svc._request(JiraRequest("GET", "/rest/api/2/project", creds)) class _StatusClientWithDetail: async def get(self, _url, headers=None, params=None): @@ -235,19 +242,19 @@ async def post(self, _url, json=None, headers=None): svc._client = _StatusClientWithDetail() with pytest.raises(JiraError, match="jira says no"): - await svc._request("GET", "/rest/api/2/project", creds) + await svc._request(JiraRequest("GET", "/rest/api/2/project", creds)) svc._client = _RequestErrorClient() with pytest.raises(JiraError, match="Unable to connect"): - await svc._request("GET", "/rest/api/2/project", creds) + await svc._request(JiraRequest("GET", "/rest/api/2/project", creds)) svc._client = _JiraErrorClient() with pytest.raises(JiraError, match="already wrapped"): - await svc._request("GET", "/rest/api/2/project", creds) + await svc._request(JiraRequest("GET", "/rest/api/2/project", creds)) svc._client = _UnexpectedClient() with pytest.raises(JiraError, match="Failed to contact Jira API"): - await svc._request("GET", "/rest/api/2/project", creds) + await svc._request(JiraRequest("GET", "/rest/api/2/project", creds)) captured: dict[str, object] = {} @@ -257,7 +264,14 @@ async def fake_post(path, payload, credentials=None): svc._post = fake_post svc._resolve_base_url = lambda credentials=None: "https://jira.example.com" - created = await svc.create_issue("OPS", "Summary", priority="High", credentials=creds) + created = await svc.create_issue( + JiraIssueCreateRequest( + project_key="OPS", + summary="Summary", + options=JiraIssueCreateOptions(priority="High"), + credentials=creds, + ) + ) assert created["key"] == "OPS-1" assert captured["payload"]["fields"]["priority"]["name"] == "High" @@ -268,10 +282,8 @@ async def no_transitions(issue_key, credentials=None): assert ( await svc._transition_issue_by_target( "OPS-1", - credentials=creds, - target_names={"done"}, - transition_names={"done"}, - status_category_key="done", + JiraTransitionTarget({"done"}, {"done"}, "done"), + creds, ) is False ) @@ -283,10 +295,8 @@ async def no_id_transition(issue_key, credentials=None): assert ( await svc._transition_issue_by_target( "OPS-1", - credentials=creds, - target_names={"done"}, - transition_names={"done"}, - status_category_key="done", + JiraTransitionTarget({"done"}, {"done"}, "done"), + creds, ) is False ) @@ -330,22 +340,26 @@ def decrypt(self, _payload): with pytest.raises(HTTPException, match="missing or invalid"): sec_mod.save_tenant_jira_config( "tenant-a", - enabled=True, - base_url="http://bad", - email="user@example.com", - api_token="token", - bearer=None, + sec_mod.JiraTenantConfigUpdate( + enabled=True, + base_url="http://bad", + email="user@example.com", + api_token="token", + bearer=None, + ), ) monkeypatch.setattr(sec_mod, "is_safe_http_url", lambda _url: True) with pytest.raises(HTTPException, match="credentials are incomplete"): sec_mod.save_tenant_jira_config( "tenant-a", - enabled=True, - base_url="https://jira.example.com", - email="user@example.com", - api_token=None, - bearer=None, + sec_mod.JiraTenantConfigUpdate( + enabled=True, + base_url="https://jira.example.com", + email="user@example.com", + api_token=None, + bearer=None, + ), ) monkeypatch.setattr( @@ -610,12 +624,17 @@ async def sendgrid_false(*_args, **_kwargs): captured = {} - def build_message(subject, body, smtp_from, recipients, html_body): - return SimpleNamespace(subject=subject, body=body, smtp_from=smtp_from, recipients=recipients) + def build_message(payload): + return SimpleNamespace( + subject=payload.subject, + body=payload.body, + smtp_from=payload.smtp_from, + recipients=payload.recipients, + ) - async def smtp_capture(message, hostname, port, username, password, start_tls, use_tls): - captured["username"] = username - captured["password"] = password + async def smtp_capture(message, smtp=None, **_kwargs): + captured["username"] = smtp.username + captured["password"] = smtp.password return False channel = NotificationChannel( diff --git a/tests/test_storage_and_alertmanager_edges.py b/tests/test_storage_and_alertmanager_edges.py index 8210c2b..9b090e1 100644 --- a/tests/test_storage_and_alertmanager_edges.py +++ b/tests/test_storage_and_alertmanager_edges.py @@ -27,6 +27,8 @@ from config import config from models.access.auth_models import Role, TokenData from services import alertmanager_service as alert_mod +from services.alerting.channels_ops import NotificationDispatchContext +from services.storage.incidents import IncidentAccessContext from services.storage_db_service import DatabaseStorageService @@ -134,7 +136,7 @@ def test_storage_service_delegates_to_subservices(monkeypatch): assert svc.list_incidents("tenant", "user", ["g1"], limit=10, offset=2)[0] == "list-incidents" assert svc.get_incident_summary("tenant", "user")[0] == "summary" assert svc.unlink_jira_integration_from_incidents("tenant", "jira")[0] == "unlink" - assert svc.get_incident_for_user("inc", "tenant", "user")[0] == "incident" + assert svc.get_incident_for_user("inc", "tenant", IncidentAccessContext(user_id="user"))[0] == "incident" assert svc.update_incident("inc", "tenant", "user", "payload")[0] == "update-incident" assert svc.filter_alerts_for_user("tenant", "user", ["g1"], [{"a": 1}])[0] == "filter" assert svc.get_public_alert_rules("tenant")[0] == "public" @@ -362,7 +364,7 @@ async def async_value(value): monkeypatch.setitem(ops, "prune_removed_member_group_silences", lambda *_a, **_k: async_value(2)) monkeypatch.setitem(ops, "get_status", lambda *_a, **_k: async_value("status")) monkeypatch.setitem(ops, "get_receivers", lambda *_a, **_k: async_value(["receiver"])) - assert await svc.notify_for_alerts("tenant", [], object(), object()) is None + assert await svc.notify_for_alerts(NotificationDispatchContext(svc, "tenant", object(), object()), []) is None assert await svc.list_metric_names("org") == ["metric"] assert await svc.list_label_names("org") == ["label-a"] assert await svc.list_label_values("org", "job", "up") == ["value-a"] diff --git a/tests/test_storage_incidents_helpers_and_service.py b/tests/test_storage_incidents_helpers_and_service.py index eb0c716..cd1cfc0 100644 --- a/tests/test_storage_incidents_helpers_and_service.py +++ b/tests/test_storage_incidents_helpers_and_service.py @@ -180,11 +180,13 @@ def __init__(self): assert incidents_core_mod._is_alert_suppressed({"status": {"state": "suppressed"}}) is True assert ( incidents_mod._incident_access_allowed( - visibility="group", - creator_id="owner", - user_id="user", - shared_group_ids=["g1"], - user_group_ids=["g1"], + incidents_mod.AccessCheck( + visibility="group", + created_by="owner", + user_id="user", + shared_group_ids=["g1"], + user_group_ids=["g1"], + ) ) is True ) @@ -434,8 +436,8 @@ def test_incident_service_summary_list_get_update_and_filter(monkeypatch): monkeypatch.setattr( incidents_mod, "has_access", - lambda visibility, creator_id, user_id, shared_group_ids, user_group_ids, require_write=False: ( - visibility != "private" or creator_id == user_id + lambda check: ( + check.visibility != "private" or check.created_by == check.user_id ), ) @@ -449,9 +451,20 @@ def test_incident_service_summary_list_get_update_and_filter(monkeypatch): assert [item.id for item in listed] == ["public-open", "private-open", "group-open"] assert service.list_incidents("tenant-a", "user-1", ["g1"], visibility="group", group_id="g1")[0].id == "group-open" - fetched = service.get_incident_for_user("private-open", "tenant-a", user_id="user-1", group_ids=[]) + fetched = service.get_incident_for_user( + "private-open", + "tenant-a", + incidents_mod.IncidentAccessContext(user_id="user-1", group_ids=[]), + ) assert fetched is not None - assert service.get_incident_for_user("private-open", "tenant-a", user_id="other", group_ids=[]) is None + assert ( + service.get_incident_for_user( + "private-open", + "tenant-a", + incidents_mod.IncidentAccessContext(user_id="other", group_ids=[]), + ) + is None + ) update_payload = AlertIncidentUpdateRequest( assignee="user-1", @@ -555,7 +568,7 @@ def flush(self): db = _FakeDB([row]) monkeypatch.setattr(incidents_mod, "get_db_session", lambda: _db_session(db)) monkeypatch.setattr(incidents_mod, "normalize_storage_visibility", lambda value: value) - monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda **_kwargs: True) + monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda _check: True) monkeypatch.setattr( incidents_mod, "incident_to_pydantic", @@ -586,7 +599,7 @@ def flush(self): assert "jira_ticket_key" not in meta assert any(note["text"] == "note-1" for note in row.notes) - monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda **_kwargs: False) + monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda _check: False) assert service.update_incident("inc-u", "tenant-a", "u1", AlertIncidentUpdateRequest(), ["g1"]) is None missing_db = _FakeDB([]) @@ -605,7 +618,7 @@ def test_incident_list_and_filter_additional_edges(monkeypatch): db = _FakeDB(rows, []) monkeypatch.setattr(incidents_mod, "get_db_session", lambda: _db_session(db)) monkeypatch.setattr(incidents_mod, "cap_pagination", lambda limit, offset: (limit or 50, offset)) - monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda **_kwargs: True) + monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda _check: True) monkeypatch.setattr( incidents_mod, "incident_to_pydantic", @@ -621,14 +634,25 @@ def test_incident_list_and_filter_additional_edges(monkeypatch): # get_incident_for_user not found and access denied branches missing_db = _FakeDB([]) monkeypatch.setattr(incidents_mod, "get_db_session", lambda: _db_session(missing_db)) - assert service.get_incident_for_user("missing", "tenant-a", user_id="u1", group_ids=["g1"]) is None + assert ( + service.get_incident_for_user( + "missing", + "tenant-a", + incidents_mod.IncidentAccessContext(user_id="u1", group_ids=["g1"]), + ) + is None + ) denied_row = _incident_row("inc-denied", visibility="private", created_by="u2") denied_db = _FakeDB([denied_row]) monkeypatch.setattr(incidents_mod, "get_db_session", lambda: _db_session(denied_db)) - monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda **_kwargs: False) + monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda _check: False) assert ( - service.get_incident_for_user("inc-denied", "tenant-a", user_id="u1", group_ids=["g1"], require_write=True) + service.get_incident_for_user( + "inc-denied", + "tenant-a", + incidents_mod.IncidentAccessContext(user_id="u1", group_ids=["g1"], require_write=True), + ) is None ) @@ -753,7 +777,7 @@ def test_incident_get_update_filter_remaining_branches(monkeypatch): monkeypatch.setattr(incidents_mod, "get_db_session", lambda: _db_session(db)) access_called = {"value": False} - def _access_probe(**_kwargs): + def _access_probe(_check): access_called["value"] = True return True @@ -763,7 +787,14 @@ def _access_probe(**_kwargs): "incident_to_pydantic", lambda incident: SimpleNamespace(id=incident.id), ) - assert service.get_incident_for_user("inc-1", "tenant-a", user_id="", group_ids=["g1"]) is not None + assert ( + service.get_incident_for_user( + "inc-1", + "tenant-a", + incidents_mod.IncidentAccessContext(user_id="", group_ids=["g1"]), + ) + is not None + ) assert access_called["value"] is False @@ -773,7 +804,7 @@ def test_update_incident_actor_context_and_legacy_args_require_payload(monkeypat db = _FakeDB([row]) monkeypatch.setattr(incidents_mod, "get_db_session", lambda: _db_session(db)) monkeypatch.setattr(incidents_mod, "normalize_storage_visibility", lambda value: value) - monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda **_kwargs: True) + monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda _check: True) monkeypatch.setattr( incidents_mod, "incident_to_pydantic", @@ -800,7 +831,7 @@ def test_update_incident_actor_context_and_legacy_args_require_payload(monkeypat db = _FakeDB([resolved_row]) monkeypatch.setattr(incidents_mod, "get_db_session", lambda: _db_session(db)) monkeypatch.setattr(incidents_mod, "normalize_storage_visibility", lambda value: value) - monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda **_kwargs: True) + monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda _check: True) monkeypatch.setattr( incidents_mod, "incident_to_pydantic", diff --git a/tests/test_storage_rules_channels_and_incident_helpers.py b/tests/test_storage_rules_channels_and_incident_helpers.py index 5ae5e3f..9e922e6 100644 --- a/tests/test_storage_rules_channels_and_incident_helpers.py +++ b/tests/test_storage_rules_channels_and_incident_helpers.py @@ -140,6 +140,10 @@ def _channel(**kwargs): def test_rule_helpers_and_delivery_lookup(monkeypatch): svc = rules_mod.RuleStorageService() + assert rules_mod.RuleStorageService._page_request(None).limit is None + assert rules_mod.RuleStorageService._access_context("user-1", ["g1"]).group_ids == ["g1"] + existing_access = rules_mod.RuleAccessContext(user_id="u2", group_ids=["g2"]) + assert rules_mod.RuleStorageService._access_context(existing_access) is existing_access shared = SimpleNamespace(id="g1") rule_private = _rule(shared_groups=[shared]) assert rules_mod._shared_group_ids(rule_private) == ["g1"] @@ -175,11 +179,20 @@ def test_rule_storage_crud_and_visibility(monkeypatch): rule2 = _rule(id="rule-2", visibility="group", shared_groups=[SimpleNamespace(id="g2")]) access_calls = [] - def fake_has_access(visibility, creator_id, user_id, shared_group_ids, group_ids, require_write=False): - access_calls.append((visibility, creator_id, user_id, tuple(shared_group_ids), tuple(group_ids), require_write)) - if require_write: - return visibility != "group" - return visibility != "group" + def fake_has_access(check): + access_calls.append( + ( + check.visibility, + check.created_by, + check.user_id, + tuple(check.shared_group_ids), + tuple(check.user_group_ids), + check.require_write, + ) + ) + if check.require_write: + return check.visibility != "group" + return check.visibility != "group" monkeypatch.setattr(rules_mod, "has_access", fake_has_access) monkeypatch.setattr(rules_mod, "cap_pagination", lambda limit, offset: (limit or 50, offset)) @@ -194,10 +207,18 @@ def fake_has_access(visibility, creator_id, user_id, shared_group_ids, group_ids {"id": "rule-1", "visibility": "public"}, {"id": "rule-2", "visibility": "group"}, ] - assert svc.get_alert_rules("tenant", "user", ["g1"], limit=10, offset=2) == [ + assert svc.get_alert_rules( + "tenant", + rules_mod.RuleAccessContext(user_id="user", group_ids=["g1"]), + rules_mod.PageRequest(limit=10, offset=2), + ) == [ {"id": "rule-1", "visibility": "public"} ] - assert svc.get_alert_rules_with_owner("tenant", "user", ["g1"], limit=10, offset=2) == [ + assert svc.get_alert_rules_with_owner( + "tenant", + rules_mod.RuleAccessContext(user_id="user", group_ids=["g1"]), + rules_mod.PageRequest(limit=10, offset=2), + ) == [ ({"id": "rule-1", "visibility": "public"}, "owner") ] assert svc.get_alert_rule_raw("rule-1", "tenant") is rule1 @@ -260,25 +281,28 @@ def fake_has_access(visibility, creator_id, user_id, shared_group_ids, group_ids "rule-1", AlertRuleCreate.model_validate({"name": "New", "expression": "up", "severity": "warning", "groupName": "g"}), "tenant", - "owner", - ["g1"], + rules_mod.RuleAccessContext(user_id="owner", group_ids=["g1"]), ) assert denied["name"] == "New" - monkeypatch.setattr(rules_mod, "has_access", lambda *_args, **kwargs: not kwargs.get("require_write", False)) + monkeypatch.setattr(rules_mod, "has_access", lambda check: not check.require_write) blocked_db = FakeDB(_rule(id="rule-2", visibility="private")) monkeypatch.setattr(rules_mod, "get_db_session", lambda: FakeCtx(blocked_db)) - assert svc.delete_alert_rule("rule-2", "tenant", "owner", ["g1"]) is False + assert svc.delete_alert_rule("rule-2", "tenant", rules_mod.RuleAccessContext(user_id="owner", group_ids=["g1"])) is False - monkeypatch.setattr(rules_mod, "has_access", lambda *_args, **_kwargs: True) + monkeypatch.setattr(rules_mod, "has_access", lambda _check: True) delete_db = FakeDB(_rule(id="rule-3", visibility="private")) monkeypatch.setattr(rules_mod, "get_db_session", lambda: FakeCtx(delete_db)) - assert svc.delete_alert_rule("rule-3", "tenant", "owner", ["g1"]) is True + assert svc.delete_alert_rule("rule-3", "tenant", rules_mod.RuleAccessContext(user_id="owner", group_ids=["g1"])) is True assert len(delete_db.deleted) == 1 def test_channel_helpers_and_storage_branches(monkeypatch): svc = channels_mod.ChannelStorageService() + assert channels_mod.ChannelStorageService._page_request(None).offset == 0 + assert channels_mod.ChannelStorageService._access_context("user-1", ["g1"]).group_ids == ["g1"] + existing_access = channels_mod.ChannelAccessContext(user_id="u2", group_ids=["g2"]) + assert channels_mod.ChannelStorageService._access_context(existing_access) is existing_access private_rule = _rule(visibility="private", created_by="owner") private_channel = _channel(visibility="private", created_by="owner") other_channel = _channel(id="chan-2", visibility="private", created_by="other") @@ -304,7 +328,7 @@ def test_channel_helpers_and_storage_branches(monkeypatch): assert svc._rule_channel_compatible(_rule(visibility="public"), group_channel) is True monkeypatch.setattr(channels_mod, "cap_pagination", lambda limit, offset: (limit or 50, offset)) - monkeypatch.setattr(channels_mod, "has_access", lambda visibility, *_args, **_kwargs: visibility != "group") + monkeypatch.setattr(channels_mod, "has_access", lambda check: check.visibility != "group") monkeypatch.setattr(channels_mod, "decrypt_config", lambda cfg: {**cfg, "decrypted": True}) monkeypatch.setattr(channels_mod, "encrypt_config", lambda cfg: {**cfg, "encrypted": True}) monkeypatch.setattr(channels_mod, "channel_to_pydantic", lambda obj: {"id": obj.id, "config": obj.config}) @@ -323,7 +347,11 @@ def test_channel_helpers_and_storage_branches(monkeypatch): db = FakeDB([private_channel, group_channel], private_channel) monkeypatch.setattr(channels_mod, "get_db_session", lambda: FakeCtx(db)) - listed = svc.get_notification_channels("tenant", "user", ["g1"], limit=10, offset=1) + listed = svc.get_notification_channels( + "tenant", + channels_mod.ChannelAccessContext(user_id="user", group_ids=["g1"]), + channels_mod.PageRequest(limit=10, offset=1), + ) assert listed == [ { "id": "chan-1", @@ -368,8 +396,7 @@ def test_channel_helpers_and_storage_branches(monkeypatch): "chan-1", NotificationChannelCreate.model_validate({"name": "Teams", "type": "teams", "config": {"hook": "1"}}), "tenant", - "owner", - ["g1"], + channels_mod.ChannelAccessContext(user_id="owner", group_ids=["g1"]), ) assert updated["id"] == "chan-1" @@ -380,7 +407,7 @@ def test_channel_helpers_and_storage_branches(monkeypatch): "chan-2", NotificationChannelCreate.model_validate({"name": "Nope", "type": "email", "config": {}}), "tenant", - "owner", + channels_mod.ChannelAccessContext(user_id="owner"), ) is None ) @@ -391,23 +418,31 @@ def test_channel_helpers_and_storage_branches(monkeypatch): _channel(id="chan-4", created_by="owner"), ) monkeypatch.setattr(channels_mod, "get_db_session", lambda: FakeCtx(delete_db)) - assert svc.delete_notification_channel("chan-1", "tenant", "owner") is True - assert svc.delete_notification_channel("chan-3", "tenant", "owner") is False + assert svc.delete_notification_channel("chan-1", "tenant", channels_mod.ChannelAccessContext(user_id="owner")) is True + assert svc.delete_notification_channel("chan-3", "tenant", channels_mod.ChannelAccessContext(user_id="owner")) is False owner_db = FakeDB(_channel(id="chan-3", created_by="other"), _channel(id="chan-4", created_by="owner")) monkeypatch.setattr(channels_mod, "get_db_session", lambda: FakeCtx(owner_db)) - assert svc.is_notification_channel_owner("chan-3", "tenant", "owner") is False - assert svc.is_notification_channel_owner("chan-4", "tenant", "owner") is True + assert svc.is_notification_channel_owner("chan-3", "tenant", channels_mod.ChannelAccessContext(user_id="owner")) is False + assert svc.is_notification_channel_owner("chan-4", "tenant", channels_mod.ChannelAccessContext(user_id="owner")) is True - monkeypatch.setattr(svc, "get_notification_channel", lambda *_args, **_kwargs: None) - assert svc.test_notification_channel("chan-1", "tenant", "owner") == { + monkeypatch.setattr( + channels_mod.ChannelStorageService, + "get_notification_channel", + staticmethod(lambda *_args, **_kwargs: None), + ) + assert svc.test_notification_channel("chan-1", "tenant", channels_mod.ChannelAccessContext(user_id="owner")) == { "success": False, "error": "Channel not found", } monkeypatch.setattr( - svc, "get_notification_channel", lambda *_args, **_kwargs: SimpleNamespace(name="Slack", type="slack") + channels_mod.ChannelStorageService, + "get_notification_channel", + staticmethod(lambda *_args, **_kwargs: SimpleNamespace(name="Slack", type="slack")), ) - assert svc.test_notification_channel("chan-1", "tenant", "owner")["success"] is True + assert svc.test_notification_channel("chan-1", "tenant", channels_mod.ChannelAccessContext(user_id="owner"))[ + "success" + ] is True rule_with_specific = _rule(id="rule-a", notification_channels=["chan-1", "missing", "chan-2"], visibility="private") rule_no_specific = _rule( @@ -452,7 +487,7 @@ def test_rule_storage_additional_edges(monkeypatch): monkeypatch.setattr(rules_mod, "get_db_session", lambda: FakeCtx(db)) assert svc.get_alert_rule("missing", "tenant", "user", ["g1"]) is None - monkeypatch.setattr(rules_mod, "has_access", lambda *_args, **_kwargs: True) + monkeypatch.setattr(rules_mod, "has_access", lambda _check: True) db = FakeDB(_rule(id="r-visible", name="Visible")) monkeypatch.setattr(rules_mod, "get_db_session", lambda: FakeCtx(db)) assert svc.get_alert_rule("r-visible", "tenant", "user", ["g1"]) == { @@ -470,13 +505,12 @@ def test_rule_storage_additional_edges(monkeypatch): {"name": "New", "expression": "up", "severity": "warning", "groupName": "g"} ), "tenant", - "user", - ["g1"], + rules_mod.RuleAccessContext(user_id="user", group_ids=["g1"]), ) is None ) - monkeypatch.setattr(rules_mod, "has_access", lambda *_args, **_kwargs: False) + monkeypatch.setattr(rules_mod, "has_access", lambda _check: False) db = FakeDB(_rule(id="r-no-read")) monkeypatch.setattr(rules_mod, "get_db_session", lambda: FakeCtx(db)) assert ( @@ -486,13 +520,12 @@ def test_rule_storage_additional_edges(monkeypatch): {"name": "New", "expression": "up", "severity": "warning", "groupName": "g"} ), "tenant", - "user", - ["g1"], + rules_mod.RuleAccessContext(user_id="user", group_ids=["g1"]), ) is None ) - monkeypatch.setattr(rules_mod, "has_access", lambda *_args, **kwargs: not kwargs.get("require_write", False)) + monkeypatch.setattr(rules_mod, "has_access", lambda check: not check.require_write) db = FakeDB(_rule(id="r-no-write")) monkeypatch.setattr(rules_mod, "get_db_session", lambda: FakeCtx(db)) assert ( @@ -502,15 +535,14 @@ def test_rule_storage_additional_edges(monkeypatch): {"name": "New", "expression": "up", "severity": "warning", "groupName": "g"} ), "tenant", - "user", - ["g1"], + rules_mod.RuleAccessContext(user_id="user", group_ids=["g1"]), ) is None ) db = FakeDB(None) monkeypatch.setattr(rules_mod, "get_db_session", lambda: FakeCtx(db)) - assert svc.delete_alert_rule("missing", "tenant", "user", ["g1"]) is False + assert svc.delete_alert_rule("missing", "tenant", rules_mod.RuleAccessContext(user_id="user", group_ids=["g1"])) is False def test_incident_run_async_falls_back_to_new_loop(monkeypatch): @@ -575,11 +607,13 @@ def __init__(self, shared_groups): assert incidents_core_mod._is_alert_suppressed({"status": {"state": "suppressed"}}) is True assert ( incidents_mod._incident_access_allowed( - visibility="group", - creator_id="owner", - user_id="user", - shared_group_ids=["g1"], - user_group_ids=["g1"], + incidents_mod.AccessCheck( + visibility="group", + created_by="owner", + user_id="user", + shared_group_ids=["g1"], + user_group_ids=["g1"], + ) ) is True ) @@ -660,7 +694,7 @@ def test_incident_update_private_assignment_guard(monkeypatch): db = FakeDB(incident) monkeypatch.setattr(incidents_mod, "get_db_session", lambda: FakeCtx(db)) monkeypatch.setattr(incidents_mod, "normalize_storage_visibility", lambda value: value) - monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda **_kwargs: True) + monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda _check: True) with pytest.raises(HTTPException) as exc: svc.update_incident( "inc-1", @@ -701,7 +735,7 @@ def test_incident_storage_additional_edges(monkeypatch): ) summary_db = FakeDB([summary_incident]) monkeypatch.setattr(incidents_mod, "get_db_session", lambda: FakeCtx(summary_db)) - monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda **_kwargs: False) + monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda _check: False) summary = svc.get_incident_summary("tenant", "user", ["g1"]) assert summary["open_total"] == 0 @@ -749,7 +783,7 @@ def test_incident_storage_additional_edges(monkeypatch): list_db = FakeDB([incident_private]) monkeypatch.setattr(incidents_mod, "get_db_session", lambda: FakeCtx(list_db)) monkeypatch.setattr(incidents_mod, "cap_pagination", lambda limit, offset: (limit or 50, offset)) - monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda **_kwargs: False) + monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda _check: False) assert svc.list_incidents("tenant", "user", ["g1"]) == [] incident_public = SimpleNamespace( @@ -760,7 +794,7 @@ def test_incident_storage_additional_edges(monkeypatch): ) list_db = FakeDB([incident_public]) monkeypatch.setattr(incidents_mod, "get_db_session", lambda: FakeCtx(list_db)) - monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda **_kwargs: True) + monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda _check: True) monkeypatch.setattr(incidents_mod, "incident_to_pydantic", lambda incident: {"id": incident.id}) assert svc.list_incidents("tenant", "user", ["g1"], group_id=None) == [{"id": "inc-public"}] assert svc.list_incidents("tenant", "user", ["g1"], group_id="g1") == [] @@ -771,9 +805,13 @@ def test_incident_storage_additional_edges(monkeypatch): ) get_db = FakeDB(invalid_visibility_incident) monkeypatch.setattr(incidents_mod, "get_db_session", lambda: FakeCtx(get_db)) - monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda **_kwargs: True) + monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda _check: True) monkeypatch.setattr(incidents_mod, "incident_to_pydantic", lambda incident: {"id": incident.id}) - assert svc.get_incident_for_user("inc-invalid-visibility", "tenant", user_id="user") == { + assert svc.get_incident_for_user( + "inc-invalid-visibility", + "tenant", + incidents_mod.IncidentAccessContext(user_id="user"), + ) == { "id": "inc-invalid-visibility" } @@ -789,7 +827,7 @@ def test_incident_storage_additional_edges(monkeypatch): update_db = FakeDB(incident_for_update) monkeypatch.setattr(incidents_mod, "get_db_session", lambda: FakeCtx(update_db)) monkeypatch.setattr(incidents_mod, "normalize_storage_visibility", lambda value: value) - monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda **_kwargs: True) + monkeypatch.setattr(incidents_mod, "_incident_access_allowed", lambda _check: True) monkeypatch.setattr( incidents_mod, "incident_to_pydantic", diff --git a/tests/test_storage_tenant_isolation_matrix.py b/tests/test_storage_tenant_isolation_matrix.py index 39745a4..85afdd1 100644 --- a/tests/test_storage_tenant_isolation_matrix.py +++ b/tests/test_storage_tenant_isolation_matrix.py @@ -24,6 +24,7 @@ from models.alerting.channels import ChannelType, NotificationChannelCreate from models.alerting.incidents import AlertIncidentUpdateRequest from models.alerting.rules import AlertRuleCreate, RuleSeverity +from services.storage.incidents import IncidentAccessContext from services.storage_db_service import DatabaseStorageService @@ -91,7 +92,14 @@ def test_incident_tenant_isolation_matrix(): assert all(inc.alert_name == "BOnlyAlert" for inc in incidents_b) incident_a_id = incidents_a[0].id - assert service.get_incident_for_user(incident_a_id, tenant_b, user_b, []) is None + assert ( + service.get_incident_for_user( + incident_a_id, + tenant_b, + IncidentAccessContext(user_id=user_b, group_ids=[]), + ) + is None + ) assert ( service.update_incident( incident_a_id, @@ -351,7 +359,14 @@ def test_group_incident_requires_active_group_membership_even_for_creator(): hidden_without_group = service.list_incidents(tenant_id, owner_id, group_ids=[]) assert hidden_without_group == [] - assert service.get_incident_for_user(incident_id, tenant_id, owner_id, group_ids=[]) is None + assert ( + service.get_incident_for_user( + incident_id, + tenant_id, + IncidentAccessContext(user_id=owner_id, group_ids=[]), + ) + is None + ) assert ( service.update_incident( incident_id, From fbd48f0c245dc9633a5c07279e80956d68100640 Mon Sep 17 00:00:00 2001 From: stefankumaraaisnghe Date: Sun, 19 Apr 2026 17:08:01 +1000 Subject: [PATCH 04/20] (track) missed tracking a file --- ...st_regression_refactor_coverage_fillers.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/test_regression_refactor_coverage_fillers.py diff --git a/tests/test_regression_refactor_coverage_fillers.py b/tests/test_regression_refactor_coverage_fillers.py new file mode 100644 index 0000000..4439c60 --- /dev/null +++ b/tests/test_regression_refactor_coverage_fillers.py @@ -0,0 +1,95 @@ +""" +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 pytest + +try: + from ._env import ensure_test_env +except ImportError: + from tests._env import ensure_test_env + +ensure_test_env() + +from routers.observability import incidents as incidents_router +from services import alertmanager_service as alertmanager_mod +from services.jira_service import _coerce_issue_options +from services.notification import validators as notification_validators + + +@pytest.mark.asyncio +async def test_incident_assignment_email_task_wraps_payload(monkeypatch: pytest.MonkeyPatch) -> None: + captured = {} + + async def _send(payload): + captured["payload"] = payload + return True + + monkeypatch.setattr(incidents_router.notification_service, "send_incident_assignment_email", _send) + result = await incidents_router._send_incident_assignment_email_task( + recipient_email="ops@example.com", + incident_title="CPUHigh", + incident_status="open", + incident_severity="critical", + actor="alice", + ) + assert result is True + assert captured["payload"].recipient_email == "ops@example.com" + + direct_payload = incidents_router.IncidentAssignmentEmail( + recipient_email="direct@example.com", + incident_title="MemoryHigh", + incident_status="open", + incident_severity="warning", + actor="bob", + ) + result = await incidents_router._send_incident_assignment_email_task(payload=direct_payload) + assert result is True + assert captured["payload"].recipient_email == "direct@example.com" + + +@pytest.mark.asyncio +async def test_alertmanager_get_alerts_legacy_kwargs_path(monkeypatch: pytest.MonkeyPatch) -> None: + svc = alertmanager_mod.AlertManagerService() + captured = {} + + async def _get_alerts_ops(_service, query): + captured["query"] = query + return [] + + monkeypatch.setattr(alertmanager_mod, "get_alerts_ops", _get_alerts_ops) + await svc.get_alerts(filter_labels={"alertname": "CPUHigh"}, active=True, silenced=False, inhibited=False) + query = captured["query"] + assert query.filter_labels == {"alertname": "CPUHigh"} + assert query.active is True + assert query.silenced is False + assert query.inhibited is False + + +def test_jira_issue_option_coercion_default_and_legacy_overrides() -> None: + defaults = _coerce_issue_options(None, {}) + assert defaults.description is None + assert defaults.issue_type == "Task" + assert defaults.priority is None + + legacy = _coerce_issue_options( + None, + {"description": "hello", "issue_type": "Bug", "priority": "High"}, + ) + assert legacy.description == "hello" + assert legacy.issue_type == "Bug" + assert legacy.priority == "High" + + +def test_webhook_validator_accepts_valid_url_without_errors() -> None: + errors = notification_validators.validate_channel_config( + "webhook", + {"url": "https://example.com/hook"}, + ) + assert errors == [] From 7699a77f6039877d1c792ac4bfe68d071d8da9ad Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 19 Apr 2026 14:32:24 +0530 Subject: [PATCH 05/20] refactor(notifier): remove legacy incident/alert/jira compat paths --- routers/observability/alerts/alerts_routes.py | 11 +- routers/observability/incidents.py | 32 ++--- services/alertmanager_service.py | 16 +-- services/jira_service.py | 33 +---- tests/test_incidents_router.py | 7 +- ...st_observability_router_endpoints_edges.py | 17 ++- ...on_incident_assignment_background_tasks.py | 4 +- ...gression_incident_resolution_guardrails.py | 14 +- ...egression_incident_status_and_jira_sync.py | 2 +- ...st_regression_refactor_coverage_fillers.py | 126 ++++++++++++++++-- 10 files changed, 165 insertions(+), 97 deletions(-) diff --git a/routers/observability/alerts/alerts_routes.py b/routers/observability/alerts/alerts_routes.py index 51c075c..04f009d 100644 --- a/routers/observability/alerts/alerts_routes.py +++ b/routers/observability/alerts/alerts_routes.py @@ -10,6 +10,7 @@ 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 @@ -43,10 +44,12 @@ async def list_alerts( ) -> list[Alert]: labels = alertmanager_service.parse_filter_labels(query.filter_labels) alerts = await alertmanager_service.get_alerts( - filter_labels=labels or {}, - active=query.active, - silenced=query.silenced, - inhibited=query.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") diff --git a/routers/observability/incidents.py b/routers/observability/incidents.py index 3848f3a..1409769 100644 --- a/routers/observability/incidents.py +++ b/routers/observability/incidents.py @@ -25,6 +25,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, @@ -72,17 +73,8 @@ def _status_value(value: object) -> str: async def _send_incident_assignment_email_task( - payload: IncidentAssignmentEmail | None = None, - **legacy_kwargs: object, + payload: IncidentAssignmentEmail, ) -> bool: - if payload is None: - payload = IncidentAssignmentEmail( - recipient_email=str(legacy_kwargs.get("recipient_email") or ""), - incident_title=str(legacy_kwargs.get("incident_title") or ""), - incident_status=str(legacy_kwargs.get("incident_status") or ""), - incident_severity=str(legacy_kwargs.get("incident_severity") or ""), - actor=str(legacy_kwargs.get("actor") or ""), - ) return await notification_service.send_incident_assignment_email( payload ) @@ -97,13 +89,15 @@ async def _ensure_resolve_allowed(payload: AlertIncidentUpdateRequest, existing: if existing_incident_key: active_alerts = [ alert - for alert in (await alertmanager_service.get_alerts(active=True)) + 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( - filter_labels={"fingerprint": existing.fingerprint}, - active=True, + AlertQuery( + filter_labels={"fingerprint": existing.fingerprint}, + active=True, + ) ) except httpx.HTTPError: active_alerts = [] @@ -165,11 +159,13 @@ async def _record_assignment_change( if recipient_email: background_tasks.add_task( _send_incident_assignment_email_task, - recipient_email=recipient_email, - incident_title=updated.alert_name, - incident_status=updated.status, - incident_severity=updated.severity, - actor=actor_label, + IncidentAssignmentEmail( + recipient_email=recipient_email, + incident_title=updated.alert_name, + incident_status=updated.status, + incident_severity=updated.severity, + actor=actor_label, + ), ) return logger.warning( diff --git a/services/alertmanager_service.py b/services/alertmanager_service.py index 4f134e4..0327eed 100644 --- a/services/alertmanager_service.py +++ b/services/alertmanager_service.py @@ -254,22 +254,8 @@ async def _async_bound(*args: object, **kwargs: object) -> Any: async def get_alerts( self, query: AlertQuery | None = None, - **legacy_kwargs: object, ) -> list[Alert]: - effective_query = query - if effective_query is None and legacy_kwargs: - filter_labels_raw = legacy_kwargs.get("filter_labels") - filter_labels = filter_labels_raw if isinstance(filter_labels_raw, dict) else {} - active_value = legacy_kwargs.get("active") - silenced_value = legacy_kwargs.get("silenced") - inhibited_value = legacy_kwargs.get("inhibited") - effective_query = AlertQuery( - filter_labels={str(key): str(value) for key, value in filter_labels.items()}, - active=active_value if isinstance(active_value, bool) else None, - silenced=silenced_value if isinstance(silenced_value, bool) else None, - inhibited=inhibited_value if isinstance(inhibited_value, bool) else None, - ) - return await get_alerts_ops(self, effective_query) + 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/jira_service.py b/services/jira_service.py index 617055a..6d1c446 100644 --- a/services/jira_service.py +++ b/services/jira_service.py @@ -78,35 +78,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): @@ -210,7 +187,7 @@ async def _post(self, path: str, payload: JSONDict, credentials: Credentials = N return await self._request(JiraRequest(method="POST", path=path, credentials=credentials, payload=payload)) async def create_issue(self, request: JiraIssueCreateRequest) -> JSONDict: - issue_options = _coerce_issue_options(request.options, {}) + issue_options = _coerce_issue_options(request.options) fields: JSONDict = { "project": {"key": request.project_key}, "summary": request.summary, diff --git a/tests/test_incidents_router.py b/tests/test_incidents_router.py index 3a11e14..38984b7 100644 --- a/tests/test_incidents_router.py +++ b/tests/test_incidents_router.py @@ -94,9 +94,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 diff --git a/tests/test_observability_router_endpoints_edges.py b/tests/test_observability_router_endpoints_edges.py index e5c0e42..6ca04fa 100644 --- a/tests/test_observability_router_endpoints_edges.py +++ b/tests/test_observability_router_endpoints_edges.py @@ -416,7 +416,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): @@ -1446,7 +1446,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) @@ -1459,7 +1459,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")) @@ -1791,7 +1791,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) @@ -1966,9 +1966,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 [] @@ -2019,7 +2022,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"}), diff --git a/tests/test_regression_incident_assignment_background_tasks.py b/tests/test_regression_incident_assignment_background_tasks.py index 9638dfc..cf429d6 100644 --- a/tests/test_regression_incident_assignment_background_tasks.py +++ b/tests/test_regression_incident_assignment_background_tasks.py @@ -77,7 +77,7 @@ async def _move(_incident, **_kwargs): assert "assigned incident to Bob " in sync_calls[0] assert move_calls == ["in-progress"] assert len(background.tasks) == 1 - assert background.tasks[0].kwargs["recipient_email"] == "bob@example.com" + assert background.tasks[0].args[0].recipient_email == "bob@example.com" @pytest.mark.asyncio @@ -241,4 +241,4 @@ async def _move(_incident, **_kwargs): assert len(sync_calls) == 1 assert move_calls == ["in-progress"] assert len(background.tasks) == 1 - assert background.tasks[0].kwargs["recipient_email"] == "ops@example.com" + assert background.tasks[0].args[0].recipient_email == "ops@example.com" diff --git a/tests/test_regression_incident_resolution_guardrails.py b/tests/test_regression_incident_resolution_guardrails.py index 248fec3..dbf509f 100644 --- a/tests/test_regression_incident_resolution_guardrails.py +++ b/tests/test_regression_incident_resolution_guardrails.py @@ -39,7 +39,7 @@ async def test_resolve_is_blocked_when_matching_active_alert_exists_by_incident_ monkeypatch.setattr(incidents_router.storage_service, "get_incident_for_user", lambda *_args: existing) monkeypatch.setattr(incidents_router, "incident_key_from_labels", lambda labels: labels.get("alertname", "")) - async def _get_alerts(**_kwargs): + async def _get_alerts(*_args, **_kwargs): return [SimpleNamespace(labels={"alertname": "DiskFull"})] monkeypatch.setattr(incidents_router.alertmanager_service, "get_alerts", _get_alerts) @@ -61,14 +61,14 @@ async def test_resolve_is_blocked_when_fingerprint_query_finds_active_alert(monk user = token_data() existing = alert_incident(incident_id="inc-r2", status=IncidentStatus.OPEN, labels={}, fingerprint="fp-r2") - get_alerts_calls: list[dict[str, object]] = [] + get_alerts_calls: list[object] = [] monkeypatch.setattr(incidents_router, "run_in_threadpool", run_in_threadpool_inline) monkeypatch.setattr(incidents_router.storage_service, "get_incident_for_user", lambda *_args: existing) monkeypatch.setattr(incidents_router, "incident_key_from_labels", lambda _labels: None) - async def _get_alerts(**kwargs): - get_alerts_calls.append(kwargs) + async def _get_alerts(*args, **_kwargs): + get_alerts_calls.extend(args) return [SimpleNamespace(labels={"alertname": "Anything"})] monkeypatch.setattr(incidents_router.alertmanager_service, "get_alerts", _get_alerts) @@ -82,7 +82,7 @@ async def _get_alerts(**kwargs): ) assert exc.value.status_code == 400 - assert get_alerts_calls[0]["filter_labels"] == {"fingerprint": "fp-r2"} + assert getattr(get_alerts_calls[0], "filter_labels", {}) == {"fingerprint": "fp-r2"} @pytest.mark.asyncio @@ -96,7 +96,7 @@ async def test_resolve_continues_when_alertmanager_lookup_errors(monkeypatch: py monkeypatch.setattr(incidents_router.storage_service, "update_incident", lambda *_args, **_kwargs: updated) monkeypatch.setattr(incidents_router, "incident_key_from_labels", lambda labels: labels.get("alertname", "")) - async def _get_alerts(**_kwargs): + async def _get_alerts(*_args, **_kwargs): raise httpx.RequestError("boom", request=httpx.Request("GET", "https://alertmanager.example")) monkeypatch.setattr(incidents_router.alertmanager_service, "get_alerts", _get_alerts) @@ -128,7 +128,7 @@ async def test_resolve_moves_ticket_done_when_no_active_alerts(monkeypatch: pyte monkeypatch.setattr(incidents_router.storage_service, "update_incident", lambda *_args, **_kwargs: updated) monkeypatch.setattr(incidents_router, "incident_key_from_labels", lambda labels: labels.get("alertname", "")) - async def _get_alerts(**_kwargs): + async def _get_alerts(*_args, **_kwargs): return [] async def _done(*_args, **_kwargs): diff --git a/tests/test_regression_incident_status_and_jira_sync.py b/tests/test_regression_incident_status_and_jira_sync.py index 4dbdfd1..df65902 100644 --- a/tests/test_regression_incident_status_and_jira_sync.py +++ b/tests/test_regression_incident_status_and_jira_sync.py @@ -84,7 +84,7 @@ async def _noop(*_args, **_kwargs): monkeypatch.setattr(incidents_router, "move_incident_ticket_to_todo", _noop) monkeypatch.setattr(incidents_router, "move_incident_ticket_to_in_progress", _noop) - async def _get_alerts(**_kwargs): + async def _get_alerts(*_args, **_kwargs): return [] monkeypatch.setattr(incidents_router.alertmanager_service, "get_alerts", _get_alerts) diff --git a/tests/test_regression_refactor_coverage_fillers.py b/tests/test_regression_refactor_coverage_fillers.py index 4439c60..a2f87d7 100644 --- a/tests/test_regression_refactor_coverage_fillers.py +++ b/tests/test_regression_refactor_coverage_fillers.py @@ -8,6 +8,9 @@ from __future__ import annotations +from datetime import datetime, timezone +from types import SimpleNamespace + import pytest try: @@ -18,8 +21,9 @@ ensure_test_env() from routers.observability import incidents as incidents_router +from services.alerting.alerts_ops import AlertQuery from services import alertmanager_service as alertmanager_mod -from services.jira_service import _coerce_issue_options +from services.jira_service import JiraIssueCreateOptions, _coerce_issue_options from services.notification import validators as notification_validators @@ -32,13 +36,16 @@ async def _send(payload): return True monkeypatch.setattr(incidents_router.notification_service, "send_incident_assignment_email", _send) - result = await incidents_router._send_incident_assignment_email_task( + payload = incidents_router.IncidentAssignmentEmail( recipient_email="ops@example.com", incident_title="CPUHigh", incident_status="open", incident_severity="critical", actor="alice", ) + result = await incidents_router._send_incident_assignment_email_task( + payload, + ) assert result is True assert captured["payload"].recipient_email == "ops@example.com" @@ -55,7 +62,7 @@ async def _send(payload): @pytest.mark.asyncio -async def test_alertmanager_get_alerts_legacy_kwargs_path(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_alertmanager_get_alerts_query_path(monkeypatch: pytest.MonkeyPatch) -> None: svc = alertmanager_mod.AlertManagerService() captured = {} @@ -64,7 +71,9 @@ async def _get_alerts_ops(_service, query): return [] monkeypatch.setattr(alertmanager_mod, "get_alerts_ops", _get_alerts_ops) - await svc.get_alerts(filter_labels={"alertname": "CPUHigh"}, active=True, silenced=False, inhibited=False) + await svc.get_alerts( + AlertQuery(filter_labels={"alertname": "CPUHigh"}, active=True, silenced=False, inhibited=False) + ) query = captured["query"] assert query.filter_labels == {"alertname": "CPUHigh"} assert query.active is True @@ -72,19 +81,112 @@ async def _get_alerts_ops(_service, query): assert query.inhibited is False -def test_jira_issue_option_coercion_default_and_legacy_overrides() -> None: - defaults = _coerce_issue_options(None, {}) +@pytest.mark.asyncio +async def test_alertmanager_get_alerts_with_none_query(monkeypatch: pytest.MonkeyPatch) -> None: + svc = alertmanager_mod.AlertManagerService() + captured = {} + + async def _get_alerts_ops(_service, query): + captured["query"] = query + return [] + + monkeypatch.setattr(alertmanager_mod, "get_alerts_ops", _get_alerts_ops) + await svc.get_alerts() + assert captured["query"] is None + + +@pytest.mark.asyncio +async def test_ensure_resolve_allowed_returns_when_not_resolved(monkeypatch: pytest.MonkeyPatch) -> None: + payload = incidents_router.AlertIncidentUpdateRequest(status="open") + existing = incidents_router.AlertIncident( + id="incident-1", + fingerprint="fp-1", + alertName="AlertOne", + severity="critical", + status="open", + assignee=None, + notes=[], + labels={}, + annotations={}, + visibility="public", + sharedGroupIds=[], + jiraTicketKey=None, + jiraTicketUrl=None, + jiraIntegrationId=None, + startsAt=datetime.now(tz=timezone.utc), + lastSeenAt=datetime.now(tz=timezone.utc), + resolvedAt=None, + createdAt=datetime.now(tz=timezone.utc), + updatedAt=datetime.now(tz=timezone.utc), + userManaged=False, + hideWhenResolved=False, + ) + called = False + + async def _get_alerts(*args, **kwargs): + nonlocal called + called = True + return [] + + monkeypatch.setattr(incidents_router.alertmanager_service, "get_alerts", _get_alerts) + await incidents_router._ensure_resolve_allowed(payload, existing) + assert called is False + + +@pytest.mark.asyncio +async def test_ensure_resolve_allowed_raises_if_active_alert_exists(monkeypatch: pytest.MonkeyPatch) -> None: + payload = incidents_router.AlertIncidentUpdateRequest(status="resolved") + existing = incidents_router.AlertIncident( + id="incident-2", + fingerprint="fp-2", + alertName="AlertTwo", + severity="critical", + status="open", + assignee=None, + notes=[], + labels={"alertname": "AlertTwo", "tenant": "t1"}, + annotations={}, + visibility="public", + sharedGroupIds=[], + jiraTicketKey=None, + jiraTicketUrl=None, + jiraIntegrationId=None, + startsAt=datetime.now(tz=timezone.utc), + lastSeenAt=datetime.now(tz=timezone.utc), + resolvedAt=None, + createdAt=datetime.now(tz=timezone.utc), + updatedAt=datetime.now(tz=timezone.utc), + userManaged=False, + hideWhenResolved=False, + ) + + async def _get_alerts(*args, **kwargs): + return [SimpleNamespace(labels={"alertname": "AlertTwo", "tenant": "t1"})] + + monkeypatch.setattr(incidents_router.alertmanager_service, "get_alerts", _get_alerts) + with pytest.raises(incidents_router.HTTPException, match="Cannot mark resolved"): + await incidents_router._ensure_resolve_allowed(payload, existing) + + +def test_jira_issue_option_coercion_from_issue() -> None: + result = _coerce_issue_options("User story") + assert result.description == "User story" + assert result.issue_type == "Task" + assert result.priority is None + + +def test_jira_issue_option_coercion_default_and_explicit_options() -> None: + defaults = _coerce_issue_options(None) assert defaults.description is None assert defaults.issue_type == "Task" assert defaults.priority is None - legacy = _coerce_issue_options( - None, - {"description": "hello", "issue_type": "Bug", "priority": "High"}, + explicit = _coerce_issue_options( + JiraIssueCreateOptions(description="hello", issue_type="Bug", priority="High") ) - assert legacy.description == "hello" - assert legacy.issue_type == "Bug" - assert legacy.priority == "High" + assert explicit.description == "hello" + assert explicit.issue_type == "Bug" + assert explicit.priority == "High" def test_webhook_validator_accepts_valid_url_without_errors() -> None: From 0e4616663a588f117a689a500660e4719807a4f6 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 19 Apr 2026 15:08:58 +0530 Subject: [PATCH 06/20] refactor: remove notifier legacy shims and align tests --- middleware/error_handlers.py | 25 +---- routers/observability/alerts/channels.py | 4 +- routers/observability/alerts/rules.py | 21 ++-- routers/observability/alerts/silences.py | 8 +- routers/observability/incidents.py | 16 ++-- services/notification/email_providers.py | 86 +++-------------- services/notification/transport.py | 83 +++------------- services/notification_service.py | 40 +------- services/storage/incidents.py | 96 +++++++------------ tests/test_alerting_ops_and_transport.py | 9 +- tests/test_incidents_router.py | 10 +- tests/test_middleware_core.py | 5 +- ...test_notification_and_helper_edges_more.py | 50 ++++++++-- tests/test_notification_email_providers.py | 48 +++++----- .../test_notification_service_email_edges.py | 12 ++- tests/test_notification_transport.py | 10 +- tests/test_refactor_compat_coverage_edges.py | 60 ++++++------ ...t_storage_incidents_helpers_and_service.py | 9 +- 18 files changed, 218 insertions(+), 374 deletions(-) diff --git a/middleware/error_handlers.py b/middleware/error_handlers.py index 233955b..6a61e3a 100644 --- a/middleware/error_handlers.py +++ b/middleware/error_handlers.py @@ -13,7 +13,7 @@ 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 +22,6 @@ logger = logging.getLogger(__name__) RouteResult = TypeVar("RouteResult") -_MISSING = object() @dataclass(frozen=True) @@ -36,21 +35,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 +48,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/routers/observability/alerts/channels.py b/routers/observability/alerts/channels.py index 8ccb427..c9afc5e 100644 --- a/routers/observability/alerts/channels.py +++ b/routers/observability/alerts/channels.py @@ -19,7 +19,7 @@ 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 @@ -225,7 +225,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( diff --git a/routers/observability/alerts/rules.py b/routers/observability/alerts/rules.py index e5b3a7c..e119921 100644 --- a/routers/observability/alerts/rules.py +++ b/routers/observability/alerts/rules.py @@ -28,7 +28,7 @@ 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 @@ -213,7 +213,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( @@ -239,7 +241,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"), @@ -268,7 +272,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( @@ -295,8 +301,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, @@ -393,7 +398,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(...), @@ -429,7 +434,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, diff --git a/routers/observability/alerts/silences.py b/routers/observability/alerts/silences.py index 9d18d18..1e06ebb 100644 --- a/routers/observability/alerts/silences.py +++ b/routers/observability/alerts/silences.py @@ -14,7 +14,7 @@ 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 @@ -92,8 +92,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, @@ -182,8 +181,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/incidents.py b/routers/observability/incidents.py index 1409769..60df0a2 100644 --- a/routers/observability/incidents.py +++ b/routers/observability/incidents.py @@ -35,7 +35,6 @@ ) 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 @@ -132,11 +131,10 @@ async def _record_assignment_change( storage_service.update_incident, incident_id, current_user.tenant_id, + current_user.user_id, AlertIncidentUpdateRequest.model_validate({"note": assignment_note}), - actor=IncidentActorContext( - user_id=current_user.user_id, - group_ids=group_ids, - ), + group_ids, + getattr(current_user, "email", None), ) except SQLAlchemyError: logger.exception("Failed to record assignment note for incident %s", incident_id) @@ -302,12 +300,10 @@ 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") diff --git a/services/notification/email_providers.py b/services/notification/email_providers.py index 40d63ce..37c3e67 100644 --- a/services/notification/email_providers.py +++ b/services/notification/email_providers.py @@ -16,7 +16,6 @@ from dataclasses import dataclass from email.message import EmailMessage from email.utils import parseaddr -from typing import cast import aiosmtplib import httpx @@ -38,65 +37,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: @@ -125,11 +74,9 @@ def build_smtp_message(payload: EmailDeliveryPayload) -> EmailMessage: 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}] @@ -172,11 +119,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 = { @@ -216,24 +161,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/transport.py b/services/notification/transport.py index 04f99ec..e97dc0b 100644 --- a/services/notification/transport.py +++ b/services/notification/transport.py @@ -53,48 +53,6 @@ class HttpPostRequest: retry_on_status: frozenset[int] | set[int] = DEFAULT_RETRY_ON_STATUS -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), - ) - - def _is_transient_http(exc: BaseException, retry_on_status: frozenset[int]) -> bool: if isinstance(exc, httpx.RequestError): return True @@ -146,39 +104,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_service.py b/services/notification_service.py index 3f8e8a3..9a81f30 100644 --- a/services/notification_service.py +++ b/services/notification_service.py @@ -17,8 +17,6 @@ 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 @@ -107,43 +105,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: diff --git a/services/storage/incidents.py b/services/storage/incidents.py index a96b785..5777852 100644 --- a/services/storage/incidents.py +++ b/services/storage/incidents.py @@ -76,43 +76,19 @@ class IncidentAccessContext: require_write: bool = False -def _coerce_incident_list_filters( - filters_or_group_ids: IncidentListFilters | list[str] | None, - legacy_kwargs: dict[str, object], +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: - 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 - 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, ) @@ -228,13 +204,24 @@ def sync_incidents_from_alerts(tenant_id: str, alerts: list[JSONDict], resolve_m _resolve_incidents_without_active_alerts(db, tenant_id, now, active_incident_tokens) @staticmethod - def list_incidents( + 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) @@ -433,28 +420,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 = ( @@ -483,13 +457,13 @@ def update_incident( ): return None - actor_context = IncidentActorContext(user_id=user_id, group_ids=user_group_ids, user_email=user_email) + 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, user_id + payload, incident, previous_status, str(user_id) ) - IncidentStorageService._apply_incident_metadata(payload, incident, user_id, manual_manage_flag) - IncidentStorageService._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) diff --git a/tests/test_alerting_ops_and_transport.py b/tests/test_alerting_ops_and_transport.py index 8aa356e..6c7c3a1 100644 --- a/tests/test_alerting_ops_and_transport.py +++ b/tests/test_alerting_ops_and_transport.py @@ -287,14 +287,19 @@ async def post(self, *_args, **_kwargs): 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_incidents_router.py b/tests/test_incidents_router.py index 38984b7..706216d 100644 --- a/tests/test_incidents_router.py +++ b/tests/test_incidents_router.py @@ -155,10 +155,12 @@ def fake_update_incident(*args, **kwargs): 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_middleware_core.py b/tests/test_middleware_core.py index 9b70781..516e654 100644 --- a/tests/test_middleware_core.py +++ b/tests/test_middleware_core.py @@ -26,6 +26,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 +54,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_notification_and_helper_edges_more.py b/tests/test_notification_and_helper_edges_more.py index b761ea2..f01fd01 100644 --- a/tests/test_notification_and_helper_edges_more.py +++ b/tests/test_notification_and_helper_edges_more.py @@ -284,21 +284,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" @@ -511,12 +528,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(): @@ -681,14 +701,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 ) @@ -701,7 +725,10 @@ async def smtp_os_error(*args, **kwargs): ) 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] @@ -743,7 +770,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 c8217c1..146d7d8 100644 --- a/tests/test_notification_email_providers.py +++ b/tests/test_notification_email_providers.py @@ -40,27 +40,28 @@ async def fail_post(_request): 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): @@ -78,11 +79,13 @@ async def capture_post(request): 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 +97,13 @@ async def capture_post(request): 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 +119,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_service_email_edges.py b/tests/test_notification_service_email_edges.py index 4b4e889..de5f274 100644 --- a/tests/test_notification_service_email_edges.py +++ b/tests/test_notification_service_email_edges.py @@ -21,6 +21,7 @@ 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 @@ -41,10 +42,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 diff --git a/tests/test_notification_transport.py b/tests/test_notification_transport.py index e5705e0..875a959 100644 --- a/tests/test_notification_transport.py +++ b/tests/test_notification_transport.py @@ -31,7 +31,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_refactor_compat_coverage_edges.py b/tests/test_refactor_compat_coverage_edges.py index 79d0569..45108f1 100644 --- a/tests/test_refactor_compat_coverage_edges.py +++ b/tests/test_refactor_compat_coverage_edges.py @@ -22,6 +22,7 @@ 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, JiraIssueCreateRequest, JiraService from services.notification import email_providers, transport @@ -30,8 +31,8 @@ @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") @@ -79,13 +80,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 +106,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 +113,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 +122,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_storage_incidents_helpers_and_service.py b/tests/test_storage_incidents_helpers_and_service.py index cd1cfc0..0ba4ac5 100644 --- a/tests/test_storage_incidents_helpers_and_service.py +++ b/tests/test_storage_incidents_helpers_and_service.py @@ -798,7 +798,7 @@ def _access_probe(_check): assert access_called["value"] is False -def test_update_incident_actor_context_and_legacy_args_require_payload(monkeypatch): +def test_update_incident_uses_explicit_actor_inputs(monkeypatch): service = incidents_mod.IncidentStorageService() row = _incident_row("inc-actor", annotations={}) db = _FakeDB([row]) @@ -815,15 +815,14 @@ def test_update_incident_actor_context_and_legacy_args_require_payload(monkeypat updated = service.update_incident( "inc-actor", "tenant-a", + "u1", payload, - actor=incidents_mod.IncidentActorContext(user_id="u1", group_ids=["g1"], user_email="u1@example.com"), + ["g1"], + "u1@example.com", ) assert updated is not None assert row.status == "open" - with pytest.raises(TypeError, match="payload is required"): - service.update_incident("inc-actor", "tenant-a", "u1") - resolved_row = _incident_row( "inc-resolved", status="resolved", visibility="public", created_by="u1", annotations={} ) From 8b6e119ed145c80ded76ccb0c8f7d9ba86dfda02 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 19 Apr 2026 15:16:17 +0530 Subject: [PATCH 07/20] refactor: remove channel storage legacy argument shims --- services/storage/channels.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/services/storage/channels.py b/services/storage/channels.py index 4dbd435..fdb6a7f 100644 --- a/services/storage/channels.py +++ b/services/storage/channels.py @@ -73,7 +73,7 @@ def _access_context( return ChannelAccessContext(user_id=str(access), group_ids=list(group_ids or [])) @staticmethod - def _page_request(value: PageRequest | list[str] | None) -> PageRequest: + def _page_request(value: PageRequest | None) -> PageRequest: if isinstance(value, PageRequest): return value return PageRequest() @@ -108,14 +108,12 @@ def _rule_channel_compatible(rule: AlertRuleDB, channel: NotificationChannelDB) def get_notification_channels( tenant_id: str, access: ChannelAccessContext | str, - page_or_group_ids: PageRequest | list[str] | None = None, + page: PageRequest | None = None, + group_ids: list[str] | None = None, ) -> list[NotificationChannel]: - context = ChannelStorageService._access_context( - access, - group_ids=page_or_group_ids if isinstance(page_or_group_ids, list) else None, - ) + context = ChannelStorageService._access_context(access, group_ids=group_ids) group_ids = list(context.group_ids or []) - paging = ChannelStorageService._page_request(page_or_group_ids) + paging = ChannelStorageService._page_request(page) capped_limit, capped_offset = cap_pagination(paging.limit, paging.offset) with get_db_session() as db: @@ -146,15 +144,14 @@ def get_notification_channels( return results @staticmethod - def get_notification_channel( + def get_notification_channel( # pylint: disable=too-many-positional-arguments channel_id: str, tenant_id: str, access: ChannelAccessContext | str, - include_sensitive: bool | list[str] | None = False, + group_ids: list[str] | None = None, + include_sensitive: bool = False, ) -> NotificationChannel | None: - legacy_group_ids = include_sensitive if isinstance(include_sensitive, list) else None - include_sensitive_flag = bool(include_sensitive) if not isinstance(include_sensitive, list) else False - context = ChannelStorageService._access_context(access, group_ids=legacy_group_ids) + context = ChannelStorageService._access_context(access, group_ids=group_ids) group_ids = list(context.group_ids or []) with get_db_session() as db: ch = ( @@ -177,7 +174,7 @@ def get_notification_channel( return None raw_cfg = decrypt_config(_config_dict(ch)) ch.config = raw_cfg - return channel_to_pydantic_for_viewer(ch, context.user_id, include_sensitive=include_sensitive_flag) + return channel_to_pydantic_for_viewer(ch, context.user_id, include_sensitive=include_sensitive) @staticmethod def create_notification_channel( From 7061c3fbfd7fcfb6611028c49661b81628efdfe2 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 19 Apr 2026 16:17:54 +0530 Subject: [PATCH 08/20] chore: clean deprecated wording and test literals --- CHANGELOG.md | 1 + services/alerting/integration_security_service.py | 2 +- tests/test_config_validation_edges.py | 4 ++-- tests/test_service_branch_closure_batch.py | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c9bfc7..d27ce5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to this project will be documented in this file. - 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. ## [v0.0.4] - 2026-04-14 diff --git a/services/alerting/integration_security_service.py b/services/alerting/integration_security_service.py index 99c9e11..9e9436f 100644 --- a/services/alerting/integration_security_service.py +++ b/services/alerting/integration_security_service.py @@ -195,7 +195,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 diff --git a/tests/test_config_validation_edges.py b/tests/test_config_validation_edges.py index 9609a7e..b4882b9 100644 --- a/tests/test_config_validation_edges.py +++ b/tests/test_config_validation_edges.py @@ -399,9 +399,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_service_branch_closure_batch.py b/tests/test_service_branch_closure_batch.py index 16a2aeb..f263666 100644 --- a/tests/test_service_branch_closure_batch.py +++ b/tests/test_service_branch_closure_batch.py @@ -330,7 +330,7 @@ def decrypt(self, _payload): monkeypatch.setattr(sec_mod, "Fernet", _DecryptFailFernet) assert sec_mod.decrypt_tenant_secret("enc:abc") is None - tenant = SimpleNamespace(settings={"jira": "legacy"}) + tenant = SimpleNamespace(settings={"jira": "stale-format"}) db = _SeqDB(tenant) monkeypatch.setattr(sec_mod, "get_db_session", lambda: _DBCtx(db)) loaded = sec_mod.load_tenant_jira_config("tenant-a") From 1fef221d4ec292d0258810bde75601ce25822173 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 19 Apr 2026 17:38:14 +0530 Subject: [PATCH 09/20] chore: clean notifier tests and helpers --- routers/observability/alerts/__init__.py | 6 +++++- routers/observability/alerts/access.py | 8 ++++++++ routers/observability/alerts/alerts_routes.py | 8 ++++++++ routers/observability/jira/__init__.py | 6 ++++++ routers/observability/jira/config.py | 8 ++++++++ routers/observability/jira/discovery.py | 8 ++++++++ routers/observability/jira/incident_links.py | 8 ++++++++ routers/observability/jira/integrations.py | 8 ++++++++ routers/observability/jira/shared.py | 6 +++++- services/storage/hidden_entity_storage.py | 10 +++++++++- services/storage/revocation.py | 6 +++++- tests/test_alertmanager_stateful_workflows.py | 6 +++++- tests/test_incidents_recipient_email.py | 6 +++++- tests/test_integration_security_and_jira_service.py | 4 ++-- tests/test_integration_security_service.py | 2 +- tests/test_middleware_rate_limit_and_database_edges.py | 2 +- ..._middleware_rate_limit_database_resilience_edges.py | 2 +- tests/test_notification_payloads.py | 2 +- tests/test_notification_service_email_edges.py | 2 +- tests/test_openapi_middleware.py | 6 +++--- tests/test_service_branch_closure_batch.py | 8 ++++---- tests/test_silences_ops.py | 2 +- tests/test_storage_and_alertmanager_edges.py | 2 -- ...test_storage_rules_channels_and_incident_helpers.py | 2 +- tests/test_vault_client_edges.py | 6 +++++- 25 files changed, 109 insertions(+), 25 deletions(-) diff --git a/routers/observability/alerts/__init__.py b/routers/observability/alerts/__init__.py index ad57e92..b29959d 100644 --- a/routers/observability/alerts/__init__.py +++ b/routers/observability/alerts/__init__.py @@ -1,5 +1,9 @@ """ -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..6426a76 100644 --- a/routers/observability/alerts/access.py +++ b/routers/observability/alerts/access.py @@ -1,3 +1,11 @@ +""" +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 04f009d..76e0b63 100644 --- a/routers/observability/alerts/alerts_routes.py +++ b/routers/observability/alerts/alerts_routes.py @@ -1,3 +1,11 @@ +""" +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 diff --git a/routers/observability/jira/__init__.py b/routers/observability/jira/__init__.py index 2b9626d..1b1be17 100644 --- a/routers/observability/jira/__init__.py +++ b/routers/observability/jira/__init__.py @@ -1,5 +1,11 @@ """ 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 1c6c130..249b1ff 100644 --- a/routers/observability/jira/config.py +++ b/routers/observability/jira/config.py @@ -1,3 +1,11 @@ +""" +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 diff --git a/routers/observability/jira/discovery.py b/routers/observability/jira/discovery.py index 2aafd1d..0218368 100644 --- a/routers/observability/jira/discovery.py +++ b/routers/observability/jira/discovery.py @@ -1,3 +1,11 @@ +""" +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 b41750f..fc4220d 100644 --- a/routers/observability/jira/incident_links.py +++ b/routers/observability/jira/incident_links.py @@ -1,3 +1,11 @@ +""" +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 diff --git a/routers/observability/jira/integrations.py b/routers/observability/jira/integrations.py index 86cffbf..3e28ec3 100644 --- a/routers/observability/jira/integrations.py +++ b/routers/observability/jira/integrations.py @@ -1,3 +1,11 @@ +""" +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..d75093e 100644 --- a/routers/observability/jira/shared.py +++ b/routers/observability/jira/shared.py @@ -1,5 +1,9 @@ """ -Shared Jira router state and helpers. +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/storage/hidden_entity_storage.py b/services/storage/hidden_entity_storage.py index da4506b..f42455c 100644 --- a/services/storage/hidden_entity_storage.py +++ b/services/storage/hidden_entity_storage.py @@ -1,4 +1,12 @@ -"""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 diff --git a/services/storage/revocation.py b/services/storage/revocation.py index 1fd843f..3e1037c 100644 --- a/services/storage/revocation.py +++ b/services/storage/revocation.py @@ -1,5 +1,9 @@ """ -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 diff --git a/tests/test_alertmanager_stateful_workflows.py b/tests/test_alertmanager_stateful_workflows.py index 62e690d..b313957 100644 --- a/tests/test_alertmanager_stateful_workflows.py +++ b/tests/test_alertmanager_stateful_workflows.py @@ -1,5 +1,9 @@ """ -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 diff --git a/tests/test_incidents_recipient_email.py b/tests/test_incidents_recipient_email.py index 538fe01..e0ab3ed 100644 --- a/tests/test_incidents_recipient_email.py +++ b/tests/test_incidents_recipient_email.py @@ -1,5 +1,9 @@ """ -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_integration_security_and_jira_service.py b/tests/test_integration_security_and_jira_service.py index bf807dc..f644a29 100644 --- a/tests/test_integration_security_and_jira_service.py +++ b/tests/test_integration_security_and_jira_service.py @@ -76,7 +76,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 +140,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",)]) diff --git a/tests/test_integration_security_service.py b/tests/test_integration_security_service.py index 54aa2ad..a2abc0e 100644 --- a/tests/test_integration_security_service.py +++ b/tests/test_integration_security_service.py @@ -54,7 +54,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_middleware_rate_limit_and_database_edges.py b/tests/test_middleware_rate_limit_and_database_edges.py index 5f4914b..a43c3a1 100644 --- a/tests/test_middleware_rate_limit_and_database_edges.py +++ b/tests/test_middleware_rate_limit_and_database_edges.py @@ -318,7 +318,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 3ac9006..146ba92 100644 --- a/tests/test_middleware_rate_limit_database_resilience_edges.py +++ b/tests/test_middleware_rate_limit_database_resilience_edges.py @@ -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): diff --git a/tests/test_notification_payloads.py b/tests/test_notification_payloads.py index 33f9d7d..bf18074 100644 --- a/tests/test_notification_payloads.py +++ b/tests/test_notification_payloads.py @@ -147,7 +147,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_service_email_edges.py b/tests/test_notification_service_email_edges.py index de5f274..26ee1b6 100644 --- a/tests/test_notification_service_email_edges.py +++ b/tests/test_notification_service_email_edges.py @@ -120,7 +120,7 @@ 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 diff --git a/tests/test_openapi_middleware.py b/tests/test_openapi_middleware.py index 8021aff..cde6b70 100644 --- a/tests/test_openapi_middleware.py +++ b/tests/test_openapi_middleware.py @@ -103,18 +103,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_service_branch_closure_batch.py b/tests/test_service_branch_closure_batch.py index f263666..4be2914 100644 --- a/tests/test_service_branch_closure_batch.py +++ b/tests/test_service_branch_closure_batch.py @@ -103,7 +103,7 @@ def __init__(self, db): def __enter__(self): return self.db - def __exit__(self, exc_type, exc, tb): + def __exit__(self, *args): return False @@ -656,13 +656,13 @@ async def smtp_capture(message, smtp=None, **_kwargs): assert captured["username"] == "mailer" assert captured["password"] == "fallback-token" - async def fake_teams(client, cfg, alert_obj, action): + async def fake_teams(*args, **kwargs): return True - async def fake_webhook(client, cfg, alert_obj, action): + async def fake_webhook(*args, **kwargs): return True - async def fake_pagerduty(client, cfg, alert_obj, action): + async def fake_pagerduty(*args, **kwargs): return True monkeypatch.setattr(notif_mod.notification_senders, "send_teams", fake_teams) diff --git a/tests/test_silences_ops.py b/tests/test_silences_ops.py index 88beae5..22a5f31 100644 --- a/tests/test_silences_ops.py +++ b/tests/test_silences_ops.py @@ -69,7 +69,7 @@ def __init__(self, db): def __enter__(self): return self.db - def __exit__(self, exc_type, exc, tb): + def __exit__(self, *args): return False diff --git a/tests/test_storage_and_alertmanager_edges.py b/tests/test_storage_and_alertmanager_edges.py index 9b090e1..ba104e0 100644 --- a/tests/test_storage_and_alertmanager_edges.py +++ b/tests/test_storage_and_alertmanager_edges.py @@ -398,10 +398,8 @@ async def async_value(value): assert await svc.delete_silence("sil-1") is True assert db.added == [] - @contextmanager def broken_ctx(): raise SQLAlchemyError("boom") - yield monkeypatch.setattr("services.alertmanager_service.get_db_session", broken_ctx) assert await svc.delete_silence("sil-1") is True diff --git a/tests/test_storage_rules_channels_and_incident_helpers.py b/tests/test_storage_rules_channels_and_incident_helpers.py index 9e922e6..cf1258a 100644 --- a/tests/test_storage_rules_channels_and_incident_helpers.py +++ b/tests/test_storage_rules_channels_and_incident_helpers.py @@ -96,7 +96,7 @@ def __init__(self, db): def __enter__(self): return self.db - def __exit__(self, exc_type, exc, tb): + def __exit__(self, *args): return False diff --git a/tests/test_vault_client_edges.py b/tests/test_vault_client_edges.py index 20ee37a..2569210 100644 --- a/tests/test_vault_client_edges.py +++ b/tests/test_vault_client_edges.py @@ -1,5 +1,9 @@ """ -Edge and branch coverage tests for Vault secret provider 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 From c4646ffd5add6bd35c31ae495c7087b124529aea Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 19 Apr 2026 17:41:46 +0530 Subject: [PATCH 10/20] chore: trim redundant notifier test comments --- tests/test_alertmanager_stateful_workflows.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_alertmanager_stateful_workflows.py b/tests/test_alertmanager_stateful_workflows.py index b313957..5d86397 100644 --- a/tests/test_alertmanager_stateful_workflows.py +++ b/tests/test_alertmanager_stateful_workflows.py @@ -239,12 +239,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) @@ -260,7 +258,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( From 22a0f657a55229077a1b340e9935f2fa005404e4 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 19 Apr 2026 20:26:28 +0530 Subject: [PATCH 11/20] (docstring) updated docstring notice --- __init__.py | 5 ++--- config.py | 5 ++--- custom_types/__init__.py | 5 ++--- custom_types/json.py | 5 ++--- database.py | 5 ++--- db_models.py | 5 ++--- main.py | 5 ++--- middleware/__init__.py | 5 ++--- middleware/concurrency_limit.py | 5 ++--- middleware/dependencies.py | 5 ++--- middleware/error_handlers.py | 5 ++--- middleware/headers.py | 5 ++--- middleware/openapi.py | 5 ++--- middleware/rate_limit/__init__.py | 5 ++--- middleware/rate_limit/hybrid.py | 5 ++--- middleware/rate_limit/in_memory.py | 5 ++--- middleware/rate_limit/ip.py | 5 ++--- middleware/rate_limit/models.py | 5 ++--- middleware/rate_limit/observability.py | 5 ++--- middleware/rate_limit/redis_fixed_window.py | 5 ++--- middleware/request_size_limit.py | 5 ++--- middleware/resilience.py | 5 ++--- models/__init__.py | 3 +-- models/access/__init__.py | 3 +-- models/access/auth_models.py | 5 ++--- models/alerting/__init__.py | 3 +-- models/alerting/alerts.py | 5 ++--- models/alerting/channels.py | 5 ++--- models/alerting/incidents.py | 5 ++--- models/alerting/receivers.py | 5 ++--- models/alerting/requests.py | 5 ++--- models/alerting/rules.py | 5 ++--- models/alerting/silences.py | 5 ++--- routers/__init__.py | 5 ++--- routers/observability/__init__.py | 5 ++--- routers/observability/alerts/__init__.py | 5 ++--- routers/observability/alerts/access.py | 5 ++--- routers/observability/alerts/alerts_routes.py | 5 ++--- routers/observability/alerts/channels.py | 5 ++--- routers/observability/alerts/integrations.py | 5 ++--- routers/observability/alerts/rules.py | 5 ++--- routers/observability/alerts/shared.py | 5 ++--- routers/observability/alerts/silences.py | 5 ++--- routers/observability/alerts/status.py | 5 ++--- routers/observability/alerts/webhooks.py | 5 ++--- routers/observability/incidents.py | 5 ++--- routers/observability/jira/__init__.py | 5 ++--- routers/observability/jira/config.py | 5 ++--- routers/observability/jira/discovery.py | 5 ++--- routers/observability/jira/incident_links.py | 5 ++--- routers/observability/jira/integrations.py | 5 ++--- routers/observability/jira/shared.py | 5 ++--- services/__init__.py | 3 +-- services/alerting/__init__.py | 3 +-- services/alerting/alerts_ops.py | 5 ++--- services/alerting/channels_ops.py | 5 ++--- services/alerting/integration_security_service.py | 5 ++--- services/alerting/rule_import_service.py | 5 ++--- services/alerting/ruler_yaml.py | 5 ++--- services/alerting/rules_ops.py | 5 ++--- services/alerting/silence_metadata.py | 5 ++--- services/alerting/silences_ops.py | 5 ++--- services/alertmanager_service.py | 5 ++--- services/common/__init__.py | 3 +-- services/common/access.py | 5 ++--- services/common/encryption.py | 5 ++--- services/common/http_client.py | 5 ++--- services/common/meta.py | 5 ++--- services/common/pagination.py | 5 ++--- services/common/tenants.py | 5 ++--- services/common/url_utils.py | 2 +- services/common/visibility.py | 5 ++--- services/incidents/__init__.py | 5 ++--- services/incidents/helpers.py | 2 +- services/jira/__init__.py | 5 ++--- services/jira/helpers.py | 5 ++--- services/jira_service.py | 5 ++--- services/notification/email_providers.py | 5 ++--- services/notification/payloads.py | 5 ++--- services/notification/senders.py | 5 ++--- services/notification/transport.py | 5 ++--- services/notification/validators.py | 5 ++--- services/notification_service.py | 5 ++--- services/secrets/provider.py | 5 ++--- services/secrets/vault_client.py | 5 ++--- services/storage/__init__.py | 3 +-- services/storage/channels.py | 5 ++--- services/storage/hidden_entity_storage.py | 5 ++--- services/storage/incidents.py | 5 ++--- services/storage/incidents_core.py | 5 ++--- services/storage/incidents_jira.py | 5 ++--- services/storage/incidents_sync.py | 5 ++--- services/storage/revocation.py | 5 ++--- services/storage/rules.py | 5 ++--- services/storage/serializers.py | 5 ++--- services/storage_db_service.py | 5 ++--- tests/__init__.py | 3 +-- tests/_env.py | 3 +-- tests/_regression_helpers.py | 3 +-- tests/conftest.py | 3 +-- tests/test_alert_ops.py | 3 +-- tests/test_alerting_ops_and_transport.py | 3 +-- tests/test_alertmanager_stateful_workflows.py | 3 +-- tests/test_alerts_ops_metrics_edges.py | 3 +-- tests/test_channel_delivery_visibility.py | 3 +-- tests/test_channels_ops.py | 3 +-- tests/test_channels_ops_edges.py | 3 +-- tests/test_common_access_edges.py | 3 +-- tests/test_config_validation_edges.py | 3 +-- tests/test_group_share_revocation.py | 3 +-- tests/test_helper_surfaces.py | 3 +-- tests/test_incident_aggregation.py | 3 +-- tests/test_incident_helpers_and_serializers.py | 3 +-- tests/test_incidents_recipient_email.py | 3 +-- tests/test_incidents_router.py | 3 +-- tests/test_integration_security_and_jira_service.py | 3 +-- tests/test_integration_security_service.py | 3 +-- tests/test_jira_helpers_edges.py | 3 +-- tests/test_main_entrypoint.py | 3 +-- tests/test_middleware_core.py | 3 +-- tests/test_middleware_rate_limit_and_database_edges.py | 3 +-- .../test_middleware_rate_limit_database_resilience_edges.py | 3 +-- tests/test_notification_and_helper_edges_more.py | 3 +-- tests/test_notification_email_providers.py | 3 +-- tests/test_notification_payloads.py | 3 +-- tests/test_notification_senders.py | 3 +-- tests/test_notification_senders_error_handling.py | 3 +-- tests/test_notification_service.py | 3 +-- tests/test_notification_service_and_encryption.py | 3 +-- tests/test_notification_service_email_edges.py | 3 +-- tests/test_notification_transport.py | 3 +-- tests/test_notification_validators.py | 3 +-- tests/test_observability_router_endpoints_edges.py | 3 +-- tests/test_openapi_middleware.py | 3 +-- tests/test_refactor_compat_coverage_edges.py | 3 +-- ...t_regression_alert_delivery_notify_for_alerts_workflow.py | 3 +-- tests/test_regression_channel_validators_no_gap_matrix.py | 3 +-- tests/test_regression_channels_test_endpoint_matrix.py | 3 +-- .../test_regression_incident_assignment_background_tasks.py | 3 +-- tests/test_regression_incident_recipient_email_parsing.py | 3 +-- tests/test_regression_incident_resolution_guardrails.py | 3 +-- tests/test_regression_incident_status_and_jira_sync.py | 3 +-- tests/test_regression_notification_senders_contracts.py | 3 +-- .../test_regression_notification_service_dispatch_matrix.py | 3 +-- ..._regression_notification_service_email_provider_matrix.py | 3 +-- tests/test_regression_refactor_coverage_fillers.py | 3 +-- tests/test_rule_import_service.py | 3 +-- tests/test_ruler_yaml.py | 3 +-- tests/test_rules_ops.py | 3 +-- tests/test_rules_router_registration.py | 3 +-- tests/test_security_dependencies.py | 3 +-- tests/test_service_branch_closure_batch.py | 3 +-- tests/test_silence_metadata.py | 3 +-- tests/test_silences_ops.py | 3 +-- tests/test_storage_and_alertmanager_edges.py | 3 +-- tests/test_storage_channels.py | 3 +-- tests/test_storage_incidents_helpers_and_service.py | 3 +-- tests/test_storage_rules_channels_and_incident_helpers.py | 3 +-- tests/test_storage_tenant_isolation_matrix.py | 3 +-- tests/test_url_utils.py | 3 +-- tests/test_vault_client_edges.py | 3 +-- 161 files changed, 248 insertions(+), 407 deletions(-) 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..a516f53 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 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..7766819 100644 --- a/main.py +++ b/main.py @@ -1,11 +1,10 @@ """ 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 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 4838e3e..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 diff --git a/middleware/error_handlers.py b/middleware/error_handlers.py index 6a61e3a..a14b57a 100644 --- a/middleware/error_handlers.py +++ b/middleware/error_handlers.py @@ -2,11 +2,10 @@ 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 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 c2e44ca..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 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/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/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 b29959d..b772b17 100644 --- a/routers/observability/alerts/__init__.py +++ b/routers/observability/alerts/__init__.py @@ -1,9 +1,8 @@ """ -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 diff --git a/routers/observability/alerts/access.py b/routers/observability/alerts/access.py index 6426a76..a677c97 100644 --- a/routers/observability/alerts/access.py +++ b/routers/observability/alerts/access.py @@ -1,9 +1,8 @@ """ -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 diff --git a/routers/observability/alerts/alerts_routes.py b/routers/observability/alerts/alerts_routes.py index 76e0b63..3783d7f 100644 --- a/routers/observability/alerts/alerts_routes.py +++ b/routers/observability/alerts/alerts_routes.py @@ -1,9 +1,8 @@ """ -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/channels.py b/routers/observability/alerts/channels.py index c9afc5e..05a6945 100644 --- a/routers/observability/alerts/channels.py +++ b/routers/observability/alerts/channels.py @@ -2,11 +2,10 @@ 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 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 e119921..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 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 1e06ebb..6c48512 100644 --- a/routers/observability/alerts/silences.py +++ b/routers/observability/alerts/silences.py @@ -1,11 +1,10 @@ """ 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 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 67f7670..1c201ee 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 diff --git a/routers/observability/incidents.py b/routers/observability/incidents.py index 60df0a2..7da06f1 100644 --- a/routers/observability/incidents.py +++ b/routers/observability/incidents.py @@ -2,11 +2,10 @@ 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 diff --git a/routers/observability/jira/__init__.py b/routers/observability/jira/__init__.py index 1b1be17..e9a0afa 100644 --- a/routers/observability/jira/__init__.py +++ b/routers/observability/jira/__init__.py @@ -1,11 +1,10 @@ """ Jira integration routers split by resource domain. -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 diff --git a/routers/observability/jira/config.py b/routers/observability/jira/config.py index 249b1ff..a1c6cb9 100644 --- a/routers/observability/jira/config.py +++ b/routers/observability/jira/config.py @@ -1,9 +1,8 @@ """ -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 diff --git a/routers/observability/jira/discovery.py b/routers/observability/jira/discovery.py index 0218368..59e7170 100644 --- a/routers/observability/jira/discovery.py +++ b/routers/observability/jira/discovery.py @@ -1,9 +1,8 @@ """ -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, HTTPException, Query, status diff --git a/routers/observability/jira/incident_links.py b/routers/observability/jira/incident_links.py index fc4220d..ba3ea72 100644 --- a/routers/observability/jira/incident_links.py +++ b/routers/observability/jira/incident_links.py @@ -1,9 +1,8 @@ """ -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/jira/integrations.py b/routers/observability/jira/integrations.py index 3e28ec3..9a8a3cf 100644 --- a/routers/observability/jira/integrations.py +++ b/routers/observability/jira/integrations.py @@ -1,9 +1,8 @@ """ -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 uuid diff --git a/routers/observability/jira/shared.py b/routers/observability/jira/shared.py index d75093e..48f829d 100644 --- a/routers/observability/jira/shared.py +++ b/routers/observability/jira/shared.py @@ -1,9 +1,8 @@ """ -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 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 2a6915f..c8e58b8 100644 --- a/services/alerting/alerts_ops.py +++ b/services/alerting/alerts_ops.py @@ -2,11 +2,10 @@ 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 diff --git a/services/alerting/channels_ops.py b/services/alerting/channels_ops.py index 1227ac9..8e09fa9 100644 --- a/services/alerting/channels_ops.py +++ b/services/alerting/channels_ops.py @@ -2,11 +2,10 @@ 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 diff --git a/services/alerting/integration_security_service.py b/services/alerting/integration_security_service.py index 9e9436f..f7c2f38 100644 --- a/services/alerting/integration_security_service.py +++ b/services/alerting/integration_security_service.py @@ -2,11 +2,10 @@ 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 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/alertmanager_service.py b/services/alertmanager_service.py index 0327eed..16bc727 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 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 216792e..712444d 100644 --- a/services/common/access.py +++ b/services/common/access.py @@ -1,11 +1,10 @@ """ 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 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 52f2444..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 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 3f19ad1..3a64ebe 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. 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 8fb18f2..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 diff --git a/services/jira_service.py b/services/jira_service.py index 6d1c446..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 diff --git a/services/notification/email_providers.py b/services/notification/email_providers.py index 37c3e67..fcacd8b 100644 --- a/services/notification/email_providers.py +++ b/services/notification/email_providers.py @@ -5,11 +5,10 @@ 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 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 12e3b35..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 diff --git a/services/notification/transport.py b/services/notification/transport.py index e97dc0b..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 diff --git a/services/notification/validators.py b/services/notification/validators.py index 4d7b987..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 diff --git a/services/notification_service.py b/services/notification_service.py index 9a81f30..99d3650 100644 --- a/services/notification_service.py +++ b/services/notification_service.py @@ -2,11 +2,10 @@ 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 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 fdb6a7f..f706654 100644 --- a/services/storage/channels.py +++ b/services/storage/channels.py @@ -2,11 +2,10 @@ 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 diff --git a/services/storage/hidden_entity_storage.py b/services/storage/hidden_entity_storage.py index f42455c..d4f0f7e 100644 --- a/services/storage/hidden_entity_storage.py +++ b/services/storage/hidden_entity_storage.py @@ -1,11 +1,10 @@ """ Persistence for per-user hidden silences, channels, and Jira integration preferences. -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.py b/services/storage/incidents.py index 5777852..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 diff --git a/services/storage/incidents_core.py b/services/storage/incidents_core.py index f1eb6b4..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 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 ce154fa..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 diff --git a/services/storage/revocation.py b/services/storage/revocation.py index 3e1037c..39c6c70 100644 --- a/services/storage/revocation.py +++ b/services/storage/revocation.py @@ -1,9 +1,8 @@ """ -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/rules.py b/services/storage/rules.py index 84342b2..a6194ed 100644 --- a/services/storage/rules.py +++ b/services/storage/rules.py @@ -1,11 +1,10 @@ """ 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 diff --git a/services/storage/serializers.py b/services/storage/serializers.py index aee7733..b20fc73 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 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..6db51d8 100644 --- a/tests/_env.py +++ b/tests/_env.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/_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 6c7c3a1..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 diff --git a/tests/test_alertmanager_stateful_workflows.py b/tests/test_alertmanager_stateful_workflows.py index 5d86397..4e5a53d 100644 --- a/tests/test_alertmanager_stateful_workflows.py +++ b/tests/test_alertmanager_stateful_workflows.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_alerts_ops_metrics_edges.py b/tests/test_alerts_ops_metrics_edges.py index 57d337e..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 diff --git a/tests/test_channel_delivery_visibility.py b/tests/test_channel_delivery_visibility.py index 2d111a2..e223cfd 100644 --- a/tests/test_channel_delivery_visibility.py +++ b/tests/test_channel_delivery_visibility.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.0l +License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0l """ from types import SimpleNamespace diff --git a/tests/test_channels_ops.py b/tests/test_channels_ops.py index d06f77c..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: diff --git a/tests/test_channels_ops_edges.py b/tests/test_channels_ops_edges.py index ec8e827..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 diff --git a/tests/test_common_access_edges.py b/tests/test_common_access_edges.py index 07fbe25..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 diff --git a/tests/test_config_validation_edges.py b/tests/test_config_validation_edges.py index b4882b9..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 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 e0ab3ed..57efbf4 100644 --- a/tests/test_incidents_recipient_email.py +++ b/tests/test_incidents_recipient_email.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_router.py b/tests/test_incidents_router.py index 706216d..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 diff --git a/tests/test_integration_security_and_jira_service.py b/tests/test_integration_security_and_jira_service.py index f644a29..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 diff --git a/tests/test_integration_security_service.py b/tests/test_integration_security_service.py index a2abc0e..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 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_middleware_core.py b/tests/test_middleware_core.py index 516e654..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 diff --git a/tests/test_middleware_rate_limit_and_database_edges.py b/tests/test_middleware_rate_limit_and_database_edges.py index a43c3a1..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 diff --git a/tests/test_middleware_rate_limit_database_resilience_edges.py b/tests/test_middleware_rate_limit_database_resilience_edges.py index 146ba92..90eddcf 100644 --- a/tests/test_middleware_rate_limit_database_resilience_edges.py +++ b/tests/test_middleware_rate_limit_database_resilience_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_notification_and_helper_edges_more.py b/tests/test_notification_and_helper_edges_more.py index f01fd01..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 diff --git a/tests/test_notification_email_providers.py b/tests/test_notification_email_providers.py index 146d7d8..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: diff --git a/tests/test_notification_payloads.py b/tests/test_notification_payloads.py index bf18074..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: diff --git a/tests/test_notification_senders.py b/tests/test_notification_senders.py index 21c6541..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: diff --git a/tests/test_notification_senders_error_handling.py b/tests/test_notification_senders_error_handling.py index ccab299..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: diff --git a/tests/test_notification_service.py b/tests/test_notification_service.py index b5e5d51..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: diff --git a/tests/test_notification_service_and_encryption.py b/tests/test_notification_service_and_encryption.py index f2f4f13..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 diff --git a/tests/test_notification_service_email_edges.py b/tests/test_notification_service_email_edges.py index 26ee1b6..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 diff --git a/tests/test_notification_transport.py b/tests/test_notification_transport.py index 875a959..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: 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 6ca04fa..c3df4e4 100644 --- a/tests/test_observability_router_endpoints_edges.py +++ b/tests/test_observability_router_endpoints_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_openapi_middleware.py b/tests/test_openapi_middleware.py index cde6b70..02c07b2 100644 --- a/tests/test_openapi_middleware.py +++ b/tests/test_openapi_middleware.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_refactor_compat_coverage_edges.py b/tests/test_refactor_compat_coverage_edges.py index 45108f1..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 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 7d25496..581bc95 100644 --- a/tests/test_regression_alert_delivery_notify_for_alerts_workflow.py +++ b/tests/test_regression_alert_delivery_notify_for_alerts_workflow.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_regression_channel_validators_no_gap_matrix.py b/tests/test_regression_channel_validators_no_gap_matrix.py index b0723b4..586908f 100644 --- a/tests/test_regression_channel_validators_no_gap_matrix.py +++ b/tests/test_regression_channel_validators_no_gap_matrix.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_regression_channels_test_endpoint_matrix.py b/tests/test_regression_channels_test_endpoint_matrix.py index c7e9d4c..78ef833 100644 --- a/tests/test_regression_channels_test_endpoint_matrix.py +++ b/tests/test_regression_channels_test_endpoint_matrix.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_regression_incident_assignment_background_tasks.py b/tests/test_regression_incident_assignment_background_tasks.py index cf429d6..7cb208d 100644 --- a/tests/test_regression_incident_assignment_background_tasks.py +++ b/tests/test_regression_incident_assignment_background_tasks.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_regression_incident_recipient_email_parsing.py b/tests/test_regression_incident_recipient_email_parsing.py index 559ed9c..407fdbf 100644 --- a/tests/test_regression_incident_recipient_email_parsing.py +++ b/tests/test_regression_incident_recipient_email_parsing.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_regression_incident_resolution_guardrails.py b/tests/test_regression_incident_resolution_guardrails.py index dbf509f..d7b24e6 100644 --- a/tests/test_regression_incident_resolution_guardrails.py +++ b/tests/test_regression_incident_resolution_guardrails.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_regression_incident_status_and_jira_sync.py b/tests/test_regression_incident_status_and_jira_sync.py index df65902..ff90f5e 100644 --- a/tests/test_regression_incident_status_and_jira_sync.py +++ b/tests/test_regression_incident_status_and_jira_sync.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_regression_notification_senders_contracts.py b/tests/test_regression_notification_senders_contracts.py index 50b4fda..290f71e 100644 --- a/tests/test_regression_notification_senders_contracts.py +++ b/tests/test_regression_notification_senders_contracts.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_regression_notification_service_dispatch_matrix.py b/tests/test_regression_notification_service_dispatch_matrix.py index 672862e..3dbad7e 100644 --- a/tests/test_regression_notification_service_dispatch_matrix.py +++ b/tests/test_regression_notification_service_dispatch_matrix.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_regression_notification_service_email_provider_matrix.py b/tests/test_regression_notification_service_email_provider_matrix.py index 264f57e..4baec09 100644 --- a/tests/test_regression_notification_service_email_provider_matrix.py +++ b/tests/test_regression_notification_service_email_provider_matrix.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_regression_refactor_coverage_fillers.py b/tests/test_regression_refactor_coverage_fillers.py index a2f87d7..0837b19 100644 --- a/tests/test_regression_refactor_coverage_fillers.py +++ b/tests/test_regression_refactor_coverage_fillers.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_rule_import_service.py b/tests/test_rule_import_service.py index bd8b90c..35e8bd9 100644 --- a/tests/test_rule_import_service.py +++ b/tests/test_rule_import_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 diff --git a/tests/test_ruler_yaml.py b/tests/test_ruler_yaml.py index 537fee6..4f9b37f 100644 --- a/tests/test_ruler_yaml.py +++ b/tests/test_ruler_yaml.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_rules_ops.py b/tests/test_rules_ops.py index a6213f1..3b4ba8b 100644 --- a/tests/test_rules_ops.py +++ b/tests/test_rules_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_rules_router_registration.py b/tests/test_rules_router_registration.py index a3b56d8..63956b5 100644 --- a/tests/test_rules_router_registration.py +++ b/tests/test_rules_router_registration.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_security_dependencies.py b/tests/test_security_dependencies.py index 9b674fc..9f1653e 100644 --- a/tests/test_security_dependencies.py +++ b/tests/test_security_dependencies.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 datetime import UTC, datetime, timedelta diff --git a/tests/test_service_branch_closure_batch.py b/tests/test_service_branch_closure_batch.py index 4be2914..c085bc2 100644 --- a/tests/test_service_branch_closure_batch.py +++ b/tests/test_service_branch_closure_batch.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_silence_metadata.py b/tests/test_silence_metadata.py index 8e08da5..c52ac1c 100644 --- a/tests/test_silence_metadata.py +++ b/tests/test_silence_metadata.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_silences_ops.py b/tests/test_silences_ops.py index 22a5f31..1edac95 100644 --- a/tests/test_silences_ops.py +++ b/tests/test_silences_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 """ from __future__ import annotations diff --git a/tests/test_storage_and_alertmanager_edges.py b/tests/test_storage_and_alertmanager_edges.py index ba104e0..61c50fe 100644 --- a/tests/test_storage_and_alertmanager_edges.py +++ b/tests/test_storage_and_alertmanager_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_storage_channels.py b/tests/test_storage_channels.py index d6a5f1f..6e18880 100644 --- a/tests/test_storage_channels.py +++ b/tests/test_storage_channels.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_storage_incidents_helpers_and_service.py b/tests/test_storage_incidents_helpers_and_service.py index 0ba4ac5..2917809 100644 --- a/tests/test_storage_incidents_helpers_and_service.py +++ b/tests/test_storage_incidents_helpers_and_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 diff --git a/tests/test_storage_rules_channels_and_incident_helpers.py b/tests/test_storage_rules_channels_and_incident_helpers.py index cf1258a..923d5e6 100644 --- a/tests/test_storage_rules_channels_and_incident_helpers.py +++ b/tests/test_storage_rules_channels_and_incident_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/test_storage_tenant_isolation_matrix.py b/tests/test_storage_tenant_isolation_matrix.py index 85afdd1..a2d5b6b 100644 --- a/tests/test_storage_tenant_isolation_matrix.py +++ b/tests/test_storage_tenant_isolation_matrix.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_url_utils.py b/tests/test_url_utils.py index 49cdb57..c0ecdad 100644 --- a/tests/test_url_utils.py +++ b/tests/test_url_utils.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_vault_client_edges.py b/tests/test_vault_client_edges.py index 2569210..5d7d9e1 100644 --- a/tests/test_vault_client_edges.py +++ b/tests/test_vault_client_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 From 7954acff030dbd54d348dd1f8c2b86d185738adc Mon Sep 17 00:00:00 2001 From: stefankumaraaisnghe Date: Mon, 20 Apr 2026 17:11:46 +1000 Subject: [PATCH 12/20] test: bootstrap notifier tests on temporary sqlite --- database.py | 3 + tests/_env.py | 34 +++- ...re_rate_limit_database_resilience_edges.py | 155 +++++++++--------- 3 files changed, 117 insertions(+), 75 deletions(-) diff --git a/database.py b/database.py index a516f53..7f73c89 100644 --- a/database.py +++ b/database.py @@ -41,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/tests/_env.py b/tests/_env.py index 6db51d8..668cc0e 100644 --- a/tests/_env.py +++ b/tests/_env.py @@ -1,21 +1,51 @@ """ + 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 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/test_middleware_rate_limit_database_resilience_edges.py b/tests/test_middleware_rate_limit_database_resilience_edges.py index 90eddcf..600104c 100644 --- a/tests/test_middleware_rate_limit_database_resilience_edges.py +++ b/tests/test_middleware_rate_limit_database_resilience_edges.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +import os from datetime import UTC, datetime, timedelta from types import SimpleNamespace from typing import Any, cast @@ -121,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: + db_mod.init_database(active_database_url) + db_mod.init_db() def test_in_memory_and_hybrid_rate_limiters(monkeypatch): From 95ca3b1c3f6dbb44c5c914d8d428f31dc174d31b Mon Sep 17 00:00:00 2001 From: stefankumaraaisnghe Date: Mon, 20 Apr 2026 17:12:11 +1000 Subject: [PATCH 13/20] refactor: align notifier access context flow --- routers/observability/alerts/webhooks.py | 17 ++------ routers/observability/jira/config.py | 6 +++ routers/observability/jira/discovery.py | 9 +++++ routers/observability/jira/incident_links.py | 20 ++++++---- routers/observability/jira/integrations.py | 8 ++++ routers/observability/jira/shared.py | 5 +++ services/alerting/suppression.py | 9 ++++- services/alertmanager_service.py | 2 + services/storage/channels.py | 40 ++++++++++++++----- services/storage/rules.py | 6 ++- ...st_observability_router_endpoints_edges.py | 5 ++- tests/test_storage_channels.py | 20 +++++----- 12 files changed, 102 insertions(+), 45 deletions(-) diff --git a/routers/observability/alerts/webhooks.py b/routers/observability/alerts/webhooks.py index 1c201ee..931afa0 100644 --- a/routers/observability/alerts/webhooks.py +++ b/routers/observability/alerts/webhooks.py @@ -28,19 +28,10 @@ async def _dispatch_notifications(tenant_id: str, alerts: list[JSONDict]) -> None: - try: - await alertmanager_service.notify_for_alerts( - NotificationDispatchContext(alertmanager_service, tenant_id, storage_service, notification_service), - alerts, - ) - except TypeError: - # Backward-compatibility path for tests and temporary adapters. - await alertmanager_service.notify_for_alerts( - tenant_id, - alerts, - storage_service, - notification_service, - ) + await alertmanager_service.notify_for_alerts( + NotificationDispatchContext(alertmanager_service, tenant_id, storage_service, notification_service), + alerts, + ) @router.post( diff --git a/routers/observability/jira/config.py b/routers/observability/jira/config.py index a1c6cb9..a5afc64 100644 --- a/routers/observability/jira/config.py +++ b/routers/observability/jira/config.py @@ -1,4 +1,10 @@ """ +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 diff --git a/routers/observability/jira/discovery.py b/routers/observability/jira/discovery.py index 59e7170..382fae3 100644 --- a/routers/observability/jira/discovery.py +++ b/routers/observability/jira/discovery.py @@ -1,4 +1,13 @@ """ +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 diff --git a/routers/observability/jira/incident_links.py b/routers/observability/jira/incident_links.py index ba3ea72..b62fbc4 100644 --- a/routers/observability/jira/incident_links.py +++ b/routers/observability/jira/incident_links.py @@ -1,4 +1,12 @@ """ +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 @@ -30,6 +38,7 @@ ) from services.jira.helpers import resolve_incident_jira_credentials 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 @@ -57,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") @@ -167,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") @@ -227,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 9a8a3cf..56fe140 100644 --- a/routers/observability/jira/integrations.py +++ b/routers/observability/jira/integrations.py @@ -1,4 +1,12 @@ """ +Intergrations 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 diff --git a/routers/observability/jira/shared.py b/routers/observability/jira/shared.py index 48f829d..567624b 100644 --- a/routers/observability/jira/shared.py +++ b/routers/observability/jira/shared.py @@ -1,4 +1,9 @@ """ +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 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 16bc727..a903a91 100644 --- a/services/alertmanager_service.py +++ b/services/alertmanager_service.py @@ -244,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 diff --git a/services/storage/channels.py b/services/storage/channels.py index f706654..ed55d28 100644 --- a/services/storage/channels.py +++ b/services/storage/channels.py @@ -13,6 +13,7 @@ import logging import uuid from dataclasses import dataclass, field +from types import SimpleNamespace from sqlalchemy.orm import joinedload @@ -49,6 +50,20 @@ def _config_dict(channel: NotificationChannelDB) -> JSONDict: return raw_config if isinstance(raw_config, dict) else {} +def _channel_view(channel: NotificationChannelDB, raw_config: JSONDict) -> SimpleNamespace: + return SimpleNamespace( + 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 @@ -62,6 +77,9 @@ class PageRequest: class ChannelStorageService: + def __init__(self, *_args: object, **_kwargs: object) -> None: + return + @staticmethod def _access_context( access: ChannelAccessContext | str, @@ -138,8 +156,7 @@ def get_notification_channels( ): continue raw_cfg = decrypt_config(_config_dict(ch)) - ch.config = raw_cfg - results.append(channel_to_pydantic_for_viewer(ch, context.user_id)) + results.append(channel_to_pydantic_for_viewer(_channel_view(ch, raw_cfg), context.user_id)) return results @staticmethod @@ -172,8 +189,11 @@ def get_notification_channel( # pylint: disable=too-many-positional-arguments ): return None raw_cfg = decrypt_config(_config_dict(ch)) - ch.config = raw_cfg - return channel_to_pydantic_for_viewer(ch, context.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( @@ -210,8 +230,7 @@ def create_notification_channel( 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, context.user_id) + return channel_to_pydantic_for_viewer(_channel_view(ch, cfg), context.user_id) @staticmethod def update_notification_channel( @@ -219,8 +238,9 @@ def update_notification_channel( channel_update: NotificationChannelCreate, tenant_id: str, access: ChannelAccessContext | str, + group_ids: list[str] | None = None, ) -> NotificationChannel | None: - context = ChannelStorageService._access_context(access) + context = ChannelStorageService._access_context(access, group_ids=group_ids) group_ids = list(context.group_ids or []) with get_db_session() as db: ch = ( @@ -252,8 +272,7 @@ def update_notification_channel( 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, context.user_id) + return channel_to_pydantic_for_viewer(_channel_view(ch, cfg), context.user_id) @staticmethod def delete_notification_channel( @@ -368,8 +387,7 @@ def get_notification_channels_for_rule_name( 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/rules.py b/services/storage/rules.py index a6194ed..cac8c97 100644 --- a/services/storage/rules.py +++ b/services/storage/rules.py @@ -52,6 +52,9 @@ class PageRequest: class RuleStorageService: + def __init__(self, *_args: object, **_kwargs: object) -> None: + return + @staticmethod def _access_context( access: RuleAccessContext | str, @@ -346,8 +349,9 @@ def update_alert_rule( rule_update: AlertRuleCreate, tenant_id: str, access: RuleAccessContext | str, + group_ids: list[str] | None = None, ) -> AlertRule | None: - context = RuleStorageService._access_context(access) + context = RuleStorageService._access_context(access, group_ids=group_ids) group_ids = list(context.group_ids or []) with get_db_session() as db: r = ( diff --git a/tests/test_observability_router_endpoints_edges.py b/tests/test_observability_router_endpoints_edges.py index c3df4e4..b263b87 100644 --- a/tests/test_observability_router_endpoints_edges.py +++ b/tests/test_observability_router_endpoints_edges.py @@ -230,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) diff --git a/tests/test_storage_channels.py b/tests/test_storage_channels.py index 6e18880..3c5156f 100644 --- a/tests/test_storage_channels.py +++ b/tests/test_storage_channels.py @@ -43,14 +43,14 @@ def test_encrypt_decrypt_config_roundtrip(monkeypatch): @pytest.mark.skipif(not __import__("database", fromlist=[""]).connection_test(), reason="DB not available") def test_create_channel_stores_encrypted_and_owner_sees_config(monkeypatch): - svc = ChannelStorageService(None) + svc = ChannelStorageService() prev = config.data_encryption_key try: config.data_encryption_key = Fernet.generate_key().decode() ch_in = NotificationChannelCreate( name="c1", type=ChannelType.SLACK, config={"webhook_url": "https://x"}, enabled=True, visibility="private" ) - created = svc.create_notification_channel(ch_in, tenant_id="t-1", user_id="owner", group_ids=None) + created = svc.create_notification_channel(ch_in, tenant_id="t-1", access="owner", group_ids=None) assert created.config == {"webhook_url": "https://x"} with get_db_session() as db: @@ -67,21 +67,21 @@ def test_create_channel_stores_encrypted_and_owner_sees_config(monkeypatch): @pytest.mark.skipif(not __import__("database", fromlist=[""]).connection_test(), reason="DB not available") def test_get_notification_channel_access_control(): - svc = ChannelStorageService(None) + svc = ChannelStorageService() ch_in = NotificationChannelCreate( name="c2", type=ChannelType.SLACK, config={"webhook_url": "https://x"}, enabled=True, visibility="private" ) - created = svc.create_notification_channel(ch_in, tenant_id="t-2", user_id="owner2", group_ids=None) - fetched = svc.get_notification_channel(created.id, tenant_id="t-2", user_id="someone_else", group_ids=None) + created = svc.create_notification_channel(ch_in, tenant_id="t-2", access="owner2", group_ids=None) + fetched = svc.get_notification_channel(created.id, tenant_id="t-2", access="someone_else", group_ids=None) assert fetched is None - fetched_owner = svc.get_notification_channel(created.id, tenant_id="t-2", user_id="owner2", group_ids=None) + fetched_owner = svc.get_notification_channel(created.id, tenant_id="t-2", access="owner2", group_ids=None) assert fetched_owner is not None assert fetched_owner.config == {"webhook_url": "https://x"} @pytest.mark.skipif(not __import__("database", fromlist=[""]).connection_test(), reason="DB not available") def test_channel_update_delete_require_owner(): - svc = ChannelStorageService(None) + svc = ChannelStorageService() ch_in = NotificationChannelCreate( name="shared-ch", type=ChannelType.SLACK, @@ -89,7 +89,7 @@ def test_channel_update_delete_require_owner(): enabled=True, visibility="tenant", ) - created = svc.create_notification_channel(ch_in, tenant_id="t-3", user_id="owner3", group_ids=None) + created = svc.create_notification_channel(ch_in, tenant_id="t-3", access="owner3", group_ids=None) updated = svc.update_notification_channel( created.id, NotificationChannelCreate( @@ -100,8 +100,8 @@ def test_channel_update_delete_require_owner(): visibility="tenant", ), tenant_id="t-3", - user_id="viewer3", + access="viewer3", group_ids=None, ) assert updated is None - assert svc.delete_notification_channel(created.id, tenant_id="t-3", user_id="viewer3") is False + assert svc.delete_notification_channel(created.id, tenant_id="t-3", access="viewer3") is False From 783f510bbd2cc2e83cb1c7972097a0d073056d9b Mon Sep 17 00:00:00 2001 From: stefankumaraaisnghe Date: Mon, 20 Apr 2026 17:12:31 +1000 Subject: [PATCH 14/20] docs: refresh notifier openapi --- openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openapi.json b/openapi.json index 8ab6887..c2c602f 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.1"},"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 From 4b576e4d4132b0cc96501fecb062f078e84c0cc3 Mon Sep 17 00:00:00 2001 From: stefankumaraaisnghe Date: Mon, 20 Apr 2026 19:42:09 +1000 Subject: [PATCH 15/20] fix: align notifier storage typing and lint config --- CHANGELOG.md | 2 ++ pyproject.toml | 1 + services/storage/channels.py | 19 ++++++++++++--- services/storage/rules.py | 1 + services/storage/serializers.py | 41 +++++++++++++++++++++------------ 5 files changed, 46 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d27ce5f..3115d9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ All notable changes to this project will be documented in this file. - 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 diff --git a/pyproject.toml b/pyproject.toml index fd2fa1b..907c6b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -163,6 +163,7 @@ files = ["."] exclude = [ "^(build|dist|venv|\\.venv|__pycache__|migrations)/", "^tests/", + "^mutants/", ] [tool.pylint.main] diff --git a/services/storage/channels.py b/services/storage/channels.py index ed55d28..625d1d6 100644 --- a/services/storage/channels.py +++ b/services/storage/channels.py @@ -13,7 +13,6 @@ import logging import uuid from dataclasses import dataclass, field -from types import SimpleNamespace from sqlalchemy.orm import joinedload @@ -33,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 [] @@ -50,8 +62,8 @@ def _config_dict(channel: NotificationChannelDB) -> JSONDict: return raw_config if isinstance(raw_config, dict) else {} -def _channel_view(channel: NotificationChannelDB, raw_config: JSONDict) -> SimpleNamespace: - return SimpleNamespace( +def _channel_view(channel: NotificationChannelDB, raw_config: JSONDict) -> _ChannelView: + return _ChannelView( id=channel.id, name=channel.name, type=channel.type, @@ -238,6 +250,7 @@ def update_notification_channel( channel_update: NotificationChannelCreate, tenant_id: str, access: ChannelAccessContext | str, + *, group_ids: list[str] | None = None, ) -> NotificationChannel | None: context = ChannelStorageService._access_context(access, group_ids=group_ids) diff --git a/services/storage/rules.py b/services/storage/rules.py index cac8c97..bfa6ad9 100644 --- a/services/storage/rules.py +++ b/services/storage/rules.py @@ -349,6 +349,7 @@ def update_alert_rule( rule_update: AlertRuleCreate, tenant_id: str, access: RuleAccessContext | str, + *, group_ids: list[str] | None = None, ) -> AlertRule | None: context = RuleStorageService._access_context(access, group_ids=group_ids) diff --git a/services/storage/serializers.py b/services/storage/serializers.py index b20fc73..5e3e2fe 100644 --- a/services/storage/serializers.py +++ b/services/storage/serializers.py @@ -15,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 @@ -47,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, @@ -68,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), @@ -79,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) From 72879eea0dd21ae8fc1c97f343ffdb09f37296ac Mon Sep 17 00:00:00 2001 From: stefankumaraaisnghe Date: Mon, 20 Apr 2026 20:12:21 +1000 Subject: [PATCH 16/20] (fix) The teardown now only restores the database when the saved URL is SQLite, so CI no longer tries to reconnect to an unavailable PostgreSQL instance during cleanup --- tests/test_middleware_rate_limit_database_resilience_edges.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_middleware_rate_limit_database_resilience_edges.py b/tests/test_middleware_rate_limit_database_resilience_edges.py index 600104c..be2cf77 100644 --- a/tests/test_middleware_rate_limit_database_resilience_edges.py +++ b/tests/test_middleware_rate_limit_database_resilience_edges.py @@ -202,7 +202,7 @@ def create_all(self, bind=None): finally: monkeypatch.undo() db_mod.dispose_database() - if active_database_url: + if active_database_url.startswith("sqlite"): db_mod.init_database(active_database_url) db_mod.init_db() From 60b1f8f60b7b75c398b154c50cd2046f0d1261e1 Mon Sep 17 00:00:00 2001 From: stefankumaraaisnghe Date: Mon, 20 Apr 2026 20:46:31 +1000 Subject: [PATCH 17/20] (versioning) bumping the version to v0.0.5 --- CHANGELOG.md | 2 +- openapi.json | 2 +- openapi.yaml | 2 +- pyproject.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3115d9d..4ee514d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -## [Unreleased] - 2026-04-17 +## [v0.0.5] - 2026-04-20 ### Added diff --git a/openapi.json b/openapi.json index c2c602f..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.1"},"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 907c6b8..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" From 9b01cfc4e3de670e3aaa33fb49f55f9681d43d98 Mon Sep 17 00:00:00 2001 From: stefankumaraaisnghe Date: Tue, 21 Apr 2026 21:06:36 +1000 Subject: [PATCH 18/20] (feat) add notifier startup bootstrap --- main.py | 59 ++++++++++---- middleware/runtime_ssl.py | 45 +++++++++++ tests/test_main_startup_edges.py | 127 +++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 16 deletions(-) create mode 100644 middleware/runtime_ssl.py create mode 100644 tests/test_main_startup_edges.py diff --git a/main.py b/main.py index 7766819..dbf3f8f 100644 --- a/main.py +++ b/main.py @@ -9,14 +9,17 @@ 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 @@ -33,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, ) @@ -51,9 +55,31 @@ # 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 _bootstrap_database() -> None: + timeout_seconds = float(os.getenv("DATABASE_STARTUP_TIMEOUT", "180")) + retry_delay_seconds = float(os.getenv("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" @@ -90,6 +116,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, @@ -166,15 +197,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/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/tests/test_main_startup_edges.py b/tests/test_main_startup_edges.py new file mode 100644 index 0000000..dc8f515 --- /dev/null +++ b/tests/test_main_startup_edges.py @@ -0,0 +1,127 @@ +""" +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) \ No newline at end of file From 1e400098cf12eba16399b38c477933e0cd429419 Mon Sep 17 00:00:00 2001 From: stefankumaraaisnghe Date: Wed, 22 Apr 2026 17:46:33 +1000 Subject: [PATCH 19/20] fix(notifier): require globally routable URLs --- services/common/url_utils.py | 2 +- tests/test_url_utils.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/services/common/url_utils.py b/services/common/url_utils.py index 3a64ebe..9a3c134 100644 --- a/services/common/url_utils.py +++ b/services/common/url_utils.py @@ -37,7 +37,7 @@ def is_safe_http_url(value: str | None) -> bool: if is_valid and hostname: try: ip = ipaddress.ip_address(hostname) - is_valid = not (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved) + is_valid = ip.is_global except ValueError: pass return is_valid diff --git a/tests/test_url_utils.py b/tests/test_url_utils.py index c0ecdad..372b1f4 100644 --- a/tests/test_url_utils.py +++ b/tests/test_url_utils.py @@ -36,6 +36,7 @@ def test_rejects_local_and_private_targets(self): self.assertFalse(is_safe_http_url("http://service.local/path")) self.assertFalse(is_safe_http_url("http://127.0.0.1/admin")) self.assertFalse(is_safe_http_url("http://10.0.0.10/api")) + self.assertFalse(is_safe_http_url("http://100.64.0.1/metadata")) self.assertFalse(is_safe_http_url("http://169.254.1.2/metadata")) self.assertFalse(is_safe_http_url("http://240.0.0.1/metadata")) self.assertFalse(is_safe_http_url("http://[::1]/health")) From b8a9c32377ccac1cc7c244c17d09337b9ac09045 Mon Sep 17 00:00:00 2001 From: stefankumaraaisnghe Date: Wed, 22 Apr 2026 21:33:17 +1000 Subject: [PATCH 20/20] Harden notifier startup parsing --- main.py | 12 ++++++++++-- routers/observability/jira/integrations.py | 2 +- tests/test_channel_delivery_visibility.py | 3 ++- tests/test_main_startup_edges.py | 15 +++++++++++++-- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/main.py b/main.py index dbf3f8f..5667797 100644 --- a/main.py +++ b/main.py @@ -55,9 +55,17 @@ # Expose this name for route logic and tests that monkeypatch main.connection_test. connection_test = database_module.connection_test + +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 = float(os.getenv("DATABASE_STARTUP_TIMEOUT", "180")) - retry_delay_seconds = float(os.getenv("DATABASE_STARTUP_RETRY_DELAY", "2")) + 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 diff --git a/routers/observability/jira/integrations.py b/routers/observability/jira/integrations.py index 56fe140..4455f7f 100644 --- a/routers/observability/jira/integrations.py +++ b/routers/observability/jira/integrations.py @@ -1,5 +1,5 @@ """ -Intergrations management endpoints for Jira integration in the observability notifier router. +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 diff --git a/tests/test_channel_delivery_visibility.py b/tests/test_channel_delivery_visibility.py index e223cfd..c2c07a0 100644 --- a/tests/test_channel_delivery_visibility.py +++ b/tests/test_channel_delivery_visibility.py @@ -2,7 +2,8 @@ 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.0l +License. You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 for details. """ from types import SimpleNamespace diff --git a/tests/test_main_startup_edges.py b/tests/test_main_startup_edges.py index dc8f515..dbbd22c 100644 --- a/tests/test_main_startup_edges.py +++ b/tests/test_main_startup_edges.py @@ -1,5 +1,5 @@ """ -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 @@ -124,4 +124,15 @@ def test_bootstrap_database_times_out_on_sqlalchemy_error(monkeypatch): 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) \ No newline at end of file + 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