Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
582f5c9
Add notifier mutmut mutation-testing configuration
stefankumarasinghe Apr 17, 2026
89e61b1
Add Apache license headers and test maintenance updates for notifier …
stefankumarasinghe Apr 17, 2026
8d50f8d
(pylint) making the linting stricter and cleaner, currently no issues
stefankumarasinghe Apr 19, 2026
fbd48f0
(track) missed tracking a file
stefankumarasinghe Apr 19, 2026
7699a77
refactor(notifier): remove legacy incident/alert/jira compat paths
stefankumarasinghe Apr 19, 2026
0e46166
refactor: remove notifier legacy shims and align tests
stefankumarasinghe Apr 19, 2026
8b6e119
refactor: remove channel storage legacy argument shims
stefankumarasinghe Apr 19, 2026
7061c3f
chore: clean deprecated wording and test literals
stefankumarasinghe Apr 19, 2026
1fef221
chore: clean notifier tests and helpers
stefankumarasinghe Apr 19, 2026
c4646ff
chore: trim redundant notifier test comments
stefankumarasinghe Apr 19, 2026
22a0f65
(docstring) updated docstring notice
stefankumarasinghe Apr 19, 2026
7954acf
test: bootstrap notifier tests on temporary sqlite
stefankumarasinghe Apr 20, 2026
95ca3b1
refactor: align notifier access context flow
stefankumarasinghe Apr 20, 2026
783f510
docs: refresh notifier openapi
stefankumarasinghe Apr 20, 2026
4b576e4
fix: align notifier storage typing and lint config
stefankumarasinghe Apr 20, 2026
72879ee
(fix) The teardown now only restores the database when the saved URL …
stefankumarasinghe Apr 20, 2026
60b1f8f
(versioning) bumping the version to v0.0.5
stefankumarasinghe Apr 20, 2026
9b01cfc
(feat) add notifier startup bootstrap
stefankumarasinghe Apr 21, 2026
1e40009
fix(notifier): require globally routable URLs
stefankumarasinghe Apr 22, 2026
b8a9c32
Harden notifier startup parsing
stefankumarasinghe Apr 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

All notable changes to this project will be documented in this file.

## [v0.0.5] - 2026-04-20

### Added

- Added notifier mutation-testing configuration for `mutmut` and selective notifier test execution.

### Changed

- Added Apache license header blocks to notifier regression and middleware tests.

### Fixed

- Cleaned up notifier test assertions and deterministic state handling in edge-case regression tests.
- Hardened URL safety parsing to satisfy strict typing without behavior drift, and restored full notifier `mypy`/`pytest` green status at 100% coverage.
- Refactored alertmanager, incident, Jira, notification, and storage/access call surfaces to typed request/context models while preserving router/runtime behavior.
- Removed remaining strict-design lint violations (`too-many-args`, `too-many-positional-arguments`, `too-many-statements`, etc.) across notifier and restored full `pylint` 10.00/10 with notifier-wide `mypy` + `pytest` green at 100% coverage.
- Removed stale deprecated/legacy wording in notifier Jira secret warning and related tests without changing runtime logic.
- Added a temporary SQLite test bootstrap for notifier, aligned channel/Jira/webhook storage access-context handling, and refreshed the generated OpenAPI snapshot.
- Excluded generated mutation fixtures from notifier mypy so type-checking stays focused on the real source tree.

## [v0.0.4] - 2026-04-14

### Added
Expand Down
5 changes: 2 additions & 3 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
5 changes: 2 additions & 3 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions custom_types/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 2 additions & 3 deletions custom_types/json.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 5 additions & 3 deletions database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,6 +41,9 @@ def _new_session(factory: _SessionFactory) -> Session:

def ensure_database_exists(database_url: str) -> None:
url = make_url(database_url)
drivername = str(getattr(url, "drivername", "postgresql"))
if not drivername.startswith("postgresql"):
return
db_name = url.database
if not db_name:
raise RuntimeError("Database URL must include a database name")
Expand Down
5 changes: 2 additions & 3 deletions db_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 53 additions & 19 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
"""
Entrypoint for the Notifier service.

Copyright (c) 2026 Stefan Kumarasinghe
Copyright (c) 2026 Stefan Kumarasinghe.

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the
License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
"""

from __future__ import annotations

import asyncio
import logging
import os
import secrets
import time
from collections.abc import Awaitable, Callable

import uvicorn
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from sqlalchemy.exc import SQLAlchemyError
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import Response

