seren-observatory 0.0.0.dev0__py3-none-any.whl
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.
- seren_observatory/__init__.py +23 -0
- seren_observatory/__main__.py +37 -0
- seren_observatory/_version.py +24 -0
- seren_observatory/app.py +172 -0
- seren_observatory/auth.py +134 -0
- seren_observatory/config.py +153 -0
- seren_observatory/lifecycle.py +1072 -0
- seren_observatory/manifests.py +154 -0
- seren_observatory/request_log.py +137 -0
- seren_observatory/requirements.txt +20 -0
- seren_observatory/service_routes.py +124 -0
- seren_observatory/services/__init__.py +50 -0
- seren_observatory/services/comfy.py +110 -0
- seren_observatory/services/coral.py +89 -0
- seren_observatory/services/kokoro.py +60 -0
- seren_observatory/services/llama.py +52 -0
- seren_observatory/services/whisper.py +66 -0
- seren_observatory/system_routes.py +496 -0
- seren_observatory/viewer/ui/body.html +29 -0
- seren_observatory/viewer/ui/header_aside.html +6 -0
- seren_observatory/viewer/ui/scripts.js +230 -0
- seren_observatory/viewer/ui/styles.css +227 -0
- seren_observatory/viewer/ui/tabs.html +5 -0
- seren_observatory-0.0.0.dev0.dist-info/METADATA +31 -0
- seren_observatory-0.0.0.dev0.dist-info/RECORD +28 -0
- seren_observatory-0.0.0.dev0.dist-info/WHEEL +5 -0
- seren_observatory-0.0.0.dev0.dist-info/entry_points.txt +2 -0
- seren_observatory-0.0.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
seren-observatory - per-Jetson management plane.
|
|
3
|
+
|
|
4
|
+
HTTP API exposing manifest-driven discovery and lifecycle of locally-installed
|
|
5
|
+
Seren services (llama, kokoro, comfy, whisper, coral). Consumed by:
|
|
6
|
+
- SerenRuntimeHost (C#) for chat-app workflows
|
|
7
|
+
- SerenCommandCenter (SCC, future) for cluster orchestration
|
|
8
|
+
- The NUC dashboard for monitoring
|
|
9
|
+
|
|
10
|
+
Routes are versioned under /api/v1/. See service_routes.py and system_routes.py.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
__version__: str = version("seren-observatory")
|
|
18
|
+
except PackageNotFoundError:
|
|
19
|
+
# Running from a source checkout without an editable install.
|
|
20
|
+
# Set SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 and run:
|
|
21
|
+
# pip install -e ".[dev]"
|
|
22
|
+
# to resolve this.
|
|
23
|
+
__version__ = "0.0.0.dev"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Entry point for `python -m seren_observatory`.
|
|
2
|
+
|
|
3
|
+
Accepts --config / -c to match the SerenMemory convention (Memory leads, the
|
|
4
|
+
rest follow). Host/port come from the resolved config (which itself layers
|
|
5
|
+
defaults < yaml < env). The bearer token is NOT a config concern - it's loaded
|
|
6
|
+
separately from ~/.seren/secrets.json by auth.load_token(). See config.py.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
|
|
12
|
+
import uvicorn
|
|
13
|
+
|
|
14
|
+
from .app import create_app
|
|
15
|
+
from .config import load_config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="seren_observatory",
|
|
21
|
+
description="seren-observatory - per-node management plane.")
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--config", "-c", default=None,
|
|
24
|
+
help="Path to seren-observatory.yaml (default: $SEREN_AGENT_CONFIG, then "
|
|
25
|
+
"~/seren-observatory/seren-observatory.yaml, falling back to built-in "
|
|
26
|
+
"defaults of 0.0.0.0:7777).")
|
|
27
|
+
args = parser.parse_args()
|
|
28
|
+
|
|
29
|
+
cfg = load_config(args.config)
|
|
30
|
+
app = create_app(cfg)
|
|
31
|
+
|
|
32
|
+
print(f"[seren-observatory] listening on {cfg.host}:{cfg.port}")
|
|
33
|
+
uvicorn.run(app, host=cfg.host, port=cfg.port, log_level="info")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
main()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.0.0.dev0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 0, 'dev0')
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
seren_observatory/app.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
seren-observatory - main FastAPI app.
|
|
3
|
+
|
|
4
|
+
Run with:
|
|
5
|
+
seren-observatory # via the installed console script
|
|
6
|
+
python -m seren_observatory # config-aware entry (--config/-c)
|
|
7
|
+
python -m seren_observatory.app # directly from the source tree
|
|
8
|
+
uvicorn seren_observatory.app:app # ASGI server pointing at module app
|
|
9
|
+
|
|
10
|
+
or via systemd / a launcher. Listens on 0.0.0.0:7777 by default.
|
|
11
|
+
|
|
12
|
+
Config: host/port resolve via config.load_config() (defaults < yaml's server:
|
|
13
|
+
block < env vars). The bearer token is loaded SEPARATELY from
|
|
14
|
+
~/.seren/secrets.json by auth.load_token() - it's a safety interlock, not a
|
|
15
|
+
config field. See config.py for why.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from fastapi import FastAPI
|
|
24
|
+
from fastapi.responses import HTMLResponse
|
|
25
|
+
|
|
26
|
+
from . import __version__, manifests
|
|
27
|
+
from .auth import BearerAuthMiddleware, load_token
|
|
28
|
+
from .config import ObservatoryConfig, load_config
|
|
29
|
+
from .request_log import RequestLoggingMiddleware
|
|
30
|
+
from .service_routes import register_all_services
|
|
31
|
+
from .system_routes import router as system_router
|
|
32
|
+
|
|
33
|
+
from seren_meninges import get_version
|
|
34
|
+
from seren_meninges.viewer import render_from_dir
|
|
35
|
+
|
|
36
|
+
# Version via the shared SerenMeninges helper: the installed wheel's
|
|
37
|
+
# setuptools-scm metadata, falling back to the package __version__ for an
|
|
38
|
+
# editable/dev checkout where dist metadata may be absent. get_version never
|
|
39
|
+
# raises - the same one-liner the rest of the family uses, replacing the old
|
|
40
|
+
# hand-rolled importlib.metadata block that drifted from the others.
|
|
41
|
+
APP_VERSION = get_version("seren-observatory", fallback=__version__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_app(cfg: ObservatoryConfig | None = None) -> FastAPI:
|
|
45
|
+
# cfg is accepted so the config-aware entry points (python -m seren_observatory)
|
|
46
|
+
# can pass a resolved config. When called with no arg (e.g. the
|
|
47
|
+
# module-level `app` below, or `uvicorn seren_observatory.app:app`), fall back to
|
|
48
|
+
# load_config() so behaviour is identical either way. cfg currently carries
|
|
49
|
+
# host/port; those are consumed by the caller that runs uvicorn, so the app
|
|
50
|
+
# body doesn't need them - but we resolve it anyway so a future need (e.g.
|
|
51
|
+
# surfacing the bind in the root page) has it on hand without another load.
|
|
52
|
+
cfg = cfg or load_config()
|
|
53
|
+
|
|
54
|
+
app = FastAPI(
|
|
55
|
+
title="seren-observatory",
|
|
56
|
+
version=APP_VERSION,
|
|
57
|
+
description="Per-Jetson management plane. Manifest-driven service "
|
|
58
|
+
"lifecycle, status, and orchestration. Bearer token auth "
|
|
59
|
+
"on everything except /api/v1/system/{ping,version}.",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Request logging - wraps every request, captures timing + status +
|
|
63
|
+
# 500 tracebacks. Logs go to BOTH stderr (journalctl) AND a rotating
|
|
64
|
+
# file at ~/seren-logs/observatory-requests.log (no sudo needed to read).
|
|
65
|
+
#
|
|
66
|
+
# MUST be added BEFORE auth so we log auth-rejected requests too -
|
|
67
|
+
# that's actually one of the most useful debug signals ("dashboard
|
|
68
|
+
# is failing - is the bearer token wrong, or is the route 500ing?")
|
|
69
|
+
#
|
|
70
|
+
# Starlette/FastAPI middleware order: LIFO at request time. So adding
|
|
71
|
+
# RequestLoggingMiddleware FIRST means it runs LAST on the way in
|
|
72
|
+
# (closest to the route), and FIRST on the way out - wrapping the
|
|
73
|
+
# entire chain. Adding auth SECOND means auth runs BEFORE logging on
|
|
74
|
+
# request, which is wrong. We want logging OUTERMOST.
|
|
75
|
+
#
|
|
76
|
+
# Correct stack (request flow top→bottom):
|
|
77
|
+
# RequestLoggingMiddleware (logs everything, including 401s)
|
|
78
|
+
# BearerAuthMiddleware (rejects unauthed, logging sees the rejection)
|
|
79
|
+
# <route handler>
|
|
80
|
+
#
|
|
81
|
+
# FastAPI add_middleware adds in REVERSE order at runtime, so we add
|
|
82
|
+
# auth FIRST (will run inner) and logging SECOND (will run outer).
|
|
83
|
+
token = load_token()
|
|
84
|
+
app.add_middleware(BearerAuthMiddleware, expected_token=token)
|
|
85
|
+
app.add_middleware(RequestLoggingMiddleware)
|
|
86
|
+
|
|
87
|
+
# Root info page - no service data, just links + auth status indicator
|
|
88
|
+
@app.get("/", response_class=HTMLResponse)
|
|
89
|
+
async def root() -> str:
|
|
90
|
+
node = manifests.load_node()
|
|
91
|
+
host = (node or {}).get("hostname", "unknown")
|
|
92
|
+
auth_state = "configured" if token else "DISABLED (no token in ~/.seren/secrets.json)"
|
|
93
|
+
return f"""<!doctype html>
|
|
94
|
+
<html><head><title>seren-observatory - {host}</title></head>
|
|
95
|
+
<body style="font-family: system-ui; max-width: 720px; margin: 2rem auto; padding: 0 1rem;">
|
|
96
|
+
<h1>seren-observatory</h1>
|
|
97
|
+
<p>Per-Jetson management plane for the Seren cluster.</p>
|
|
98
|
+
<dl>
|
|
99
|
+
<dt>Hostname:</dt> <dd>{host}</dd>
|
|
100
|
+
<dt>Observatory version:</dt> <dd>{APP_VERSION}</dd>
|
|
101
|
+
<dt>Auth:</dt> <dd>{auth_state}</dd>
|
|
102
|
+
</dl>
|
|
103
|
+
<h2>Endpoints</h2>
|
|
104
|
+
<ul>
|
|
105
|
+
<li><a href="/docs">/docs</a> - interactive API docs (Swagger)</li>
|
|
106
|
+
<li><a href="/api/v1/system/ping">/api/v1/system/ping</a> - public liveness</li>
|
|
107
|
+
<li><a href="/api/v1/system/version">/api/v1/system/version</a> - public version</li>
|
|
108
|
+
<li>/api/v1/system/{{node, services, health, reclaim}} - auth required</li>
|
|
109
|
+
<li>/api/v1/service/{{name}}/{{start, stop, restart, health, status, logs, manifest}} - auth required</li>
|
|
110
|
+
</ul>
|
|
111
|
+
<p>Source of truth: ~/.seren/services/*.json + ~/.seren/node.json</p>
|
|
112
|
+
</body></html>"""
|
|
113
|
+
|
|
114
|
+
# The Observatory glance - on the shared SerenMeninges baseplate.
|
|
115
|
+
@app.get("/viewer", response_class=HTMLResponse)
|
|
116
|
+
async def viewer() -> str:
|
|
117
|
+
# Node vitals, thermals, health rollup, and the service roster with
|
|
118
|
+
# lifecycle controls. PUBLIC route (the HTML shell needs no auth - see
|
|
119
|
+
# auth.PUBLIC_PATHS); its /api/v1/* fetches carry the token from the
|
|
120
|
+
# shell's key modal. With no token provisioned the read-only glance
|
|
121
|
+
# still works (safe GETs stay open); the action buttons fail closed
|
|
122
|
+
# (503) until ~/.seren/secrets.json exists - the deliberate interlock,
|
|
123
|
+
# surfaced in the UI instead of hidden.
|
|
124
|
+
return render_from_dir(
|
|
125
|
+
Path(__file__).resolve().parent / "viewer" / "ui",
|
|
126
|
+
title="seren-observatory",
|
|
127
|
+
brand="Seren<b>Observatory</b>",
|
|
128
|
+
subtitle=f"v{APP_VERSION} · per-node watch plane",
|
|
129
|
+
accent="#6eff70",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# System routes (ping, version, node, services, health, reclaim)
|
|
133
|
+
app.include_router(system_router)
|
|
134
|
+
|
|
135
|
+
# Per-service routes - one router per installed service
|
|
136
|
+
mounted = register_all_services(app)
|
|
137
|
+
print(f"[seren-observatory] mounted services: {mounted}")
|
|
138
|
+
|
|
139
|
+
return app
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# Module-level app for `uvicorn seren_observatory.app:app`
|
|
143
|
+
app = create_app()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
import uvicorn
|
|
148
|
+
|
|
149
|
+
# Direct `python -m seren_observatory.app` path. Resolve config the same way the
|
|
150
|
+
# console script does so host/port behave identically. (`python -m
|
|
151
|
+
# seren_observatory` -> __main__.py is the preferred, --config-aware entry.)
|
|
152
|
+
_cfg = load_config()
|
|
153
|
+
uvicorn.run(app, host=_cfg.host, port=_cfg.port, log_level="info")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main() -> None:
|
|
157
|
+
"""Console-script entry point: `seren-observatory` (declared in pyproject.toml).
|
|
158
|
+
|
|
159
|
+
Resolves config (defaults < yaml < env) and runs uvicorn. The CLI --config
|
|
160
|
+
flag is handled by __main__.py (`python -m seren_observatory`); the bare
|
|
161
|
+
console-script reads $SEREN_AGENT_CONFIG / the conventional path / defaults.
|
|
162
|
+
"""
|
|
163
|
+
import uvicorn
|
|
164
|
+
|
|
165
|
+
cfg = load_config()
|
|
166
|
+
uvicorn.run(
|
|
167
|
+
"seren_observatory.app:app",
|
|
168
|
+
host=cfg.host,
|
|
169
|
+
port=cfg.port,
|
|
170
|
+
log_level="info",
|
|
171
|
+
reload=False,
|
|
172
|
+
)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bearer-token auth middleware.
|
|
3
|
+
|
|
4
|
+
Token lives at ~/.seren/secrets.json with {"observatory_token": "..."}, chmod 600.
|
|
5
|
+
Generated by `seren-secrets.sh` which is invoked by foundation Phase 1
|
|
6
|
+
(or manually if missing).
|
|
7
|
+
|
|
8
|
+
Skipped paths:
|
|
9
|
+
/ - root info page (links only, no service data)
|
|
10
|
+
/api/v1/system/ping - liveness probe
|
|
11
|
+
/api/v1/system/version - observatory version (no sensitive info)
|
|
12
|
+
|
|
13
|
+
Everything else requires `Authorization: Bearer <token>`.
|
|
14
|
+
|
|
15
|
+
When NO token is configured (fresh install before seren-secrets.sh runs),
|
|
16
|
+
the observatory stays reachable for safe, read-only requests (GET/HEAD/OPTIONS) so
|
|
17
|
+
monitoring and bootstrap work - but it FAILS CLOSED on any state-changing
|
|
18
|
+
method (POST/PUT/PATCH/DELETE). This plane can restart services and trigger a
|
|
19
|
+
sudoers-backed reboot; an unprovisioned observatory on 0.0.0.0 must never be an open
|
|
20
|
+
remote-reboot button. Provision the token to unlock mutating endpoints.
|
|
21
|
+
|
|
22
|
+
Threat model: this is a Jetson on your home LAN. The token protects against
|
|
23
|
+
casual LAN-mate snooping and prevents drive-by RCE if you ever expose the
|
|
24
|
+
observatory port outside your trusted network. It is NOT designed for multi-user
|
|
25
|
+
or untrusted-attacker scenarios. Use a VPN or firewall if those apply.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hmac
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from fastapi import Request, Response
|
|
35
|
+
from fastapi.responses import JSONResponse
|
|
36
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
37
|
+
from starlette.types import ASGIApp
|
|
38
|
+
|
|
39
|
+
SECRETS_PATH = Path(os.path.expanduser("~")) / ".seren" / "secrets.json"
|
|
40
|
+
|
|
41
|
+
# HTTP methods that don't change state. When no token is configured these
|
|
42
|
+
# stay open (read-only introspection for monitoring/bootstrap); everything
|
|
43
|
+
# else is refused until a token exists.
|
|
44
|
+
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
45
|
+
|
|
46
|
+
# Paths that bypass auth. Keep this list MINIMAL - every entry here is an
|
|
47
|
+
# information disclosure or attack-surface concern.
|
|
48
|
+
PUBLIC_PATHS = frozenset({
|
|
49
|
+
"/",
|
|
50
|
+
"/viewer", # the glance HTML shell - public like /, but
|
|
51
|
+
# its /api/v1/* fetches still carry the token,
|
|
52
|
+
# and mutations still fail closed without one.
|
|
53
|
+
"/api/v1/system/ping",
|
|
54
|
+
"/api/v1/system/version",
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_token() -> str | None:
|
|
59
|
+
"""Load the observatory token from ~/.seren/secrets.json, or None if missing.
|
|
60
|
+
|
|
61
|
+
None means "auth is disabled" - the observatory will accept all requests. This
|
|
62
|
+
is meant as a fallback for fresh installs before seren-secrets.sh runs;
|
|
63
|
+
in production all installs should have a token.
|
|
64
|
+
"""
|
|
65
|
+
if not SECRETS_PATH.is_file():
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
with open(SECRETS_PATH) as f:
|
|
69
|
+
data = json.load(f)
|
|
70
|
+
token = data.get("observatory_token")
|
|
71
|
+
if isinstance(token, str) and token:
|
|
72
|
+
return token
|
|
73
|
+
except (json.JSONDecodeError, OSError):
|
|
74
|
+
pass
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class BearerAuthMiddleware(BaseHTTPMiddleware):
|
|
79
|
+
"""ASGI middleware that requires `Authorization: Bearer <token>` on
|
|
80
|
+
every request EXCEPT those listed in PUBLIC_PATHS."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, app: ASGIApp, *, expected_token: str | None) -> None:
|
|
83
|
+
super().__init__(app)
|
|
84
|
+
self._expected = expected_token
|
|
85
|
+
|
|
86
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
87
|
+
path = request.url.path
|
|
88
|
+
|
|
89
|
+
# If no token configured, stay reachable for safe reads but refuse
|
|
90
|
+
# anything that changes state. The fresh-install convenience must not
|
|
91
|
+
# extend to remote service restarts or a sudoers-backed reboot.
|
|
92
|
+
if self._expected is None:
|
|
93
|
+
if request.method not in _SAFE_METHODS:
|
|
94
|
+
return JSONResponse(
|
|
95
|
+
{"error": "unauthorized",
|
|
96
|
+
"detail": "no observatory token configured; service-management "
|
|
97
|
+
"endpoints are disabled until ~/.seren/secrets.json "
|
|
98
|
+
"exists (run seren-secrets.sh)"},
|
|
99
|
+
status_code=503,
|
|
100
|
+
)
|
|
101
|
+
response = await call_next(request)
|
|
102
|
+
response.headers["X-Seren-Auth"] = "disabled-no-token-configured"
|
|
103
|
+
return response
|
|
104
|
+
|
|
105
|
+
if path in PUBLIC_PATHS:
|
|
106
|
+
return await call_next(request)
|
|
107
|
+
|
|
108
|
+
auth_header = request.headers.get("authorization", "")
|
|
109
|
+
if not auth_header.startswith("Bearer "):
|
|
110
|
+
return JSONResponse(
|
|
111
|
+
{"error": "unauthorized", "detail": "missing bearer token"},
|
|
112
|
+
status_code=401,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
provided = auth_header[len("Bearer "):].strip()
|
|
116
|
+
# Constant-time compare to avoid timing leaks on token prefix
|
|
117
|
+
if not _constant_time_eq(provided, self._expected):
|
|
118
|
+
return JSONResponse(
|
|
119
|
+
{"error": "unauthorized", "detail": "invalid token"},
|
|
120
|
+
status_code=401,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return await call_next(request)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _constant_time_eq(a: str, b: str) -> bool:
|
|
127
|
+
"""Constant-time comparison via the stdlib's audited hmac.compare_digest.
|
|
128
|
+
|
|
129
|
+
Encodes to bytes so non-ASCII input can't raise (compare_digest rejects
|
|
130
|
+
non-ASCII str). Different-length inputs return False without raising, so
|
|
131
|
+
this is a drop-in for the previous hand-rolled version - and it means we
|
|
132
|
+
don't pull in the `cryptography` package just to compare two tokens.
|
|
133
|
+
"""
|
|
134
|
+
return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Config for seren-observatory.
|
|
2
|
+
|
|
3
|
+
Follows the SerenMemory convention (Memory leads, the rest follow) so a buddy
|
|
4
|
+
who set up one service already knows how to set up this one:
|
|
5
|
+
|
|
6
|
+
* network settings live under a ``server:`` block (host/port)
|
|
7
|
+
* config resolves: --config -> $SEREN_AGENT_CONFIG ->
|
|
8
|
+
~/seren-observatory/seren-observatory.yaml -> built-in defaults
|
|
9
|
+
* the file is named seren-observatory.yaml
|
|
10
|
+
|
|
11
|
+
DELIBERATE EXCEPTION - the bearer token is NOT here. Unlike SerenMemory, the
|
|
12
|
+
observatory's token is a SAFETY INTERLOCK, not a config knob: this plane can restart
|
|
13
|
+
services and trigger a sudoers-backed reboot, so the token lives in
|
|
14
|
+
~/.seren/secrets.json (chmod 600, written by seren-secrets.sh) and is loaded
|
|
15
|
+
by auth.load_token(). The observatory fails CLOSED on mutating methods when no token
|
|
16
|
+
exists. Putting the token in a yaml field would add a second, lower-security
|
|
17
|
+
path (yaml may be 644, may be committed) next to the deliberate secrets.json
|
|
18
|
+
one - a security regression dressed as consistency. Follow-the-leader on
|
|
19
|
+
STRUCTURE (--config, server: block, resolution order); NOT on collapsing auth
|
|
20
|
+
into config. (Same spirit as Margin keeping 127.0.0.1 instead of inheriting
|
|
21
|
+
Memory's 0.0.0.0.)
|
|
22
|
+
|
|
23
|
+
Precedence (highest wins):
|
|
24
|
+
1. Env vars (AGENT_HOST/AGENT_PORT, and the SEREN_AGENT_* aliases)
|
|
25
|
+
2. YAML file (operator's standing config)
|
|
26
|
+
3. Defaults (0.0.0.0:7777 - the Seren cluster convention)
|
|
27
|
+
|
|
28
|
+
Lenient parse (Postel-as-kindness): missing file or malformed YAML -> log and
|
|
29
|
+
fall back to defaults; a bad single value -> that key falls back, others apply.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Optional
|
|
36
|
+
|
|
37
|
+
from pydantic import BaseModel
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
import yaml # type: ignore[import-untyped]
|
|
41
|
+
_HAS_YAML = True
|
|
42
|
+
except ImportError: # pragma: no cover - pyyaml is a hard dep, but be lenient
|
|
43
|
+
_HAS_YAML = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ObservatoryConfig(BaseModel):
|
|
47
|
+
"""seren-observatory network config. Defaults match the cluster convention:
|
|
48
|
+
bind all interfaces (trusted LAN) on 7777.
|
|
49
|
+
|
|
50
|
+
NOTE: no token field here, on purpose - see module docstring.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
# Bind all interfaces by default - the observatory is a cluster plane meant to be
|
|
54
|
+
# reached from the NUC/RuntimeHost across the trusted LAN. (Contrast Margin,
|
|
55
|
+
# which is private and binds 127.0.0.1.) The auth interlock, not the bind
|
|
56
|
+
# address, is what protects the mutating endpoints.
|
|
57
|
+
host: str = "0.0.0.0"
|
|
58
|
+
port: int = 7777
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_DEFAULT_CONFIG_PATH = Path.home() / "seren-observatory" / "seren-observatory.yaml"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resolve_config_path(explicit_path: Optional[str] = None) -> Optional[Path]:
|
|
65
|
+
"""--config -> $SEREN_AGENT_CONFIG -> ~/seren-observatory/seren-observatory.yaml -> None."""
|
|
66
|
+
if explicit_path:
|
|
67
|
+
return Path(explicit_path).expanduser()
|
|
68
|
+
env = os.getenv("SEREN_AGENT_CONFIG")
|
|
69
|
+
if env:
|
|
70
|
+
return Path(env).expanduser()
|
|
71
|
+
if _DEFAULT_CONFIG_PATH.exists():
|
|
72
|
+
return _DEFAULT_CONFIG_PATH
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load_yaml_lenient(path: Path) -> dict[str, Any]:
|
|
77
|
+
if not path.exists():
|
|
78
|
+
return {}
|
|
79
|
+
if not _HAS_YAML:
|
|
80
|
+
print(f"[seren-observatory] config: pyyaml not installed; ignoring {path}")
|
|
81
|
+
return {}
|
|
82
|
+
try:
|
|
83
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
84
|
+
data = yaml.safe_load(f)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
print(f"[seren-observatory] config: failed to parse {path}: {e} (using defaults)")
|
|
87
|
+
return {}
|
|
88
|
+
if data is None:
|
|
89
|
+
return {}
|
|
90
|
+
if not isinstance(data, dict):
|
|
91
|
+
print(f"[seren-observatory] config: {path} top-level must be a mapping; got "
|
|
92
|
+
f"{type(data).__name__} (using defaults)")
|
|
93
|
+
return {}
|
|
94
|
+
return data
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _apply_server_overrides(cfg: ObservatoryConfig, server: dict[str, Any], *, source: str) -> None:
|
|
98
|
+
"""Apply per-key overrides; each key try/except'd so one bad value doesn't
|
|
99
|
+
sink the others. Only host/port are known - anything else is ignored with
|
|
100
|
+
a note (notably 'bearer_token', which is intentionally NOT honored here)."""
|
|
101
|
+
known = {"host", "port"}
|
|
102
|
+
for key, raw in server.items():
|
|
103
|
+
if key == "bearer_token":
|
|
104
|
+
# Loud, specific note: the token is not a config field by design.
|
|
105
|
+
print("[seren-observatory] config: 'bearer_token' in the yaml is ignored "
|
|
106
|
+
"by design - the observatory token lives in ~/.seren/secrets.json "
|
|
107
|
+
"(run seren-secrets.sh). See config.py for why.")
|
|
108
|
+
continue
|
|
109
|
+
if key not in known:
|
|
110
|
+
print(f"[seren-observatory] config: ignoring unknown server key '{key}' from {source}")
|
|
111
|
+
continue
|
|
112
|
+
try:
|
|
113
|
+
current = cfg.model_dump()
|
|
114
|
+
current[key] = raw
|
|
115
|
+
cfg.__dict__.update(ObservatoryConfig.model_validate(current).__dict__)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
print(f"[seren-observatory] config: ignored bad value for '{key}' from {source}: {e}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_config(path: Optional[str] = None) -> ObservatoryConfig:
|
|
121
|
+
"""Defaults -> YAML (server: block) -> env vars. Never raises on bad input.
|
|
122
|
+
|
|
123
|
+
``path`` is the --config flag value (highest-priority config location).
|
|
124
|
+
"""
|
|
125
|
+
cfg = ObservatoryConfig()
|
|
126
|
+
|
|
127
|
+
# Layer 2: YAML
|
|
128
|
+
yaml_path = _resolve_config_path(path)
|
|
129
|
+
if yaml_path is not None:
|
|
130
|
+
data = _load_yaml_lenient(yaml_path)
|
|
131
|
+
server = data.get("server")
|
|
132
|
+
if isinstance(server, dict):
|
|
133
|
+
_apply_server_overrides(cfg, server, source=str(yaml_path))
|
|
134
|
+
elif server is not None:
|
|
135
|
+
print(f"[seren-observatory] config: 'server' in {yaml_path} must be a mapping; ignoring")
|
|
136
|
+
|
|
137
|
+
# Layer 3: env vars (highest precedence). Honor BOTH the original
|
|
138
|
+
# AGENT_HOST/AGENT_PORT (what app.py historically read, and what the old
|
|
139
|
+
# launcher exported) AND the SEREN_AGENT_* aliases the yaml sample
|
|
140
|
+
# documents. The SEREN_AGENT_* form wins if both are somehow set, since
|
|
141
|
+
# it's the documented, namespaced one.
|
|
142
|
+
env_overrides: dict[str, Any] = {}
|
|
143
|
+
for env_key, attr in (("AGENT_HOST", "host"),
|
|
144
|
+
("AGENT_PORT", "port"),
|
|
145
|
+
("SEREN_AGENT_HOST", "host"),
|
|
146
|
+
("SEREN_AGENT_PORT", "port")):
|
|
147
|
+
v = os.getenv(env_key)
|
|
148
|
+
if v is not None:
|
|
149
|
+
env_overrides[attr] = v
|
|
150
|
+
if env_overrides:
|
|
151
|
+
_apply_server_overrides(cfg, env_overrides, source="environment")
|
|
152
|
+
|
|
153
|
+
return cfg
|