diff --git a/services/mcp-deribit/src/mcp_deribit/__main__.py b/services/mcp-deribit/src/mcp_deribit/__main__.py index bfd399d..a1450a2 100644 --- a/services/mcp-deribit/src/mcp_deribit/__main__.py +++ b/services/mcp-deribit/src/mcp_deribit/__main__.py @@ -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, diff --git a/services/mcp-deribit/src/mcp_deribit/env_validation.py b/services/mcp-deribit/src/mcp_deribit/env_validation.py new file mode 100644 index 0000000..723249d --- /dev/null +++ b/services/mcp-deribit/src/mcp_deribit/env_validation.py @@ -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]: ", n) + continue + if any(t in n.upper() for t in sensitive_tokens): + logger.info("env[%s]: ", 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) diff --git a/services/mcp-deribit/tests/test_env_validation.py b/services/mcp-deribit/tests/test_env_validation.py new file mode 100644 index 0000000..592e1bf --- /dev/null +++ b/services/mcp-deribit/tests/test_env_validation.py @@ -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 "" in log_text