Expand All @@ -34,6 +36,7 @@
openapi_tags,
)
from middleware.request_size_limit import RequestSizeLimitMiddleware
from middleware.runtime_ssl import RuntimeSSLOptions, run_uvicorn
from routers.observability.alerts import (
router as alertmanager_alerts_router,
)
Expand All @@ -52,9 +55,39 @@
# Expose this name for route logic and tests that monkeypatch main.connection_test.
connection_test = database_module.connection_test

database_module.ensure_database_exists(config.notifier_database_url)
database_module.init_database(config.notifier_database_url, config.log_level == "debug")
database_module.init_db()

def _get_float_env(var_name: str, default: str) -> float:
raw_value = os.getenv(var_name, default)
try:
return float(raw_value)
except ValueError as exc:
raise RuntimeError(f"Invalid value for {var_name}: {raw_value!r}. Expected a numeric value.") from exc

def _bootstrap_database() -> None:
timeout_seconds = _get_float_env("DATABASE_STARTUP_TIMEOUT", "180")
retry_delay_seconds = _get_float_env("DATABASE_STARTUP_RETRY_DELAY", "2")
deadline = time.monotonic() + timeout_seconds
attempt = 0

while True:
attempt += 1
try:
database_module.ensure_database_exists(config.notifier_database_url)
database_module.init_database(config.notifier_database_url, config.log_level == "debug")
database_module.init_db()
logger.info("Notifier database initialization completed")
return
except SQLAlchemyError as exc:
if time.monotonic() >= deadline:
raise RuntimeError("Notifier database did not become ready before startup timeout") from exc
logger.warning(
"Notifier database not ready (attempt %d, retrying in %.1fs): %s",
attempt,
retry_delay_seconds,
exc,
)
time.sleep(retry_delay_seconds)


APP_TITLE = "Notifier"
APP_DESCRIPTION = "Internal alerting service for Watchdog"
Expand Down Expand Up @@ -91,6 +124,11 @@
)


@app.on_event("startup")
async def _startup_database() -> None:
await asyncio.to_thread(_bootstrap_database)


@app.middleware("http")
async def require_internal_service_token(
request: Request,
Expand Down Expand Up @@ -167,15 +205,11 @@ async def readiness() -> JSONResponse:


if __name__ == "__main__":
if config.notifier_ssl_enabled:
uvicorn.run(
app,
host=config.host,
port=config.port,
loop="uvloop",
log_level=config.log_level,
ssl_certfile=config.notifier_ssl_certfile,
ssl_keyfile=config.notifier_ssl_keyfile,
)
else:
uvicorn.run(app, host=config.host, port=config.port, loop="uvloop", log_level=config.log_level)
run_uvicorn(
app,
host=config.host,
port=config.port,
loop="uvloop",
log_level=config.log_level,
ssl_options=RuntimeSSLOptions.from_config(config),
)
5 changes: 2 additions & 3 deletions middleware/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 2 additions & 3 deletions middleware/concurrency_limit.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
37 changes: 22 additions & 15 deletions middleware/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +15,7 @@
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from functools import lru_cache
from ipaddress import IPv4Network, IPv6Network, ip_address, ip_network

Expand Down Expand Up @@ -209,23 +209,30 @@ def dependency(current_user: TokenData = Depends(checker)) -> TokenData:
return dependency


def enforce_public_endpoint_security(
request: Request,
*,
scope: str,
limit: int,
window_seconds: int,
allowlist: str | None = None,
fallback_mode: str | None = None,
) -> None:
@dataclass(frozen=True)
class PublicEndpointSecurityConfig:
scope: str
limit: int
window_seconds: int
allowlist: str | None = None
fallback_mode: str | None = None


def enforce_public_endpoint_security(request: Request, config_data: PublicEndpointSecurityConfig) -> None:
resolved_ip = client_ip(request)
if config.require_client_ip_for_public_endpoints and resolved_ip == "unknown":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied for {scope}: client IP resolution failed",
detail=f"Access denied for {config_data.scope}: client IP resolution failed",
)
enforce_ip_rate_limit(request, scope=scope, limit=limit, window_seconds=window_seconds, fallback_mode=fallback_mode)
_enforce_ip_allowlist(request, allowlist, scope=scope)
enforce_ip_rate_limit(
request,
scope=config_data.scope,
limit=config_data.limit,
window_seconds=config_data.window_seconds,
fallback_mode=config_data.fallback_mode,
)
_enforce_ip_allowlist(request, config_data.allowlist, scope=config_data.scope)


def _enforce_ip_allowlist(request: Request, allowlist: str | None, *, scope: str) -> None:
Expand Down
Loading
Loading