juniper-service-core 0.1.0__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.
- juniper_service_core-0.1.0/LICENSE +21 -0
- juniper_service_core-0.1.0/PKG-INFO +104 -0
- juniper_service_core-0.1.0/README.md +72 -0
- juniper_service_core-0.1.0/juniper_service_core/__init__.py +103 -0
- juniper_service_core-0.1.0/juniper_service_core/_version.py +5 -0
- juniper_service_core-0.1.0/juniper_service_core/app.py +39 -0
- juniper_service_core-0.1.0/juniper_service_core/health.py +41 -0
- juniper_service_core-0.1.0/juniper_service_core/launcher.py +198 -0
- juniper_service_core-0.1.0/juniper_service_core/lifecycle.py +80 -0
- juniper_service_core-0.1.0/juniper_service_core/middleware.py +203 -0
- juniper_service_core-0.1.0/juniper_service_core/secrets.py +36 -0
- juniper_service_core-0.1.0/juniper_service_core/security.py +282 -0
- juniper_service_core-0.1.0/juniper_service_core/settings.py +36 -0
- juniper_service_core-0.1.0/juniper_service_core.egg-info/PKG-INFO +104 -0
- juniper_service_core-0.1.0/juniper_service_core.egg-info/SOURCES.txt +26 -0
- juniper_service_core-0.1.0/juniper_service_core.egg-info/dependency_links.txt +1 -0
- juniper_service_core-0.1.0/juniper_service_core.egg-info/requires.txt +10 -0
- juniper_service_core-0.1.0/juniper_service_core.egg-info/top_level.txt +1 -0
- juniper_service_core-0.1.0/pyproject.toml +82 -0
- juniper_service_core-0.1.0/setup.cfg +4 -0
- juniper_service_core-0.1.0/tests/test_app.py +42 -0
- juniper_service_core-0.1.0/tests/test_launcher.py +122 -0
- juniper_service_core-0.1.0/tests/test_lifecycle.py +79 -0
- juniper_service_core-0.1.0/tests/test_middleware.py +166 -0
- juniper_service_core-0.1.0/tests/test_secrets.py +47 -0
- juniper_service_core-0.1.0/tests/test_security.py +164 -0
- juniper_service_core-0.1.0/tests/test_settings.py +42 -0
- juniper_service_core-0.1.0/tests/test_smoke.py +74 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Overtoad
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: juniper-service-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared service-tier scaffolding (FastAPI app factory, settings base, generic routes) for Juniper ML model services
|
|
5
|
+
Author: Paul Calnon
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pcalnon/juniper-ml
|
|
8
|
+
Project-URL: Repository, https://github.com/pcalnon/juniper-ml
|
|
9
|
+
Project-URL: Issues, https://github.com/pcalnon/juniper-ml/issues
|
|
10
|
+
Keywords: juniper,service,fastapi,scaffolding,middleware
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: fastapi>=0.110
|
|
23
|
+
Requires-Dist: pydantic>=2.0
|
|
24
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
25
|
+
Requires-Dist: juniper-model-core>=0.1.0
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
29
|
+
Requires-Dist: pytest-cov>=5.0; extra == "test"
|
|
30
|
+
Requires-Dist: httpx>=0.27; extra == "test"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# juniper-service-core
|
|
34
|
+
|
|
35
|
+
**Project**: Juniper — Cascade Correlation Neural Network Research Platform
|
|
36
|
+
**Application**: juniper-service-core (subdirectory of juniper-ml)
|
|
37
|
+
**Author**: Paul Calnon
|
|
38
|
+
**License**: MIT License
|
|
39
|
+
**Version**: 0.1.0
|
|
40
|
+
|
|
41
|
+
Shared **service-tier scaffolding** for Juniper ML model services: a model-agnostic
|
|
42
|
+
FastAPI application factory, a `pydantic-settings` base, and a generic
|
|
43
|
+
liveness/readiness health router. This is WS-2 of the model/middleware refactor
|
|
44
|
+
(`notes/JUNIPER_MODEL_MIDDLEWARE_REFACTOR_DESIGN_AND_PLAN_2026-05-31.md` in the
|
|
45
|
+
juniper-ml repo).
|
|
46
|
+
|
|
47
|
+
The `-core` suffix marks it as genuinely shared: it carries **no** model,
|
|
48
|
+
classification, or training logic — those stay in the owning service (e.g.
|
|
49
|
+
`juniper-cascor`) and are passed in.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install juniper-service-core
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## What's in this scaffold
|
|
58
|
+
|
|
59
|
+
| Surface | Module | Purpose |
|
|
60
|
+
|---------|--------|---------|
|
|
61
|
+
| `create_app(...)` | `juniper_service_core.app` | FastAPI app factory: mounts the health router, then any service-supplied routers. Model-agnostic. |
|
|
62
|
+
| `SettingsBase` | `juniper_service_core.settings` | `pydantic-settings` base with generic fields (`service_name`, `host`, `port`, `log_level`). Subclasses set their own `env_prefix`. |
|
|
63
|
+
| `health_router()` | `juniper_service_core.health` | Generic `APIRouter` exposing `GET /v1/health` (liveness) and `GET /v1/health/ready` (readiness). |
|
|
64
|
+
|
|
65
|
+
### Dependency-free top-level import
|
|
66
|
+
|
|
67
|
+
`import juniper_service_core` pulls **no** third-party runtime dependency. Only
|
|
68
|
+
`__version__` is exposed eagerly; `create_app` and `SettingsBase` are imported lazily
|
|
69
|
+
on attribute access (PEP 562 `__getattr__`) from submodules that require `fastapi` /
|
|
70
|
+
`pydantic-settings`. This lets the TestPyPI publish-verify run a clean `--no-deps`
|
|
71
|
+
import check.
|
|
72
|
+
|
|
73
|
+
## Usage
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from juniper_service_core import create_app, SettingsBase
|
|
77
|
+
from pydantic_settings import SettingsConfigDict
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MyServiceSettings(SettingsBase):
|
|
81
|
+
model_config = SettingsConfigDict(env_prefix="JUNIPER_MYSVC_")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
app = create_app(title="My Service", version="1.0.0", routers=[...])
|
|
85
|
+
# GET /v1/health -> {"status": "ok"}
|
|
86
|
+
# GET /v1/health/ready -> {"status": "ready"}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## What's deferred
|
|
90
|
+
|
|
91
|
+
This first PR is an additive package skeleton. The following are intentionally **not**
|
|
92
|
+
in this scaffold and arrive in later WS-2 follow-ups:
|
|
93
|
+
|
|
94
|
+
- Extraction of the **security / middleware / websocket / worker / generic-route**
|
|
95
|
+
helpers from `juniper-cascor`.
|
|
96
|
+
- The `TrainingLifecycleBase` body, which depends on `juniper-model-core`
|
|
97
|
+
(this scaffold does not yet depend on `juniper-model-core`).
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pip install -e ".[test]"
|
|
103
|
+
pytest tests/ -v
|
|
104
|
+
```
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# juniper-service-core
|
|
2
|
+
|
|
3
|
+
**Project**: Juniper — Cascade Correlation Neural Network Research Platform
|
|
4
|
+
**Application**: juniper-service-core (subdirectory of juniper-ml)
|
|
5
|
+
**Author**: Paul Calnon
|
|
6
|
+
**License**: MIT License
|
|
7
|
+
**Version**: 0.1.0
|
|
8
|
+
|
|
9
|
+
Shared **service-tier scaffolding** for Juniper ML model services: a model-agnostic
|
|
10
|
+
FastAPI application factory, a `pydantic-settings` base, and a generic
|
|
11
|
+
liveness/readiness health router. This is WS-2 of the model/middleware refactor
|
|
12
|
+
(`notes/JUNIPER_MODEL_MIDDLEWARE_REFACTOR_DESIGN_AND_PLAN_2026-05-31.md` in the
|
|
13
|
+
juniper-ml repo).
|
|
14
|
+
|
|
15
|
+
The `-core` suffix marks it as genuinely shared: it carries **no** model,
|
|
16
|
+
classification, or training logic — those stay in the owning service (e.g.
|
|
17
|
+
`juniper-cascor`) and are passed in.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install juniper-service-core
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## What's in this scaffold
|
|
26
|
+
|
|
27
|
+
| Surface | Module | Purpose |
|
|
28
|
+
|---------|--------|---------|
|
|
29
|
+
| `create_app(...)` | `juniper_service_core.app` | FastAPI app factory: mounts the health router, then any service-supplied routers. Model-agnostic. |
|
|
30
|
+
| `SettingsBase` | `juniper_service_core.settings` | `pydantic-settings` base with generic fields (`service_name`, `host`, `port`, `log_level`). Subclasses set their own `env_prefix`. |
|
|
31
|
+
| `health_router()` | `juniper_service_core.health` | Generic `APIRouter` exposing `GET /v1/health` (liveness) and `GET /v1/health/ready` (readiness). |
|
|
32
|
+
|
|
33
|
+
### Dependency-free top-level import
|
|
34
|
+
|
|
35
|
+
`import juniper_service_core` pulls **no** third-party runtime dependency. Only
|
|
36
|
+
`__version__` is exposed eagerly; `create_app` and `SettingsBase` are imported lazily
|
|
37
|
+
on attribute access (PEP 562 `__getattr__`) from submodules that require `fastapi` /
|
|
38
|
+
`pydantic-settings`. This lets the TestPyPI publish-verify run a clean `--no-deps`
|
|
39
|
+
import check.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from juniper_service_core import create_app, SettingsBase
|
|
45
|
+
from pydantic_settings import SettingsConfigDict
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MyServiceSettings(SettingsBase):
|
|
49
|
+
model_config = SettingsConfigDict(env_prefix="JUNIPER_MYSVC_")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
app = create_app(title="My Service", version="1.0.0", routers=[...])
|
|
53
|
+
# GET /v1/health -> {"status": "ok"}
|
|
54
|
+
# GET /v1/health/ready -> {"status": "ready"}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## What's deferred
|
|
58
|
+
|
|
59
|
+
This first PR is an additive package skeleton. The following are intentionally **not**
|
|
60
|
+
in this scaffold and arrive in later WS-2 follow-ups:
|
|
61
|
+
|
|
62
|
+
- Extraction of the **security / middleware / websocket / worker / generic-route**
|
|
63
|
+
helpers from `juniper-cascor`.
|
|
64
|
+
- The `TrainingLifecycleBase` body, which depends on `juniper-model-core`
|
|
65
|
+
(this scaffold does not yet depend on `juniper-model-core`).
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install -e ".[test]"
|
|
71
|
+
pytest tests/ -v
|
|
72
|
+
```
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""``juniper-service-core`` -- shared service-tier scaffolding for Juniper ML model services.
|
|
2
|
+
|
|
3
|
+
A genuinely-shared abstraction (the ``-core`` suffix): the minimal, model-agnostic
|
|
4
|
+
FastAPI plumbing every Juniper model service needs -- an app factory
|
|
5
|
+
(:func:`create_app`), a pydantic-settings base (:class:`SettingsBase`), and a generic
|
|
6
|
+
liveness/readiness health router. WS-2 of the model/middleware refactor
|
|
7
|
+
(``notes/JUNIPER_MODEL_MIDDLEWARE_REFACTOR_DESIGN_AND_PLAN_2026-05-31.md`` in the
|
|
8
|
+
juniper-ml repo).
|
|
9
|
+
|
|
10
|
+
**Dependency-free top-level import.** Importing this top-level package pulls **no**
|
|
11
|
+
third-party runtime dependency. Only :data:`__version__` is exposed eagerly; the rest
|
|
12
|
+
of the public surface (``create_app``, ``SettingsBase``, the security helpers, the
|
|
13
|
+
secrets helper, and the middleware classes) is imported lazily on attribute access
|
|
14
|
+
(PEP 562 ``__getattr__``) from submodules that *do* require ``fastapi`` /
|
|
15
|
+
``pydantic-settings`` / ``starlette``. This is what lets the TestPyPI publish-verify
|
|
16
|
+
run a clean ``--no-deps`` ``import juniper_service_core`` check.
|
|
17
|
+
|
|
18
|
+
cascor's generic service infra extracted so far (de-cascored): API-key auth + rate
|
|
19
|
+
limiting (:mod:`~juniper_service_core.security`), Docker-secrets reading
|
|
20
|
+
(:mod:`~juniper_service_core.secrets`), the security / body-limit middleware
|
|
21
|
+
(:mod:`~juniper_service_core.middleware`), the subprocess service launcher
|
|
22
|
+
(:mod:`~juniper_service_core.launcher`), and the **synchronous**
|
|
23
|
+
``TrainingLifecycleBase`` body (:mod:`~juniper_service_core.lifecycle`, which drives a
|
|
24
|
+
``juniper-model-core`` ``TrainableModel``). The websocket / worker / generic-route
|
|
25
|
+
helpers -- and the threaded / worker-coordinated lifecycle bodies (OQ-11) -- remain
|
|
26
|
+
deferred (later WS-2 follow-ups).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from juniper_service_core._version import __version__
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"__version__",
|
|
35
|
+
"create_app",
|
|
36
|
+
"SettingsBase",
|
|
37
|
+
# Security (lazy, from .security)
|
|
38
|
+
"APIKeyAuth",
|
|
39
|
+
"RateLimiter",
|
|
40
|
+
"api_key_header",
|
|
41
|
+
"build_api_key_auth",
|
|
42
|
+
"build_rate_limiter",
|
|
43
|
+
# Secrets (lazy, from .secrets)
|
|
44
|
+
"get_secret",
|
|
45
|
+
# Middleware (lazy, from .middleware)
|
|
46
|
+
"SecurityHeadersMiddleware",
|
|
47
|
+
"RequestBodyLimitMiddleware",
|
|
48
|
+
"SecurityMiddleware",
|
|
49
|
+
# Launcher (lazy, from .launcher -- stdlib-only)
|
|
50
|
+
"ManagedService",
|
|
51
|
+
"start_service",
|
|
52
|
+
"wait_for_health",
|
|
53
|
+
# Lifecycle (lazy, from .lifecycle -- requires juniper-model-core)
|
|
54
|
+
"TrainingLifecycle",
|
|
55
|
+
"EventCollector",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# Maps each lazily-resolved public name to the submodule that defines it. Keeping
|
|
59
|
+
# these imports out of module top level preserves the dependency-free
|
|
60
|
+
# ``import juniper_service_core`` guarantee: ``fastapi`` / ``pydantic-settings`` /
|
|
61
|
+
# ``starlette`` are only imported when one of these names is actually accessed.
|
|
62
|
+
# (``.secrets`` is stdlib-only, but is routed here too for uniformity.)
|
|
63
|
+
_LAZY_EXPORTS = {
|
|
64
|
+
"create_app": "juniper_service_core.app",
|
|
65
|
+
"SettingsBase": "juniper_service_core.settings",
|
|
66
|
+
"APIKeyAuth": "juniper_service_core.security",
|
|
67
|
+
"RateLimiter": "juniper_service_core.security",
|
|
68
|
+
"api_key_header": "juniper_service_core.security",
|
|
69
|
+
"build_api_key_auth": "juniper_service_core.security",
|
|
70
|
+
"build_rate_limiter": "juniper_service_core.security",
|
|
71
|
+
"get_secret": "juniper_service_core.secrets",
|
|
72
|
+
"SecurityHeadersMiddleware": "juniper_service_core.middleware",
|
|
73
|
+
"RequestBodyLimitMiddleware": "juniper_service_core.middleware",
|
|
74
|
+
"SecurityMiddleware": "juniper_service_core.middleware",
|
|
75
|
+
# .launcher is stdlib-only (asyncio / subprocess / urllib), but is routed
|
|
76
|
+
# through the lazy path too so the PEP 562 pattern stays uniform.
|
|
77
|
+
"ManagedService": "juniper_service_core.launcher",
|
|
78
|
+
"start_service": "juniper_service_core.launcher",
|
|
79
|
+
"wait_for_health": "juniper_service_core.launcher",
|
|
80
|
+
# .lifecycle requires juniper-model-core; kept lazy so the top-level import stays
|
|
81
|
+
# dependency-free and the --no-deps publish-verify still works.
|
|
82
|
+
"TrainingLifecycle": "juniper_service_core.lifecycle",
|
|
83
|
+
"EventCollector": "juniper_service_core.lifecycle",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def __getattr__(name: str):
|
|
88
|
+
"""Lazily resolve the third-party-dependent public surface (PEP 562).
|
|
89
|
+
|
|
90
|
+
Keeping these imports out of module top level preserves the dependency-free
|
|
91
|
+
``import juniper_service_core`` guarantee: ``fastapi`` / ``pydantic-settings`` /
|
|
92
|
+
``starlette`` are only imported when one of the lazy exports is accessed.
|
|
93
|
+
"""
|
|
94
|
+
module_name = _LAZY_EXPORTS.get(name)
|
|
95
|
+
if module_name is not None:
|
|
96
|
+
from importlib import import_module
|
|
97
|
+
|
|
98
|
+
return getattr(import_module(module_name), name)
|
|
99
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def __dir__() -> list[str]:
|
|
103
|
+
return sorted(__all__)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""FastAPI application factory for Juniper model services.
|
|
2
|
+
|
|
3
|
+
:func:`create_app` builds a model-agnostic FastAPI app, mounts the generic health
|
|
4
|
+
router, then includes any service-supplied routers. It carries **no** model,
|
|
5
|
+
classification, or training logic -- those live in the owning service and are passed in
|
|
6
|
+
as ``routers``. This keeps the service-tier scaffolding reusable across every Juniper
|
|
7
|
+
model service (WS-2).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, FastAPI
|
|
15
|
+
|
|
16
|
+
from juniper_service_core.health import health_router
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_app(
|
|
20
|
+
*,
|
|
21
|
+
title: str = "Juniper Service",
|
|
22
|
+
version: str = "0.1.0",
|
|
23
|
+
routers: Iterable[APIRouter] = (),
|
|
24
|
+
) -> FastAPI:
|
|
25
|
+
"""Create a FastAPI app with the generic health router plus any extra routers.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
title: OpenAPI title for the app.
|
|
29
|
+
version: OpenAPI version string for the app.
|
|
30
|
+
routers: Additional service routers to mount after the health router.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
A configured :class:`~fastapi.FastAPI` instance. Model-agnostic by design.
|
|
34
|
+
"""
|
|
35
|
+
app = FastAPI(title=title, version=version)
|
|
36
|
+
app.include_router(health_router())
|
|
37
|
+
for router in routers:
|
|
38
|
+
app.include_router(router)
|
|
39
|
+
return app
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Generic liveness/readiness health router shared by Juniper model services.
|
|
2
|
+
|
|
3
|
+
Provides a minimal, model-agnostic ``/v1/health`` (liveness) and ``/v1/health/ready``
|
|
4
|
+
(readiness) pair. Services that need richer dependency-probe readiness (the
|
|
5
|
+
``ReadinessResponse`` contract) layer that on via ``juniper-observability``; this router
|
|
6
|
+
is the baseline every service gets for free from the app factory.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HealthStatus(BaseModel):
|
|
16
|
+
"""Response body for the generic health endpoints."""
|
|
17
|
+
|
|
18
|
+
status: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def health_router() -> APIRouter:
|
|
22
|
+
"""Build the generic health :class:`~fastapi.APIRouter`.
|
|
23
|
+
|
|
24
|
+
Returns a router exposing:
|
|
25
|
+
|
|
26
|
+
* ``GET /v1/health`` -- liveness, returns ``{"status": "ok"}``.
|
|
27
|
+
* ``GET /v1/health/ready`` -- readiness, returns ``{"status": "ready"}``.
|
|
28
|
+
"""
|
|
29
|
+
router = APIRouter(tags=["health"])
|
|
30
|
+
|
|
31
|
+
@router.get("/v1/health", response_model=HealthStatus)
|
|
32
|
+
async def health() -> HealthStatus:
|
|
33
|
+
"""Liveness probe: the process is up and serving requests."""
|
|
34
|
+
return HealthStatus(status="ok")
|
|
35
|
+
|
|
36
|
+
@router.get("/v1/health/ready", response_model=HealthStatus)
|
|
37
|
+
async def health_ready() -> HealthStatus:
|
|
38
|
+
"""Readiness probe: the service is ready to accept traffic."""
|
|
39
|
+
return HealthStatus(status="ready")
|
|
40
|
+
|
|
41
|
+
return router
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Generic subprocess launcher for companion services.
|
|
2
|
+
|
|
3
|
+
Provides a small, model-agnostic mechanism for starting auxiliary services as
|
|
4
|
+
managed subprocesses and waiting for them to report healthy over HTTP:
|
|
5
|
+
|
|
6
|
+
* :class:`ManagedService` — a subprocess wrapper with lifecycle support
|
|
7
|
+
(``is_running`` / ``terminate``).
|
|
8
|
+
* :func:`wait_for_health` — poll an HTTP health endpoint until it responds 200
|
|
9
|
+
or a timeout expires.
|
|
10
|
+
* :func:`start_service` — ``Popen`` a service from a shell-command string and
|
|
11
|
+
wait for it to become healthy.
|
|
12
|
+
|
|
13
|
+
Started services are tracked in a module-level registry and terminated on
|
|
14
|
+
interpreter exit via an :mod:`atexit` hook.
|
|
15
|
+
|
|
16
|
+
Primarily intended for non-containerized (local development) environments where
|
|
17
|
+
Docker Compose is not managing service orchestration. In Docker deployments,
|
|
18
|
+
use ``depends_on`` with ``condition: service_healthy`` instead.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import atexit
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import shlex
|
|
26
|
+
import subprocess # nosec B404 — subprocess is the core purpose of this module
|
|
27
|
+
import urllib.request
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
# Local timeout / interval defaults (seconds). Defined here (rather than imported
|
|
31
|
+
# from a project-specific constants module) so this launcher carries no coupling to
|
|
32
|
+
# any particular Juniper service.
|
|
33
|
+
_HEALTH_CHECK_HTTP_TIMEOUT = 5.0
|
|
34
|
+
_PROCESS_TERMINATION_TIMEOUT = 5.0
|
|
35
|
+
_SERVICE_DEFAULT_TERMINATE_TIMEOUT = 10.0
|
|
36
|
+
_SERVICE_HEALTH_POLL_INTERVAL = 0.5
|
|
37
|
+
_SERVICE_HEALTH_POLL_TIMEOUT = 30.0
|
|
38
|
+
_SERVICE_TERMINATION_TIMEOUT = 10.0
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("juniper_service_core.launcher")
|
|
41
|
+
|
|
42
|
+
_active_services: list["ManagedService"] = []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ManagedService:
|
|
46
|
+
"""A subprocess-managed companion service with lifecycle support."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
name: str,
|
|
51
|
+
process: subprocess.Popen,
|
|
52
|
+
log_handle: object | None = None,
|
|
53
|
+
):
|
|
54
|
+
self.name = name
|
|
55
|
+
self.process = process
|
|
56
|
+
self._log_handle = log_handle
|
|
57
|
+
|
|
58
|
+
def is_running(self) -> bool:
|
|
59
|
+
return self.process.poll() is None
|
|
60
|
+
|
|
61
|
+
def terminate(self, timeout: float = _SERVICE_DEFAULT_TERMINATE_TIMEOUT) -> None:
|
|
62
|
+
if not self.is_running():
|
|
63
|
+
logger.debug(f"{self.name} already stopped (rc={self.process.returncode})")
|
|
64
|
+
self._close_log()
|
|
65
|
+
return
|
|
66
|
+
logger.info(f"Terminating {self.name} (pid={self.process.pid})")
|
|
67
|
+
self.process.terminate()
|
|
68
|
+
try:
|
|
69
|
+
self.process.wait(timeout=timeout)
|
|
70
|
+
logger.info(f"{self.name} stopped gracefully")
|
|
71
|
+
except subprocess.TimeoutExpired:
|
|
72
|
+
logger.warning(f"{self.name} did not stop in {timeout}s, sending SIGKILL")
|
|
73
|
+
self.process.kill()
|
|
74
|
+
self.process.wait(timeout=_PROCESS_TERMINATION_TIMEOUT)
|
|
75
|
+
logger.info(f"{self.name} killed")
|
|
76
|
+
self._close_log()
|
|
77
|
+
|
|
78
|
+
def _close_log(self) -> None:
|
|
79
|
+
if self._log_handle is not None:
|
|
80
|
+
try:
|
|
81
|
+
self._log_handle.close()
|
|
82
|
+
except Exception: # nosec B110 — cleanup must not propagate exceptions
|
|
83
|
+
pass
|
|
84
|
+
self._log_handle = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _cleanup_at_exit() -> None:
|
|
88
|
+
"""Terminate all managed services on interpreter exit."""
|
|
89
|
+
for svc in _active_services:
|
|
90
|
+
try:
|
|
91
|
+
svc.terminate(timeout=_SERVICE_TERMINATION_TIMEOUT)
|
|
92
|
+
except Exception: # nosec B110 — cleanup must not propagate exceptions
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
atexit.register(_cleanup_at_exit)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _resolve_log_dir() -> Path:
|
|
100
|
+
"""Resolve the canonical log directory for subprocess output."""
|
|
101
|
+
return Path(os.environ.get("JUNIPER_SERVICE_LOG_DIR") or (Path.cwd() / "logs"))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def wait_for_health(
|
|
105
|
+
url: str,
|
|
106
|
+
timeout: float = _SERVICE_HEALTH_POLL_TIMEOUT,
|
|
107
|
+
interval: float = _SERVICE_HEALTH_POLL_INTERVAL,
|
|
108
|
+
) -> bool:
|
|
109
|
+
"""Poll a health endpoint until it responds HTTP 200 or timeout expires."""
|
|
110
|
+
import time
|
|
111
|
+
|
|
112
|
+
deadline = time.monotonic() + timeout
|
|
113
|
+
while time.monotonic() < deadline:
|
|
114
|
+
try:
|
|
115
|
+
req = urllib.request.Request(url, method="GET")
|
|
116
|
+
with urllib.request.urlopen(req, timeout=_HEALTH_CHECK_HTTP_TIMEOUT) as resp: # nosec B310 — internal health check URL from configuration
|
|
117
|
+
if resp.status == 200:
|
|
118
|
+
return True
|
|
119
|
+
except Exception: # nosec B110 — health poll retries on any exception
|
|
120
|
+
pass
|
|
121
|
+
await asyncio.sleep(interval)
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def start_service(
|
|
126
|
+
name: str,
|
|
127
|
+
command: str,
|
|
128
|
+
health_url: str,
|
|
129
|
+
env_overrides: dict[str, str] | None = None,
|
|
130
|
+
health_timeout: float = _SERVICE_HEALTH_POLL_TIMEOUT,
|
|
131
|
+
) -> ManagedService | None:
|
|
132
|
+
"""Start a service as a subprocess and wait for it to become healthy.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
name: Human-readable service name for logging.
|
|
136
|
+
command: Shell command string to start the service (parsed with shlex).
|
|
137
|
+
health_url: URL to poll for health status (expects HTTP 200).
|
|
138
|
+
env_overrides: Additional environment variables for the subprocess.
|
|
139
|
+
health_timeout: Seconds to wait for the health check to pass.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
ManagedService instance if started successfully, None otherwise.
|
|
143
|
+
"""
|
|
144
|
+
cmd_parts = shlex.split(command)
|
|
145
|
+
logger.info(f"Starting {name}: {command}")
|
|
146
|
+
|
|
147
|
+
env = os.environ.copy()
|
|
148
|
+
if env_overrides:
|
|
149
|
+
env.update(env_overrides)
|
|
150
|
+
|
|
151
|
+
# Redirect subprocess output to a log file for diagnostics
|
|
152
|
+
log_dir = _resolve_log_dir()
|
|
153
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
safe_name = name.lower().replace(" ", "_").replace("-", "_")
|
|
155
|
+
log_file = log_dir / f"subprocess_{safe_name}.log"
|
|
156
|
+
|
|
157
|
+
log_handle = None
|
|
158
|
+
stdout_target = subprocess.DEVNULL
|
|
159
|
+
stderr_target = subprocess.DEVNULL
|
|
160
|
+
try:
|
|
161
|
+
log_handle = open(log_file, "a", encoding="utf-8")
|
|
162
|
+
stdout_target = log_handle
|
|
163
|
+
stderr_target = subprocess.STDOUT
|
|
164
|
+
logger.info(f"{name} output -> {log_file}")
|
|
165
|
+
except OSError:
|
|
166
|
+
logger.warning(f"Could not open log file {log_file}, using /dev/null")
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
process = subprocess.Popen( # nosec B603 — command is from settings, not user input
|
|
170
|
+
cmd_parts,
|
|
171
|
+
env=env,
|
|
172
|
+
stdout=stdout_target,
|
|
173
|
+
stderr=stderr_target,
|
|
174
|
+
start_new_session=True,
|
|
175
|
+
)
|
|
176
|
+
except Exception:
|
|
177
|
+
logger.exception(f"Failed to start {name}")
|
|
178
|
+
if log_handle:
|
|
179
|
+
log_handle.close()
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
service = ManagedService(name, process, log_handle)
|
|
183
|
+
_active_services.append(service)
|
|
184
|
+
|
|
185
|
+
logger.info(f"Waiting for {name} health at {health_url} (timeout={health_timeout}s)")
|
|
186
|
+
healthy = await wait_for_health(health_url, timeout=health_timeout)
|
|
187
|
+
|
|
188
|
+
if not healthy:
|
|
189
|
+
if service.is_running():
|
|
190
|
+
logger.error(f"{name} started but health check failed after {health_timeout}s")
|
|
191
|
+
else:
|
|
192
|
+
logger.error(f"{name} exited prematurely (rc={process.returncode})")
|
|
193
|
+
service.terminate()
|
|
194
|
+
_active_services.remove(service)
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
logger.info(f"{name} is healthy (pid={process.pid})")
|
|
198
|
+
return service
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Generic training-lifecycle bodies (WS-2).
|
|
2
|
+
|
|
3
|
+
Concrete :class:`juniper_model_core.lifecycle.TrainingLifecycleBase` implementations that
|
|
4
|
+
drive a model-core :class:`~juniper_model_core.interfaces.TrainableModel` through training
|
|
5
|
+
and forward the model's :class:`~juniper_model_core.events.TrainingEvent`s to the injected
|
|
6
|
+
sink. This is the **synchronous foundation**; the threaded / finite-state-machine /
|
|
7
|
+
dataset-hot-swap / worker-coordinated bodies -- and the worker-parallelism question
|
|
8
|
+
(OQ-11) -- are deferred follow-ups (model-core ``lifecycle.py`` decision D8).
|
|
9
|
+
|
|
10
|
+
Importing this module requires ``juniper-model-core`` (the model contract); it is therefore
|
|
11
|
+
NOT imported at the top level of :mod:`juniper_service_core` (it is exposed lazily) so the
|
|
12
|
+
dependency-free top-level import is preserved.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import replace
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from juniper_model_core.lifecycle import TrainingLifecycleBase
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
import numpy as np
|
|
25
|
+
from juniper_model_core.events import TrainingEvent
|
|
26
|
+
from juniper_model_core.interfaces import TrainableModel, TrainResult
|
|
27
|
+
|
|
28
|
+
__all__ = ["TrainingLifecycle", "EventCollector"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EventCollector:
|
|
32
|
+
"""A simple, ordered event sink -- for tests, inspection, and replay.
|
|
33
|
+
|
|
34
|
+
Use it as the ``on_event`` sink of a lifecycle; it records every emitted event in order.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self.events: list[TrainingEvent] = []
|
|
39
|
+
|
|
40
|
+
def __call__(self, event: TrainingEvent) -> None:
|
|
41
|
+
self.events.append(event)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def types(self) -> list[str]:
|
|
45
|
+
"""The event ``type`` strings, in emission order."""
|
|
46
|
+
return [event.type for event in self.events]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TrainingLifecycle(TrainingLifecycleBase):
|
|
50
|
+
"""Synchronous lifecycle: drives ``model.fit`` to completion on the calling thread.
|
|
51
|
+
|
|
52
|
+
:meth:`run` wires the model's ``on_event`` to this lifecycle's :meth:`emit`, so the
|
|
53
|
+
model's progress events flow to the injected sink. The lifecycle owns **run-level
|
|
54
|
+
ordering**: it stamps a monotonic ``seq`` on each event as it passes through, so the
|
|
55
|
+
sink sees a legally-ordered stream regardless of what ``seq`` the model emits.
|
|
56
|
+
|
|
57
|
+
Growth (``unit_added``) for a :class:`~juniper_model_core.interfaces.GrowableModel`
|
|
58
|
+
happens *inside* its ``fit`` (the model-core contract), so this single synchronous body
|
|
59
|
+
drives both fixed-topology and growable models. The threaded / FSM / dataset-hot-swap /
|
|
60
|
+
worker-coordinated bodies are deferred (D8; worker parallelism is OQ-11).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, model: TrainableModel, on_event: Callable[[TrainingEvent], None] | None = None) -> None:
|
|
64
|
+
super().__init__(model, on_event)
|
|
65
|
+
self._seq = 0
|
|
66
|
+
|
|
67
|
+
def emit(self, event: TrainingEvent) -> None:
|
|
68
|
+
"""Forward ``event`` to the sink with a monotonic, run-scoped ``seq`` stamped."""
|
|
69
|
+
super().emit(replace(event, seq=self._seq))
|
|
70
|
+
self._seq += 1
|
|
71
|
+
|
|
72
|
+
def run(self, X: np.ndarray, y: np.ndarray, **kw: Any) -> TrainResult:
|
|
73
|
+
"""Drive the model's full ``fit`` synchronously, routing its events through the
|
|
74
|
+
lifecycle.
|
|
75
|
+
|
|
76
|
+
``**kw`` (e.g. ``X_val`` / ``y_val``, or sequence auxiliaries like ``dt``) is
|
|
77
|
+
forwarded to ``fit``. Do **not** pass ``on_event`` -- the lifecycle owns the sink.
|
|
78
|
+
"""
|
|
79
|
+
self._seq = 0
|
|
80
|
+
return self.model.fit(X, y, on_event=self.emit, **kw)
|