refactor(mcp-deribit): localize env_validation as service-internal util
This commit is contained in:
@@ -5,7 +5,7 @@ import os
|
||||
|
||||
import uvicorn
|
||||
from mcp_common.auth import load_token_store_from_files
|
||||
from mcp_common.env_validation import (
|
||||
from mcp_deribit.env_validation import (
|
||||
fail_fast_if_missing,
|
||||
require_env,
|
||||
summarize,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""CER-P5-010: env validation policy — fail-fast per mandatory, soft per optional.
|
||||
|
||||
Usage al boot di ogni mcp `__main__.py`:
|
||||
|
||||
from option_mcp_common.env_validation import require_env, optional_env, summarize
|
||||
|
||||
creds_file = require_env("CREDENTIALS_FILE", "deribit credentials JSON path")
|
||||
host = optional_env("HOST", default="0.0.0.0")
|
||||
summarize(["CREDENTIALS_FILE", "HOST", "PORT"])
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MissingEnvError(RuntimeError):
|
||||
"""Mandatory env var absent or empty."""
|
||||
|
||||
|
||||
def require_env(name: str, description: str = "") -> str:
|
||||
"""Fail-fast: raise MissingEnvError se name non presente o vuoto.
|
||||
|
||||
Uscita dal processo con codice 2 se chiamato dal main(). Comporta
|
||||
logging chiaro del missing var prima dell'exit.
|
||||
"""
|
||||
val = (os.environ.get(name) or "").strip()
|
||||
if not val:
|
||||
msg = f"missing mandatory env var: {name}"
|
||||
if description:
|
||||
msg += f" ({description})"
|
||||
logger.error(msg)
|
||||
raise MissingEnvError(msg)
|
||||
return val
|
||||
|
||||
|
||||
def optional_env(name: str, *, default: str = "") -> str:
|
||||
"""Soft: ritorna env o default. Log INFO se default usato."""
|
||||
val = (os.environ.get(name) or "").strip()
|
||||
if not val:
|
||||
if default:
|
||||
logger.info("env %s not set, using default=%r", name, default)
|
||||
return default
|
||||
return val
|
||||
|
||||
|
||||
def summarize(names: list[str]) -> None:
|
||||
"""Log INFO di tutti gli env rilevanti con presenza (mask se SECRET/KEY/TOKEN)."""
|
||||
sensitive_tokens = ("SECRET", "KEY", "TOKEN", "PASSWORD", "CREDENTIAL", "WALLET")
|
||||
for n in names:
|
||||
val = os.environ.get(n)
|
||||
if val is None:
|
||||
logger.info("env[%s]: <unset>", n)
|
||||
continue
|
||||
if any(t in n.upper() for t in sensitive_tokens):
|
||||
logger.info("env[%s]: <set, %d chars>", n, len(val))
|
||||
else:
|
||||
logger.info("env[%s]: %s", n, val)
|
||||
|
||||
|
||||
def fail_fast_if_missing(names: list[str]) -> None:
|
||||
"""Verifica lista di nomi mandatory al boot. Exit 2 se uno solo manca.
|
||||
|
||||
Uso preferito: early call in main() per bloccare boot se config incompleta.
|
||||
"""
|
||||
missing: list[str] = []
|
||||
for n in names:
|
||||
if not (os.environ.get(n) or "").strip():
|
||||
missing.append(n)
|
||||
if missing:
|
||||
logger.error("boot aborted: missing mandatory env vars: %s", missing)
|
||||
print(
|
||||
f"FATAL: missing mandatory env vars: {missing}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""CER-P5-010 env validation tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from mcp_deribit.env_validation import (
|
||||
MissingEnvError,
|
||||
fail_fast_if_missing,
|
||||
optional_env,
|
||||
require_env,
|
||||
summarize,
|
||||
)
|
||||
|
||||
|
||||
def test_require_env_present(monkeypatch):
|
||||
monkeypatch.setenv("FOO_KEY", "value1")
|
||||
assert require_env("FOO_KEY") == "value1"
|
||||
|
||||
|
||||
def test_require_env_missing_raises(monkeypatch):
|
||||
monkeypatch.delenv("MISSING_REQ", raising=False)
|
||||
with pytest.raises(MissingEnvError):
|
||||
require_env("MISSING_REQ", "critical path")
|
||||
|
||||
|
||||
def test_require_env_empty_raises(monkeypatch):
|
||||
monkeypatch.setenv("EMPTY_REQ", "")
|
||||
with pytest.raises(MissingEnvError):
|
||||
require_env("EMPTY_REQ")
|
||||
|
||||
|
||||
def test_require_env_whitespace_only_raises(monkeypatch):
|
||||
monkeypatch.setenv("WS_REQ", " ")
|
||||
with pytest.raises(MissingEnvError):
|
||||
require_env("WS_REQ")
|
||||
|
||||
|
||||
def test_optional_env_default(monkeypatch):
|
||||
monkeypatch.delenv("OPT_A", raising=False)
|
||||
assert optional_env("OPT_A", default="fallback") == "fallback"
|
||||
|
||||
|
||||
def test_optional_env_set(monkeypatch):
|
||||
monkeypatch.setenv("OPT_B", "xx")
|
||||
assert optional_env("OPT_B", default="fallback") == "xx"
|
||||
|
||||
|
||||
def test_fail_fast_all_present(monkeypatch):
|
||||
monkeypatch.setenv("AA", "1")
|
||||
monkeypatch.setenv("BB", "2")
|
||||
fail_fast_if_missing(["AA", "BB"]) # no exit
|
||||
|
||||
|
||||
def test_fail_fast_missing_exits(monkeypatch):
|
||||
monkeypatch.setenv("HAVE_IT", "1")
|
||||
monkeypatch.delenv("MISSING_X", raising=False)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
fail_fast_if_missing(["HAVE_IT", "MISSING_X"])
|
||||
assert exc.value.code == 2
|
||||
|
||||
|
||||
def test_summarize_does_not_leak_secrets(monkeypatch, caplog):
|
||||
import logging
|
||||
monkeypatch.setenv("API_KEY_FOO", "super-secret-token-123456")
|
||||
monkeypatch.setenv("PORT", "9000")
|
||||
with caplog.at_level(logging.INFO, logger="mcp_deribit.env_validation"):
|
||||
summarize(["API_KEY_FOO", "PORT", "NOT_SET_XYZ"])
|
||||
log_text = "\n".join(caplog.messages)
|
||||
assert "super-secret-token-123456" not in log_text
|
||||
assert "9000" in log_text
|
||||
assert "<unset>" in log_text
|
||||
Reference in New Issue
Block a user