refactor(mcp-deribit): localize env_validation as service-internal util
This commit is contained in:
@@ -5,7 +5,7 @@ import os
|
|||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from mcp_common.auth import load_token_store_from_files
|
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,
|
fail_fast_if_missing,
|
||||||
require_env,
|
require_env,
|
||||||
summarize,
|
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