processcube-etw-library 0.1.0__tar.gz → 2026.1.19b0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- processcube_etw_library-2026.1.19b0/.github/workflows/build_and_publish.yml +61 -0
- processcube_etw_library-2026.1.19b0/.gitignore +7 -0
- processcube_etw_library-2026.1.19b0/.python-version +1 -0
- processcube_etw_library-2026.1.19b0/PKG-INFO +24 -0
- processcube_etw_library-2026.1.19b0/README.md +11 -0
- processcube_etw_library-2026.1.19b0/pyproject.toml +27 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library/__init__.py +19 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library/create_external_task_client.py +44 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library/etw_app.py +115 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library/health/__init__.py +23 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library/health/built_in.py +32 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library/health/check.py +45 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library/health/handlers.py +44 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library/health/models.py +43 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library/health/registry.py +28 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library/health/routes.py +27 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library/identity_provider.py +87 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library/server_config.py +27 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library/typed_handler.py +49 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library.egg-info/PKG-INFO +24 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library.egg-info/SOURCES.txt +24 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library.egg-info/requires.txt +4 -0
- processcube_etw_library-2026.1.19b0/src/processcube_etw_library.egg-info/top_level.txt +1 -0
- processcube_etw_library-2026.1.19b0/uv.lock +1604 -0
- processcube_etw_library-0.1.0/PKG-INFO +0 -15
- processcube_etw_library-0.1.0/README.md +0 -0
- processcube_etw_library-0.1.0/pyproject.toml +0 -27
- processcube_etw_library-0.1.0/src/processcube/__init__.py +0 -0
- processcube_etw_library-0.1.0/src/processcube/identity_provider.py +0 -63
- processcube_etw_library-0.1.0/src/processcube_etw_library.egg-info/PKG-INFO +0 -15
- processcube_etw_library-0.1.0/src/processcube_etw_library.egg-info/SOURCES.txt +0 -9
- processcube_etw_library-0.1.0/src/processcube_etw_library.egg-info/requires.txt +0 -7
- processcube_etw_library-0.1.0/src/processcube_etw_library.egg-info/top_level.txt +0 -1
- {processcube_etw_library-0.1.0 → processcube_etw_library-2026.1.19b0}/setup.cfg +0 -0
- {processcube_etw_library-0.1.0 → processcube_etw_library-2026.1.19b0}/src/processcube_etw_library.egg-info/dependency_links.txt +0 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
name: Build and Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
pull_request:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
build-and-publish:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
environment:
|
|
16
|
+
name: pypi
|
|
17
|
+
permissions:
|
|
18
|
+
id-token: write
|
|
19
|
+
contents: read
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v6
|
|
22
|
+
|
|
23
|
+
- name: Install uv
|
|
24
|
+
uses: astral-sh/setup-uv@v7
|
|
25
|
+
with:
|
|
26
|
+
enable-cache: true
|
|
27
|
+
version: "0.9.26"
|
|
28
|
+
|
|
29
|
+
- name: Set up Python
|
|
30
|
+
uses: actions/setup-python@v6
|
|
31
|
+
with:
|
|
32
|
+
python-version-file: ".python-version"
|
|
33
|
+
|
|
34
|
+
- name: Set version
|
|
35
|
+
run: |
|
|
36
|
+
DATE_VERSION=$(date +'%Y.%m.%d')
|
|
37
|
+
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
|
|
38
|
+
VERSION="${DATE_VERSION}"
|
|
39
|
+
else
|
|
40
|
+
VERSION="${DATE_VERSION}-beta"
|
|
41
|
+
fi
|
|
42
|
+
echo "VERSION=${VERSION}" >> $GITHUB_ENV
|
|
43
|
+
echo "Setting version to: ${VERSION}"
|
|
44
|
+
|
|
45
|
+
- name: Debug GitHub context
|
|
46
|
+
run: |
|
|
47
|
+
echo "GITHUB EVENT: ${{ github.event_name }}"
|
|
48
|
+
echo "GITHUB REF: ${{ github.ref }}"
|
|
49
|
+
echo "GITHUB BASE REF: ${{ github.base_ref }}"
|
|
50
|
+
echo "GITHUB HEAD REF: ${{ github.head_ref }}"
|
|
51
|
+
|
|
52
|
+
- name: Install dependencies
|
|
53
|
+
run: uv sync --locked
|
|
54
|
+
|
|
55
|
+
- name: Build
|
|
56
|
+
run: uv build
|
|
57
|
+
env:
|
|
58
|
+
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ env.VERSION }}
|
|
59
|
+
|
|
60
|
+
- name: Publish to PyPI
|
|
61
|
+
run: uv publish --token ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: processcube-etw-library
|
|
3
|
+
Version: 2026.1.19b0
|
|
4
|
+
Summary: A library to create ETW apps with the ProcessCube platform.
|
|
5
|
+
Author-email: Jeremy Hill <jeremy.hill@profection.de>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: fastapi[standard]>=0.128.0
|
|
10
|
+
Requires-Dist: oauth2-client>=1.4.2
|
|
11
|
+
Requires-Dist: processcube-client>=5.0.0
|
|
12
|
+
Requires-Dist: tenacity>=9.1.2
|
|
13
|
+
|
|
14
|
+
# ProcessCube ETW Library
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
### Start the ETW application
|
|
21
|
+
|
|
22
|
+
### Subscribe to External Task Topics
|
|
23
|
+
|
|
24
|
+
### Add a custom health check
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42", "wheel", "setuptools-scm>=8"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "processcube-etw-library"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "A library to create ETW apps with the ProcessCube platform."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "Jeremy Hill", email = "jeremy.hill@profection.de" }]
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"fastapi[standard]>=0.128.0",
|
|
15
|
+
"oauth2-client>=1.4.2",
|
|
16
|
+
"processcube-client>=5.0.0",
|
|
17
|
+
"tenacity>=9.1.2",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[tool.uv]
|
|
21
|
+
override-dependencies = ["nest-asyncio>=1.6.0"]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools]
|
|
24
|
+
packages = ["processcube_etw_library"]
|
|
25
|
+
package-dir = { "" = "src" }
|
|
26
|
+
|
|
27
|
+
[tool.setuptools_scm]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .health import (
|
|
2
|
+
create_url_health_check,
|
|
3
|
+
HealthCheck,
|
|
4
|
+
HealthCheckModel,
|
|
5
|
+
HealthCheckRegistry,
|
|
6
|
+
HealthConditionInfo,
|
|
7
|
+
LivezResponse,
|
|
8
|
+
)
|
|
9
|
+
from .etw_app import new_external_task_worker_app
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"create_url_health_check",
|
|
13
|
+
"HealthCheck",
|
|
14
|
+
"HealthCheckModel",
|
|
15
|
+
"HealthCheckRegistry",
|
|
16
|
+
"HealthConditionInfo",
|
|
17
|
+
"LivezResponse",
|
|
18
|
+
"new_external_task_worker_app",
|
|
19
|
+
]
|
processcube_etw_library-2026.1.19b0/src/processcube_etw_library/create_external_task_client.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from os import getenv
|
|
2
|
+
|
|
3
|
+
# Prevent nest_asyncio from patching asyncio.run() in processcube_client - it breaks uvicorn's loop_factory
|
|
4
|
+
import nest_asyncio
|
|
5
|
+
nest_asyncio.apply = lambda *args, **kwargs: None
|
|
6
|
+
|
|
7
|
+
from processcube_client.external_task import ExternalTaskClient
|
|
8
|
+
from processcube_client.app_info import AppInfoClient
|
|
9
|
+
|
|
10
|
+
from .identity_provider import IdentityProvider
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _determine_authority_url(engine_url: str) -> str:
|
|
14
|
+
if authority := getenv("PROCESSCUBE_AUTHORITY_URL"):
|
|
15
|
+
return authority
|
|
16
|
+
|
|
17
|
+
app_info_client = AppInfoClient(engine_url)
|
|
18
|
+
authority_url = app_info_client.get_authority()
|
|
19
|
+
return authority_url # type: ignore
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_external_task_client(
|
|
23
|
+
engine_url: str,
|
|
24
|
+
client_name: str,
|
|
25
|
+
client_secret: str,
|
|
26
|
+
client_scopes: str,
|
|
27
|
+
max_get_oauth_access_token_retries: int,
|
|
28
|
+
) -> ExternalTaskClient:
|
|
29
|
+
authority_url = _determine_authority_url(engine_url)
|
|
30
|
+
|
|
31
|
+
identity_provider = IdentityProvider(
|
|
32
|
+
authority_url,
|
|
33
|
+
client_name,
|
|
34
|
+
client_secret,
|
|
35
|
+
client_scopes,
|
|
36
|
+
max_get_oauth_access_token_retries,
|
|
37
|
+
)
|
|
38
|
+
client = ExternalTaskClient(
|
|
39
|
+
engine_url,
|
|
40
|
+
identity=identity_provider,
|
|
41
|
+
install_signals=False,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return client
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
import os
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
from .health import (
|
|
11
|
+
add_built_in_health_checks,
|
|
12
|
+
HealthCheck,
|
|
13
|
+
HealthCheckRegistry,
|
|
14
|
+
setup_health_routes
|
|
15
|
+
)
|
|
16
|
+
from .create_external_task_client import create_external_task_client
|
|
17
|
+
from .server_config import get_server_config
|
|
18
|
+
from .typed_handler import create_typed_handler_wrapper
|
|
19
|
+
from processcube_client.external_task import ExternalTaskClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ExternalTaskWorkerApp:
|
|
23
|
+
_etw_client: ExternalTaskClient
|
|
24
|
+
_health_registry: HealthCheckRegistry
|
|
25
|
+
_executor: ThreadPoolExecutor
|
|
26
|
+
_etw_future: Optional[asyncio.Future]
|
|
27
|
+
_app: FastAPI
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self, etw_client: ExternalTaskClient, built_in_health_checks: bool = True
|
|
31
|
+
):
|
|
32
|
+
self._executor = ThreadPoolExecutor(max_workers=1)
|
|
33
|
+
self._etw_future = None
|
|
34
|
+
self._etw_client = etw_client
|
|
35
|
+
self._app = FastAPI(lifespan=self._lifespan)
|
|
36
|
+
self._health_registry = HealthCheckRegistry()
|
|
37
|
+
if built_in_health_checks:
|
|
38
|
+
add_built_in_health_checks(self._health_registry)
|
|
39
|
+
setup_health_routes(self._app, self._health_registry)
|
|
40
|
+
|
|
41
|
+
@asynccontextmanager
|
|
42
|
+
async def _lifespan(self, app: FastAPI):
|
|
43
|
+
loop = asyncio.get_running_loop()
|
|
44
|
+
self._etw_future = loop.run_in_executor(self._executor, self._etw_client.start)
|
|
45
|
+
|
|
46
|
+
yield
|
|
47
|
+
|
|
48
|
+
loop = asyncio.get_running_loop()
|
|
49
|
+
await loop.run_in_executor(None, self._etw_client.stop)
|
|
50
|
+
|
|
51
|
+
if self._etw_future is not None and not self._etw_future.done():
|
|
52
|
+
self._etw_future.cancel()
|
|
53
|
+
try:
|
|
54
|
+
await self._etw_future
|
|
55
|
+
except asyncio.CancelledError:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
self._executor.shutdown(wait=False)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def fastapi_app(self) -> FastAPI:
|
|
62
|
+
return self._app
|
|
63
|
+
|
|
64
|
+
def run(self) -> None:
|
|
65
|
+
config = get_server_config()
|
|
66
|
+
uvicorn.run(self._app, **config)
|
|
67
|
+
|
|
68
|
+
def subscribe_to_external_task_for_topic(
|
|
69
|
+
self, topic: str, handler: Callable, **options
|
|
70
|
+
) -> None:
|
|
71
|
+
self._etw_client.subscribe_to_external_task_topic(topic, handler, **options)
|
|
72
|
+
|
|
73
|
+
def subscribe_to_external_task_for_topic_typed(
|
|
74
|
+
self, topic: str, handler: Callable, **options
|
|
75
|
+
) -> None:
|
|
76
|
+
wrapper = create_typed_handler_wrapper(handler)
|
|
77
|
+
self._etw_client.subscribe_to_external_task_topic(topic, wrapper, **options)
|
|
78
|
+
|
|
79
|
+
def add_health_check(self, check: HealthCheck) -> None:
|
|
80
|
+
self._health_registry.register(check)
|
|
81
|
+
|
|
82
|
+
def remove_health_check(self, service_name: str) -> bool:
|
|
83
|
+
return self._health_registry.unregister(service_name)
|
|
84
|
+
|
|
85
|
+
def get_health_checks(self) -> list[HealthCheck]:
|
|
86
|
+
return self._health_registry.get_all()
|
|
87
|
+
|
|
88
|
+
def get_health_check(self, service_name: str) -> Optional[HealthCheck]:
|
|
89
|
+
return self._health_registry.get_by_name(service_name)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def new_external_task_worker_app(
|
|
93
|
+
built_in_health_checks: bool = True,
|
|
94
|
+
) -> ExternalTaskWorkerApp:
|
|
95
|
+
engine_url = os.getenv("PROCESSCUBE_ENGINE_URL", "http://localhost:56000")
|
|
96
|
+
etw_client_name = os.getenv("PROCESSCUBE_ETW_CLIENT_ID", "test_etw")
|
|
97
|
+
etw_client_secret = os.getenv(
|
|
98
|
+
"PROCESSCUBE_ETW_CLIENT_SECRET", "3ef62eb3-fe49-4c2c-ba6f-73e4d234321b"
|
|
99
|
+
)
|
|
100
|
+
etw_client_scopes = os.getenv("PROCESSCUBE_ETW_CLIENT_SCOPES", "engine_etw")
|
|
101
|
+
max_get_oauth_access_token_retries = int(
|
|
102
|
+
os.getenv("MAX_GET_OAUTH_ACCESS_TOKEN_RETRIES", 10)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
external_task_client = create_external_task_client(
|
|
106
|
+
engine_url,
|
|
107
|
+
etw_client_name,
|
|
108
|
+
etw_client_secret,
|
|
109
|
+
etw_client_scopes,
|
|
110
|
+
max_get_oauth_access_token_retries,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return ExternalTaskWorkerApp(
|
|
114
|
+
external_task_client, built_in_health_checks=built_in_health_checks
|
|
115
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .check import HealthCheck, create_url_health_check
|
|
2
|
+
from .handlers import health_check
|
|
3
|
+
from .models import (
|
|
4
|
+
HealthCheckEntityModel,
|
|
5
|
+
HealthCheckModel,
|
|
6
|
+
HealthConditionInfo,
|
|
7
|
+
LivezResponse,
|
|
8
|
+
)
|
|
9
|
+
from .registry import HealthCheckRegistry
|
|
10
|
+
from .built_in import add_built_in_health_checks
|
|
11
|
+
from .routes import setup_health_routes
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"add_built_in_health_checks",
|
|
15
|
+
"create_url_health_check",
|
|
16
|
+
"health_check",
|
|
17
|
+
"HealthCheck",
|
|
18
|
+
"HealthCheckEntityModel",
|
|
19
|
+
"HealthCheckModel",
|
|
20
|
+
"HealthCheckRegistry",
|
|
21
|
+
"HealthConditionInfo",
|
|
22
|
+
"LivezResponse",
|
|
23
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from .check import HealthCheck, create_url_health_check
|
|
4
|
+
from .registry import HealthCheckRegistry
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def add_built_in_health_checks(registry: HealthCheckRegistry) -> None:
|
|
8
|
+
engine_url = (
|
|
9
|
+
os.getenv("PROCESSCUBE_ENGINE_URL", "http://localhost:56000").strip("/")
|
|
10
|
+
+ "/atlas_engine/api/v1/info"
|
|
11
|
+
)
|
|
12
|
+
authority_url = (
|
|
13
|
+
os.getenv("PROCESSCUBE_AUTHORITY_URL", "http://localhost:56020").strip("/")
|
|
14
|
+
+ "/.well-known/openid-configuration"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
registry.register(
|
|
18
|
+
HealthCheck(
|
|
19
|
+
create_url_health_check(engine_url),
|
|
20
|
+
service_name="ProcessCube Engine",
|
|
21
|
+
tags=["core", "backend"],
|
|
22
|
+
comments=["Checks if the ProcessCube Engine is reachable"],
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
registry.register(
|
|
26
|
+
HealthCheck(
|
|
27
|
+
create_url_health_check(authority_url),
|
|
28
|
+
service_name="ProcessCube Authority",
|
|
29
|
+
tags=["core", "auth"],
|
|
30
|
+
comments=["Checks if the ProcessCube Authority is reachable"],
|
|
31
|
+
)
|
|
32
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from time import perf_counter
|
|
4
|
+
import inspect
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .models import HealthCheckResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
type HealthCheckCondition = Callable[[], bool | Awaitable[bool]]
|
|
12
|
+
|
|
13
|
+
class HealthCheck:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
condition: HealthCheckCondition,
|
|
17
|
+
service_name: str,
|
|
18
|
+
tags: list[str] | None = None,
|
|
19
|
+
comments: list[str] | None = None,
|
|
20
|
+
):
|
|
21
|
+
self.condition = condition
|
|
22
|
+
self.service_name = service_name
|
|
23
|
+
self.tags = tags or []
|
|
24
|
+
self.comments = comments or []
|
|
25
|
+
|
|
26
|
+
async def run(self) -> HealthCheckResult:
|
|
27
|
+
start = perf_counter()
|
|
28
|
+
try:
|
|
29
|
+
result = self.condition()
|
|
30
|
+
if inspect.isawaitable(result):
|
|
31
|
+
result = await result
|
|
32
|
+
healthy = bool(result)
|
|
33
|
+
except Exception:
|
|
34
|
+
healthy = False
|
|
35
|
+
elapsed = perf_counter() - start
|
|
36
|
+
return HealthCheckResult(healthy=healthy, time_taken=timedelta(seconds=elapsed))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_url_health_check(url: str, timeout: float = 5.0) -> HealthCheckCondition:
|
|
40
|
+
async def check():
|
|
41
|
+
async with httpx.AsyncClient() as client:
|
|
42
|
+
response = await client.get(url, timeout=timeout)
|
|
43
|
+
return True if response.status_code == 200 else False
|
|
44
|
+
|
|
45
|
+
return check
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from time import perf_counter
|
|
4
|
+
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
|
|
7
|
+
from .check import HealthCheck
|
|
8
|
+
from .models import (
|
|
9
|
+
HealthCheckEntityModel,
|
|
10
|
+
HealthCheckModel,
|
|
11
|
+
)
|
|
12
|
+
from .registry import HealthCheckRegistry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def _run_health_checks(checks: list[HealthCheck]) -> HealthCheckModel:
|
|
16
|
+
total_start = perf_counter()
|
|
17
|
+
results = await asyncio.gather(*(check.run() for check in checks))
|
|
18
|
+
total_elapsed = perf_counter() - total_start
|
|
19
|
+
|
|
20
|
+
entities = []
|
|
21
|
+
all_healthy = True
|
|
22
|
+
for check, result in zip(checks, results):
|
|
23
|
+
if not result.healthy:
|
|
24
|
+
all_healthy = False
|
|
25
|
+
entity = HealthCheckEntityModel(
|
|
26
|
+
service=check.service_name,
|
|
27
|
+
status="healthy" if result.healthy else "unhealthy",
|
|
28
|
+
time_taken=result.time_taken,
|
|
29
|
+
tags=check.tags,
|
|
30
|
+
comments=check.comments,
|
|
31
|
+
)
|
|
32
|
+
entities.append(entity)
|
|
33
|
+
|
|
34
|
+
return HealthCheckModel(
|
|
35
|
+
status="healthy" if all_healthy else "unhealthy",
|
|
36
|
+
total_time_taken=timedelta(seconds=total_elapsed),
|
|
37
|
+
entities=entities,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def health_check(registry: HealthCheckRegistry) -> HealthCheckModel:
|
|
42
|
+
health_result = await _run_health_checks(registry.get_all())
|
|
43
|
+
status_code = 200 if health_result.status == "healthy" else 503
|
|
44
|
+
return JSONResponse(content=health_result.model_dump(), status_code=status_code)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field, field_serializer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HealthCheckEntityModel(BaseModel):
|
|
7
|
+
service: str
|
|
8
|
+
status: str = "healthy"
|
|
9
|
+
time_taken: timedelta = Field(default=timedelta())
|
|
10
|
+
tags: list[str] = Field(default_factory=list)
|
|
11
|
+
comments: list[str] = Field(default_factory=list)
|
|
12
|
+
|
|
13
|
+
@field_serializer("time_taken")
|
|
14
|
+
def serialize_time_taken(self, time: timedelta) -> str:
|
|
15
|
+
return str(time)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HealthCheckModel(BaseModel):
|
|
19
|
+
status: str = "healthy"
|
|
20
|
+
total_time_taken: timedelta = Field(default=timedelta())
|
|
21
|
+
entities: list[HealthCheckEntityModel] = Field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
@field_serializer("total_time_taken")
|
|
24
|
+
def serialize_total_time_taken(self, time: timedelta) -> str:
|
|
25
|
+
return str(time)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LivezResponse(BaseModel):
|
|
29
|
+
status: str = Field(default="alive", description="Liveness status")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HealthConditionInfo(BaseModel):
|
|
33
|
+
service_name: str = Field(..., description="Name of the service")
|
|
34
|
+
tags: list[str] = Field(default_factory=list, description="Tags for categorization")
|
|
35
|
+
comments: list[str] = Field(default_factory=list, description="Comments describing the check")
|
|
36
|
+
|
|
37
|
+
class HealthCheckResult(BaseModel):
|
|
38
|
+
healthy: bool = Field(..., description="Indicates if the health check passed")
|
|
39
|
+
time_taken: timedelta = Field(..., description="Time taken to perform the health check")
|
|
40
|
+
|
|
41
|
+
@field_serializer("time_taken")
|
|
42
|
+
def serialize_time_taken(self, time: timedelta) -> str:
|
|
43
|
+
return str(time)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .check import HealthCheck
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HealthCheckRegistry:
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self._checks: list[HealthCheck] = []
|
|
7
|
+
|
|
8
|
+
def register(self, check: HealthCheck) -> None:
|
|
9
|
+
existing_names = {c.service_name for c in self._checks}
|
|
10
|
+
if check.service_name in existing_names:
|
|
11
|
+
raise ValueError(f"Health check with service name '{check.service_name}' already exists")
|
|
12
|
+
self._checks.append(check)
|
|
13
|
+
|
|
14
|
+
def unregister(self, service_name: str) -> bool:
|
|
15
|
+
for i, check in enumerate(self._checks):
|
|
16
|
+
if check.service_name == service_name:
|
|
17
|
+
self._checks.pop(i)
|
|
18
|
+
return True
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
def get_all(self) -> list[HealthCheck]:
|
|
22
|
+
return self._checks.copy()
|
|
23
|
+
|
|
24
|
+
def get_by_name(self, service_name: str) -> HealthCheck | None:
|
|
25
|
+
for check in self._checks:
|
|
26
|
+
if check.service_name == service_name:
|
|
27
|
+
return check
|
|
28
|
+
return None
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
|
|
3
|
+
from .registry import HealthCheckRegistry
|
|
4
|
+
from .handlers import health_check
|
|
5
|
+
from .models import (
|
|
6
|
+
HealthCheckModel,
|
|
7
|
+
LivezResponse,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def setup_health_routes(app: FastAPI, registry: HealthCheckRegistry) -> None:
|
|
12
|
+
@app.get(
|
|
13
|
+
"/healthyz",
|
|
14
|
+
response_model=HealthCheckModel,
|
|
15
|
+
responses={503: {"model": HealthCheckModel}},
|
|
16
|
+
)
|
|
17
|
+
@app.get(
|
|
18
|
+
"/readyz",
|
|
19
|
+
response_model=HealthCheckModel,
|
|
20
|
+
responses={503: {"model": HealthCheckModel}},
|
|
21
|
+
)
|
|
22
|
+
async def health_check_route() -> HealthCheckModel:
|
|
23
|
+
return await health_check(registry)
|
|
24
|
+
|
|
25
|
+
@app.get("/livez", response_model=LivezResponse)
|
|
26
|
+
def livez() -> LivezResponse:
|
|
27
|
+
return LivezResponse(status="alive")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from oauth2_client.credentials_manager import CredentialManager, ServiceInformation
|
|
5
|
+
from tenacity import (
|
|
6
|
+
retry,
|
|
7
|
+
stop_after_attempt,
|
|
8
|
+
wait_exponential,
|
|
9
|
+
retry_if_exception_type,
|
|
10
|
+
)
|
|
11
|
+
from requests import exceptions
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("processcube.oauth")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def exit_after_retries(retry_state):
|
|
17
|
+
logger.error(
|
|
18
|
+
f"Failed to obtain OAuth access token after {retry_state} attempts. Exiting."
|
|
19
|
+
)
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def log_failed_attempt(retry_state):
|
|
24
|
+
logger.warning(
|
|
25
|
+
f"Attempt {retry_state.attempt_number} to obtain OAuth access token failed: {retry_state.outcome.exception()}"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class IdentityProvider:
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
authority_url: str,
|
|
34
|
+
client_name: str,
|
|
35
|
+
client_secret: str,
|
|
36
|
+
client_scopes: str,
|
|
37
|
+
max_get_oauth_access_token_retries: int,
|
|
38
|
+
):
|
|
39
|
+
self._authority_url = authority_url
|
|
40
|
+
self._client_name = client_name
|
|
41
|
+
self._client_secret = client_secret
|
|
42
|
+
self._client_scopes = client_scopes
|
|
43
|
+
self._max_get_oauth_access_token_retries = max_get_oauth_access_token_retries
|
|
44
|
+
|
|
45
|
+
self._access_token_caller = self._prepare_get_access_token_caller()
|
|
46
|
+
logger.debug(
|
|
47
|
+
f"Prepare identity provider with (authority_url={authority_url}, client_name={client_name}, client_secret=***, client_scopes={client_scopes})."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def __call__(self):
|
|
51
|
+
return self._access_token_caller()
|
|
52
|
+
|
|
53
|
+
def _prepare_get_access_token_caller(self):
|
|
54
|
+
|
|
55
|
+
@retry(
|
|
56
|
+
stop=stop_after_attempt(self._max_get_oauth_access_token_retries),
|
|
57
|
+
wait=wait_exponential(multiplier=1, min=2, max=30),
|
|
58
|
+
retry=retry_if_exception_type(exceptions.ConnectionError),
|
|
59
|
+
after=log_failed_attempt,
|
|
60
|
+
retry_error_callback=exit_after_retries,
|
|
61
|
+
)
|
|
62
|
+
def get_access_token(authority_url, client_name, client_secret, client_scopes):
|
|
63
|
+
logger.debug(
|
|
64
|
+
f"Get access token from ProcessCube (authority_url={authority_url}, client_name={client_name}, client_secret=***, client_scopes={client_scopes})."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
client_scopes = client_scopes.split(" ")
|
|
68
|
+
|
|
69
|
+
service_information = ServiceInformation(
|
|
70
|
+
f"{authority_url}/auth",
|
|
71
|
+
f"{authority_url}/token",
|
|
72
|
+
client_name,
|
|
73
|
+
client_secret,
|
|
74
|
+
client_scopes,
|
|
75
|
+
)
|
|
76
|
+
manager = CredentialManager(service_information)
|
|
77
|
+
|
|
78
|
+
manager.init_with_client_credentials()
|
|
79
|
+
|
|
80
|
+
return {"token": manager._access_token}
|
|
81
|
+
|
|
82
|
+
return lambda: get_access_token(
|
|
83
|
+
self._authority_url,
|
|
84
|
+
self._client_name,
|
|
85
|
+
self._client_secret,
|
|
86
|
+
self._client_scopes,
|
|
87
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from os import getenv
|
|
2
|
+
from typing import TypedDict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ServerConfig(TypedDict, total=False):
|
|
6
|
+
host: str
|
|
7
|
+
port: int
|
|
8
|
+
log_level: str
|
|
9
|
+
access_log: bool
|
|
10
|
+
reload: bool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_server_config() -> ServerConfig:
|
|
14
|
+
if getenv("ENVIRONMENT") == "production":
|
|
15
|
+
return ServerConfig(
|
|
16
|
+
host="0.0.0.0",
|
|
17
|
+
port=8000,
|
|
18
|
+
log_level="warning",
|
|
19
|
+
access_log=False,
|
|
20
|
+
)
|
|
21
|
+
else:
|
|
22
|
+
return ServerConfig(
|
|
23
|
+
host="0.0.0.0",
|
|
24
|
+
port=8000,
|
|
25
|
+
log_level="debug",
|
|
26
|
+
access_log=True,
|
|
27
|
+
)
|