feat(safety+audit+deploy): consistency_check + audit log file sink + deploy script
#2 Env switch safety: - mcp_common/environment.py: nuova consistency_check() che previene switch accidentali a mainnet. Solleva EnvironmentMismatchError se resolved=mainnet senza creds["environment"]="mainnet" esplicito, o se declared/resolved mismatch. Override via STRICT_MAINNET=false. - Wirato in app_factory.run_exchange_main al boot. - 6 nuovi test consistency. #3 Audit log persistence: - mcp_common/audit.py: TimedRotatingFileHandler aggiuntivo se env AUDIT_LOG_FILE settato. Rotation midnight UTC, retention 30gg default (AUDIT_LOG_BACKUP_DAYS). Format JSONL con SecretsFilter. - docker-compose.prod.yml: bind mount /var/log/cerbero-mcp + env AUDIT_LOG_FILE per i 4 servizi exchange (write endpoints). - 2 nuovi test file sink. #1 Deploy script: - scripts/deploy.sh: idempotente, fa docker login + clone/pull repo + copia secrets chmod 600 + crea .env + setup audit dir + pull image + up + smoke test pubblico HTTPS. - DEPLOYMENT.md aggiornato: sezioni 2 (script), 3 (safety mainnet), 4 (audit log query), renumber sezioni successive. Test: 488/488 verdi. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+106
-7
@@ -62,7 +62,106 @@ Settings → Applications → Generate Token). Aggiungilo come secret a
|
|||||||
livello user (User Settings → Secrets → New Secret) con nome
|
livello user (User Settings → Secrets → New Secret) con nome
|
||||||
`REGISTRY_TOKEN`. Tutti i tuoi repo ereditano il secret automaticamente.
|
`REGISTRY_TOKEN`. Tutti i tuoi repo ereditano il secret automaticamente.
|
||||||
|
|
||||||
## 2. Setup iniziale del VPS
|
## 2. Deploy automatizzato (script)
|
||||||
|
|
||||||
|
Il modo più rapido è `scripts/deploy.sh`, idempotente. Esegui sul VPS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prerequisiti
|
||||||
|
export GITEA_PAT="<PAT con scope read:package>"
|
||||||
|
export GITEA_USER=adriano
|
||||||
|
mkdir -p ~/cerbero-secrets
|
||||||
|
# Copia (via scp dal posto sicuro) i secret in ~/cerbero-secrets/:
|
||||||
|
# deribit.json bybit.json hyperliquid.json alpaca.json
|
||||||
|
# macro.json sentiment.json core.token observer.token
|
||||||
|
|
||||||
|
# Clone temporaneo solo per lo script
|
||||||
|
curl -sL -o /tmp/deploy.sh \
|
||||||
|
https://git.tielogic.xyz/Adriano/Cerbero-mcp/raw/branch/main/scripts/deploy.sh
|
||||||
|
chmod +x /tmp/deploy.sh
|
||||||
|
|
||||||
|
# Run (richiede sudo per /opt/cerbero-mcp e /var/log/cerbero-mcp)
|
||||||
|
/tmp/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Lo script esegue: docker login registry → clone/pull repo in
|
||||||
|
`/opt/cerbero-mcp` → copia secrets con `chmod 600` → genera `.env`
|
||||||
|
iniziale (testnet) → crea `/var/log/cerbero-mcp` con permessi `1000:1000`
|
||||||
|
→ pull image dal registry → `docker compose up -d` → smoke test pubblico.
|
||||||
|
|
||||||
|
Per aggiornare in seguito: ri-esegui lo stesso script (preserva `.env`).
|
||||||
|
|
||||||
|
## 3. Safety: switch testnet → mainnet
|
||||||
|
|
||||||
|
`mcp_common.environment.consistency_check` (richiamato dal boot
|
||||||
|
`run_exchange_main`) PREVIENE switch accidentali:
|
||||||
|
|
||||||
|
- Se l'ambiente risolto è **mainnet** ma il secret JSON corrispondente
|
||||||
|
non contiene `"environment": "mainnet"` esplicito → boot abort con
|
||||||
|
`EnvironmentMismatchError`.
|
||||||
|
- Se il secret dichiara un environment diverso da quello risolto (es.
|
||||||
|
`creds["environment"]="mainnet"` ma env var setta testnet) → boot abort.
|
||||||
|
|
||||||
|
**Per passare a mainnet su un exchange specifico** (es. bybit):
|
||||||
|
|
||||||
|
1. Edita `secrets/bybit.json`: aggiungi `"environment": "mainnet"`.
|
||||||
|
2. Modifica `.env`: `BYBIT_TESTNET=false`.
|
||||||
|
3. `docker compose -f docker-compose.prod.yml --env-file .env restart mcp-bybit`.
|
||||||
|
|
||||||
|
Senza il flag esplicito nel secret, il container mcp-bybit fallirà al
|
||||||
|
boot e Watchtower NON aggiornerà su versioni con cred mainnet rotti.
|
||||||
|
|
||||||
|
Override `STRICT_MAINNET=false` in `.env` permette mainnet senza la
|
||||||
|
conferma esplicita (downgrade safety, sconsigliato in produzione).
|
||||||
|
|
||||||
|
## 4. Audit log persistente
|
||||||
|
|
||||||
|
Tutti i write endpoint (`place_order`, `place_combo_order`, `cancel_*`,
|
||||||
|
`set_*`, `close_*`, `transfer_*`, `amend_*`, `switch_*`) emettono un
|
||||||
|
record JSON strutturato sul logger `mcp.audit`.
|
||||||
|
|
||||||
|
**Sink**:
|
||||||
|
- stdout/stderr container (sempre, visibile via `docker logs`).
|
||||||
|
- File JSONL persistente su volume host:
|
||||||
|
`${AUDIT_LOG_DIR:-/var/log/cerbero-mcp}/<service>.audit.jsonl`.
|
||||||
|
Rotation a mezzanotte UTC con retention `AUDIT_LOG_BACKUP_DAYS`
|
||||||
|
(default 30 giorni).
|
||||||
|
|
||||||
|
**Esempio record**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"audit_event": "write_op",
|
||||||
|
"action": "place_order",
|
||||||
|
"exchange": "bybit",
|
||||||
|
"principal": "core",
|
||||||
|
"target": "BTCUSDT",
|
||||||
|
"payload": {"side": "Buy", "qty": 0.01, "price": 60000, "leverage": 3},
|
||||||
|
"result": {"order_id": "abc123", "status": "submitted"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query operative**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tutto l'audit log oggi
|
||||||
|
tail -f /var/log/cerbero-mcp/*.audit.jsonl
|
||||||
|
|
||||||
|
# Solo place_order su bybit
|
||||||
|
jq -c 'select(.action=="place_order" and .exchange=="bybit")' \
|
||||||
|
/var/log/cerbero-mcp/bybit.audit.jsonl
|
||||||
|
|
||||||
|
# Errori
|
||||||
|
jq -c 'select(.error)' /var/log/cerbero-mcp/*.audit.jsonl
|
||||||
|
|
||||||
|
# Operazioni di un principal
|
||||||
|
jq -c 'select(.principal=="core")' /var/log/cerbero-mcp/*.audit.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
I secret (api_key, password) sono filtrati automaticamente da
|
||||||
|
`SecretsFilter` prima di arrivare al sink.
|
||||||
|
|
||||||
|
## 5. Setup iniziale del VPS (manuale, alternativa allo script)
|
||||||
|
|
||||||
**Pre-requisiti**: Docker Engine ≥ 24, `docker compose` plugin, accesso SSH
|
**Pre-requisiti**: Docker Engine ≥ 24, `docker compose` plugin, accesso SSH
|
||||||
sudo, dominio DNS A record `cerbero-mcp.tielogic.xyz` → IP del VPS, porte 80
|
sudo, dominio DNS A record `cerbero-mcp.tielogic.xyz` → IP del VPS, porte 80
|
||||||
@@ -138,7 +237,7 @@ docker compose -f docker-compose.prod.yml logs -f gateway
|
|||||||
Caddy chiede automaticamente il certificato Let's Encrypt al primo
|
Caddy chiede automaticamente il certificato Let's Encrypt al primo
|
||||||
contatto su `https://cerbero-mcp.tielogic.xyz`.
|
contatto su `https://cerbero-mcp.tielogic.xyz`.
|
||||||
|
|
||||||
## 3. Auto-update via Watchtower
|
## 6. Auto-update via Watchtower
|
||||||
|
|
||||||
Watchtower (servizio `watchtower` nel compose) polla il registry ogni
|
Watchtower (servizio `watchtower` nel compose) polla il registry ogni
|
||||||
`WATCHTOWER_POLL_INTERVAL` secondi. Se trova un nuovo digest dietro al tag
|
`WATCHTOWER_POLL_INTERVAL` secondi. Se trova un nuovo digest dietro al tag
|
||||||
@@ -172,7 +271,7 @@ Rimuovi la label `com.centurylinklabs.watchtower.enable=true` per quel
|
|||||||
servizio nel compose (oppure imposta `=false`). Watchtower lo ignora ma
|
servizio nel compose (oppure imposta `=false`). Watchtower lo ignora ma
|
||||||
continua a tenere aggiornati gli altri.
|
continua a tenere aggiornati gli altri.
|
||||||
|
|
||||||
## 4. Rollback
|
## 7. Rollback
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Trova lo SHA della versione precedente
|
# Trova lo SHA della versione precedente
|
||||||
@@ -187,7 +286,7 @@ docker compose -f docker-compose.prod.yml --env-file .env up -d
|
|||||||
Watchtower NON downgraderà perché il digest del tag pin corrisponde a quello
|
Watchtower NON downgraderà perché il digest del tag pin corrisponde a quello
|
||||||
locale.
|
locale.
|
||||||
|
|
||||||
## 5. Smoke test post-deploy
|
## 8. Smoke test post-deploy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Da fuori VPS (laptop)
|
# Da fuori VPS (laptop)
|
||||||
@@ -204,7 +303,7 @@ curl -X POST https://cerbero-mcp.tielogic.xyz/mcp-deribit/tools/place_order \
|
|||||||
GATEWAY=http://localhost bash tests/smoke/run.sh
|
GATEWAY=http://localhost bash tests/smoke/run.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## 6. Sicurezza VPS
|
## 9. Sicurezza VPS
|
||||||
|
|
||||||
- Firewall `ufw`: `allow 22, 80, 443`. Tutto il resto deny in.
|
- Firewall `ufw`: `allow 22, 80, 443`. Tutto il resto deny in.
|
||||||
- `fail2ban` su SSH e (opz) sul log Caddy 401.
|
- `fail2ban` su SSH e (opz) sul log Caddy 401.
|
||||||
@@ -214,7 +313,7 @@ GATEWAY=http://localhost bash tests/smoke/run.sh
|
|||||||
- Audit log in `docker compose logs <service> | grep audit_event` — per
|
- Audit log in `docker compose logs <service> | grep audit_event` — per
|
||||||
produzione meglio redirezionare a syslog o a un servizio dedicato.
|
produzione meglio redirezionare a syslog o a un servizio dedicato.
|
||||||
|
|
||||||
## 7. Note Traefik / reverse proxy davanti a Gitea
|
## 10. Note Traefik / reverse proxy davanti a Gitea
|
||||||
|
|
||||||
Gitea è esposto via Traefik (ROOT_URL `https://git.tielogic.xyz`). Per il push
|
Gitea è esposto via Traefik (ROOT_URL `https://git.tielogic.xyz`). Per il push
|
||||||
di image Docker il reverse proxy deve consentire upload di body grossi (un
|
di image Docker il reverse proxy deve consentire upload di body grossi (un
|
||||||
@@ -234,7 +333,7 @@ http:
|
|||||||
|
|
||||||
Applicalo come middleware al router Gitea.
|
Applicalo come middleware al router Gitea.
|
||||||
|
|
||||||
## 8. Aggiornamento del compose stesso (file YAML)
|
## 11. Aggiornamento del compose stesso (file YAML)
|
||||||
|
|
||||||
Watchtower aggiorna le **image**, non il `docker-compose.prod.yml`. Se cambi
|
Watchtower aggiorna le **image**, non il `docker-compose.prod.yml`. Se cambi
|
||||||
struttura (nuovi servizi, nuove env var) devi:
|
struttura (nuovi servizi, nuove env var) devi:
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ x-common-security: &common-security
|
|||||||
networks: [internal]
|
networks: [internal]
|
||||||
labels:
|
labels:
|
||||||
com.centurylinklabs.watchtower.enable: "true"
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
|
volumes:
|
||||||
|
- ${AUDIT_LOG_DIR:-/var/log/cerbero-mcp}:/var/log/cerbero-mcp:rw
|
||||||
|
|
||||||
x-image-prefix: &image_prefix git.tielogic.xyz/adriano/cerbero-mcp
|
x-image-prefix: &image_prefix git.tielogic.xyz/adriano/cerbero-mcp
|
||||||
|
|
||||||
@@ -103,6 +105,7 @@ services:
|
|||||||
OBSERVER_TOKEN_FILE: /run/secrets/observer_token
|
OBSERVER_TOKEN_FILE: /run/secrets/observer_token
|
||||||
DERIBIT_TESTNET: "${DERIBIT_TESTNET:-true}"
|
DERIBIT_TESTNET: "${DERIBIT_TESTNET:-true}"
|
||||||
ROOT_PATH: /mcp-deribit
|
ROOT_PATH: /mcp-deribit
|
||||||
|
AUDIT_LOG_FILE: /var/log/cerbero-mcp/deribit.audit.jsonl
|
||||||
|
|
||||||
mcp-hyperliquid:
|
mcp-hyperliquid:
|
||||||
image: ${IMAGE_PREFIX:-git.tielogic.xyz/adriano/cerbero-mcp}/mcp-hyperliquid:${IMAGE_TAG:-latest}
|
image: ${IMAGE_PREFIX:-git.tielogic.xyz/adriano/cerbero-mcp}/mcp-hyperliquid:${IMAGE_TAG:-latest}
|
||||||
@@ -118,6 +121,7 @@ services:
|
|||||||
OBSERVER_TOKEN_FILE: /run/secrets/observer_token
|
OBSERVER_TOKEN_FILE: /run/secrets/observer_token
|
||||||
HYPERLIQUID_TESTNET: "${HYPERLIQUID_TESTNET:-true}"
|
HYPERLIQUID_TESTNET: "${HYPERLIQUID_TESTNET:-true}"
|
||||||
ROOT_PATH: /mcp-hyperliquid
|
ROOT_PATH: /mcp-hyperliquid
|
||||||
|
AUDIT_LOG_FILE: /var/log/cerbero-mcp/hyperliquid.audit.jsonl
|
||||||
|
|
||||||
mcp-bybit:
|
mcp-bybit:
|
||||||
image: ${IMAGE_PREFIX:-git.tielogic.xyz/adriano/cerbero-mcp}/mcp-bybit:${IMAGE_TAG:-latest}
|
image: ${IMAGE_PREFIX:-git.tielogic.xyz/adriano/cerbero-mcp}/mcp-bybit:${IMAGE_TAG:-latest}
|
||||||
@@ -133,6 +137,7 @@ services:
|
|||||||
OBSERVER_TOKEN_FILE: /run/secrets/observer_token
|
OBSERVER_TOKEN_FILE: /run/secrets/observer_token
|
||||||
BYBIT_TESTNET: "${BYBIT_TESTNET:-true}"
|
BYBIT_TESTNET: "${BYBIT_TESTNET:-true}"
|
||||||
ROOT_PATH: /mcp-bybit
|
ROOT_PATH: /mcp-bybit
|
||||||
|
AUDIT_LOG_FILE: /var/log/cerbero-mcp/bybit.audit.jsonl
|
||||||
PORT: "9019"
|
PORT: "9019"
|
||||||
|
|
||||||
mcp-alpaca:
|
mcp-alpaca:
|
||||||
@@ -149,6 +154,7 @@ services:
|
|||||||
OBSERVER_TOKEN_FILE: /run/secrets/observer_token
|
OBSERVER_TOKEN_FILE: /run/secrets/observer_token
|
||||||
ALPACA_PAPER: "${ALPACA_PAPER:-true}"
|
ALPACA_PAPER: "${ALPACA_PAPER:-true}"
|
||||||
ROOT_PATH: /mcp-alpaca
|
ROOT_PATH: /mcp-alpaca
|
||||||
|
AUDIT_LOG_FILE: /var/log/cerbero-mcp/alpaca.audit.jsonl
|
||||||
PORT: "9020"
|
PORT: "9020"
|
||||||
|
|
||||||
mcp-macro:
|
mcp-macro:
|
||||||
|
|||||||
Executable
+165
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Cerbero_mcp — deploy script per VPS produzione.
|
||||||
|
#
|
||||||
|
# Pre-requisiti sul VPS (NON gestiti da questo script):
|
||||||
|
# 1. Docker Engine ≥ 24 + plugin docker compose installati.
|
||||||
|
# 2. DNS A record `cerbero-mcp.tielogic.xyz` → IP del VPS.
|
||||||
|
# 3. Porte 80 e 443 aperte sul firewall (per ACME + traffico HTTPS).
|
||||||
|
# 4. PAT Gitea con scope `read:package`, salvato in env `$GITEA_PAT`.
|
||||||
|
# 5. Username Gitea in env `$GITEA_USER` (default: adriano).
|
||||||
|
# 6. Secret JSON exchange + token bearer disponibili in $SECRETS_SRC
|
||||||
|
# (default: ~/cerbero-secrets/), che lo script copierà in
|
||||||
|
# $DEPLOY_DIR/secrets/ con permessi 600.
|
||||||
|
#
|
||||||
|
# Idempotente: rieseguibile per aggiornamenti.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEPLOY_DIR="${DEPLOY_DIR:-/opt/cerbero-mcp}"
|
||||||
|
SECRETS_SRC="${SECRETS_SRC:-$HOME/cerbero-secrets}"
|
||||||
|
GITEA_USER="${GITEA_USER:-adriano}"
|
||||||
|
GITEA_REPO_URL="${GITEA_REPO_URL:-ssh://git@git.tielogic.xyz:222/Adriano/Cerbero-mcp.git}"
|
||||||
|
REGISTRY="${REGISTRY:-git.tielogic.xyz}"
|
||||||
|
DOMAIN="${DOMAIN:-cerbero-mcp.tielogic.xyz}"
|
||||||
|
AUDIT_LOG_DIR="${AUDIT_LOG_DIR:-/var/log/cerbero-mcp}"
|
||||||
|
|
||||||
|
echo "=== Cerbero_mcp deploy → $DEPLOY_DIR (domain $DOMAIN) ==="
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 1. Verifica pre-requisiti
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
command -v docker >/dev/null || { echo "FATAL: docker non installato"; exit 1; }
|
||||||
|
docker compose version >/dev/null || { echo "FATAL: docker compose plugin assente"; exit 1; }
|
||||||
|
|
||||||
|
if [ -z "${GITEA_PAT:-}" ]; then
|
||||||
|
echo "FATAL: env GITEA_PAT non settata. Export del PAT con scope read:package prima."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$SECRETS_SRC" ]; then
|
||||||
|
echo "FATAL: secrets src dir $SECRETS_SRC non esiste."
|
||||||
|
echo " Atteso contenere: deribit.json bybit.json hyperliquid.json alpaca.json"
|
||||||
|
echo " macro.json sentiment.json core.token observer.token"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check DNS resolution (warning only, non blocca)
|
||||||
|
ip_resolved=$(getent hosts "$DOMAIN" | awk '{print $1}' | head -1 || true)
|
||||||
|
if [ -z "$ip_resolved" ]; then
|
||||||
|
echo "WARN: $DOMAIN non risolve via DNS — TLS Let's Encrypt fallirà finché DNS non propaga."
|
||||||
|
else
|
||||||
|
echo "DNS $DOMAIN → $ip_resolved"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 2. Login al container registry
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
echo "=== docker login $REGISTRY ==="
|
||||||
|
echo "$GITEA_PAT" | docker login "$REGISTRY" -u "$GITEA_USER" --password-stdin
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 3. Setup dir + clone/pull repo
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
sudo mkdir -p "$DEPLOY_DIR"
|
||||||
|
sudo chown "$USER:$USER" "$DEPLOY_DIR"
|
||||||
|
|
||||||
|
if [ -d "$DEPLOY_DIR/.git" ]; then
|
||||||
|
echo "=== Aggiornamento repo $DEPLOY_DIR ==="
|
||||||
|
git -C "$DEPLOY_DIR" pull --ff-only
|
||||||
|
else
|
||||||
|
echo "=== Clone repo $GITEA_REPO_URL → $DEPLOY_DIR ==="
|
||||||
|
git clone "$GITEA_REPO_URL" "$DEPLOY_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 4. Copia secrets con permessi 600
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
mkdir -p secrets
|
||||||
|
echo "=== Copia secrets da $SECRETS_SRC ==="
|
||||||
|
for f in deribit.json bybit.json hyperliquid.json alpaca.json macro.json sentiment.json core.token observer.token; do
|
||||||
|
if [ -f "$SECRETS_SRC/$f" ]; then
|
||||||
|
cp "$SECRETS_SRC/$f" "secrets/$f"
|
||||||
|
chmod 600 "secrets/$f"
|
||||||
|
echo " ok: secrets/$f"
|
||||||
|
else
|
||||||
|
echo " WARN: $SECRETS_SRC/$f assente — il servizio relativo fallirà al boot."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 5. Crea/aggiorna .env (preserva esistente)
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "=== Creazione .env iniziale (testnet di default) ==="
|
||||||
|
cat > .env <<EOF
|
||||||
|
# Cerbero_mcp deploy config — modifica per passare a mainnet
|
||||||
|
ACME_EMAIL=adrianodalpastro@tielogic.com
|
||||||
|
GATEWAY_HTTP_PORT=80
|
||||||
|
GATEWAY_HTTPS_PORT=443
|
||||||
|
WRITE_ALLOWLIST="127.0.0.1/32 ::1/128 172.16.0.0/12"
|
||||||
|
|
||||||
|
IMAGE_TAG=latest
|
||||||
|
IMAGE_PREFIX=git.tielogic.xyz/adriano/cerbero-mcp
|
||||||
|
|
||||||
|
# Environment exchange (true=testnet, false=mainnet).
|
||||||
|
# IMPORTANTE: per mainnet aggiungi anche "environment":"mainnet" al secret JSON
|
||||||
|
# corrispondente, altrimenti il boot abortisce per safety (vedi consistency_check).
|
||||||
|
DERIBIT_TESTNET=true
|
||||||
|
BYBIT_TESTNET=true
|
||||||
|
HYPERLIQUID_TESTNET=true
|
||||||
|
ALPACA_PAPER=true
|
||||||
|
|
||||||
|
# Permette mainnet senza creds["environment"]="mainnet" esplicito (sconsigliato).
|
||||||
|
STRICT_MAINNET=true
|
||||||
|
|
||||||
|
# Audit log persistente per write endpoint (place_order, cancel, ecc.).
|
||||||
|
AUDIT_LOG_DIR=$AUDIT_LOG_DIR
|
||||||
|
|
||||||
|
# Watchtower polling auto-update (sec).
|
||||||
|
WATCHTOWER_POLL_INTERVAL=300
|
||||||
|
EOF
|
||||||
|
echo " $DEPLOY_DIR/.env creato. Rivedi prima del primo up."
|
||||||
|
else
|
||||||
|
echo "=== .env preesistente — non sovrascritto ==="
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 6. Audit log dir host (volume bind)
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
sudo mkdir -p "$AUDIT_LOG_DIR"
|
||||||
|
sudo chown 1000:1000 "$AUDIT_LOG_DIR" # uid del container app
|
||||||
|
echo "Audit log dir: $AUDIT_LOG_DIR (chown 1000:1000)"
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 7. Pull image + up
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
echo "=== docker compose pull + up ==="
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env pull
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env up -d
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 8. Verifica stato
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
sleep 5
|
||||||
|
echo "=== Stato container ==="
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env ps
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Smoke test (health check via gateway pubblico) ==="
|
||||||
|
sleep 10 # tempo per Caddy di richiedere cert
|
||||||
|
if curl -sf -o /dev/null -m 10 "https://$DOMAIN/mcp-macro/health"; then
|
||||||
|
echo " ✅ https://$DOMAIN/mcp-macro/health → 200"
|
||||||
|
else
|
||||||
|
echo " ⚠️ https://$DOMAIN/mcp-macro/health non risponde (DNS o cert non ancora pronti?)"
|
||||||
|
echo " Riprova fra 30s o controlla: docker compose -f docker-compose.prod.yml logs gateway"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Deploy completato ==="
|
||||||
|
echo "Comandi utili:"
|
||||||
|
echo " Logs: docker compose -f docker-compose.prod.yml --env-file .env logs -f <service>"
|
||||||
|
echo " Audit: tail -f $AUDIT_LOG_DIR/*.audit.jsonl"
|
||||||
|
echo " Restart: docker compose -f docker-compose.prod.yml --env-file .env restart <service>"
|
||||||
|
echo " Stop: docker compose -f docker-compose.prod.yml --env-file .env down"
|
||||||
@@ -24,7 +24,11 @@ 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 fail_fast_if_missing, require_env, summarize
|
from mcp_common.env_validation import fail_fast_if_missing, require_env, summarize
|
||||||
from mcp_common.environment import EnvironmentInfo, resolve_environment
|
from mcp_common.environment import (
|
||||||
|
EnvironmentInfo,
|
||||||
|
consistency_check,
|
||||||
|
resolve_environment,
|
||||||
|
)
|
||||||
from mcp_common.logging import configure_root_logging
|
from mcp_common.logging import configure_root_logging
|
||||||
|
|
||||||
|
|
||||||
@@ -69,6 +73,11 @@ def run_exchange_main(spec: ExchangeAppSpec) -> None:
|
|||||||
default_base_url_testnet=spec.default_base_url_testnet,
|
default_base_url_testnet=spec.default_base_url_testnet,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Safety: previene switch accidentali a mainnet senza conferma esplicita
|
||||||
|
# nel secret. Solleva EnvironmentMismatchError → boot abort se mismatch.
|
||||||
|
strict_mainnet = os.environ.get("STRICT_MAINNET", "true").lower() not in ("0", "false", "no")
|
||||||
|
consistency_check(env_info, creds, strict_mainnet=strict_mainnet)
|
||||||
|
|
||||||
client = spec.build_client(creds, env_info)
|
client = spec.build_client(creds, env_info)
|
||||||
|
|
||||||
token_store = load_token_store_from_files(
|
token_store = load_token_store_from_files(
|
||||||
|
|||||||
@@ -1,22 +1,68 @@
|
|||||||
"""Audit log strutturato per write endpoint MCP (place_order, cancel,
|
"""Audit log strutturato per write endpoint MCP (place_order, cancel,
|
||||||
set_*, close_*, transfer_*). Usa un logger dedicato `mcp.audit` su stream
|
set_*, close_*, transfer_*). Usa un logger dedicato `mcp.audit` su stream
|
||||||
JSON: in deployment può essere redirezionato a file/syslog/SIEM separato.
|
JSON.
|
||||||
|
|
||||||
Logica:
|
Sink:
|
||||||
- `audit_write_op(principal, action, exchange, target, payload, result)`
|
- stdout/stderr (sempre): tramite root JSON logger configurato da
|
||||||
emette UN record JSON per ogni operazione con esito (ok/error).
|
`mcp_common.logging.configure_root_logging`.
|
||||||
- Payload sensibile (api_key, secret) già filtrato dal SecretsFilter
|
- File JSONL persistente (opzionale): se env var `AUDIT_LOG_FILE` è
|
||||||
|
settata, aggiunge un `TimedRotatingFileHandler` che ruota a mezzanotte
|
||||||
|
con `AUDIT_LOG_BACKUP_DAYS` di retention (default 30). Una riga JSON
|
||||||
|
per record (formato `.jsonl`).
|
||||||
|
|
||||||
|
Per VPS produzione: setta `AUDIT_LOG_FILE=/var/log/cerbero-mcp/<service>.audit.jsonl`
|
||||||
|
con bind mount del volume `/var/log/cerbero-mcp` nel docker-compose.
|
||||||
|
|
||||||
|
Payload sensibile (api_key, secret) già filtrato dal SecretsFilter
|
||||||
globale; qui non si include creds.
|
globale; qui non si include creds.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mcp_common.auth import Principal
|
from mcp_common.auth import Principal
|
||||||
from mcp_common.logging import get_json_logger
|
from mcp_common.logging import SecretsFilter, get_json_logger
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pythonjsonlogger.json import JsonFormatter as _JsonFormatter # noqa: N813
|
||||||
|
except ImportError:
|
||||||
|
from pythonjsonlogger.jsonlogger import JsonFormatter as _JsonFormatter # noqa: N813
|
||||||
|
|
||||||
_logger = get_json_logger("mcp.audit", level=logging.INFO)
|
_logger = get_json_logger("mcp.audit", level=logging.INFO)
|
||||||
|
_file_handler_attached = False
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_audit_sink() -> None:
|
||||||
|
"""Aggiunge FileHandler al logger mcp.audit se AUDIT_LOG_FILE è settato.
|
||||||
|
Idempotente: chiamato la prima volta da audit_write_op, poi no-op.
|
||||||
|
"""
|
||||||
|
global _file_handler_attached
|
||||||
|
if _file_handler_attached:
|
||||||
|
return
|
||||||
|
|
||||||
|
file_path = os.environ.get("AUDIT_LOG_FILE", "").strip()
|
||||||
|
if not file_path:
|
||||||
|
_file_handler_attached = True
|
||||||
|
return
|
||||||
|
|
||||||
|
backup_days = int(os.environ.get("AUDIT_LOG_BACKUP_DAYS", "30"))
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
|
||||||
|
handler = TimedRotatingFileHandler(
|
||||||
|
file_path,
|
||||||
|
when="midnight",
|
||||||
|
interval=1,
|
||||||
|
backupCount=backup_days,
|
||||||
|
encoding="utf-8",
|
||||||
|
utc=True,
|
||||||
|
)
|
||||||
|
handler.setFormatter(_JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s"))
|
||||||
|
handler.addFilter(SecretsFilter())
|
||||||
|
_logger.addHandler(handler)
|
||||||
|
_file_handler_attached = True
|
||||||
|
|
||||||
|
|
||||||
def audit_write_op(
|
def audit_write_op(
|
||||||
@@ -40,6 +86,7 @@ def audit_write_op(
|
|||||||
result: output del client (order_id, status, ecc.).
|
result: output del client (order_id, status, ecc.).
|
||||||
error: stringa errore se l'operazione ha fallito.
|
error: stringa errore se l'operazione ha fallito.
|
||||||
"""
|
"""
|
||||||
|
_configure_audit_sink()
|
||||||
record: dict[str, Any] = {
|
record: dict[str, Any] = {
|
||||||
"audit_event": "write_op",
|
"audit_event": "write_op",
|
||||||
"action": action,
|
"action": action,
|
||||||
|
|||||||
@@ -1,18 +1,31 @@
|
|||||||
"""Resolver di ambiente (testnet/mainnet) per MCP exchange.
|
"""Resolver di ambiente (testnet/mainnet) per MCP exchange.
|
||||||
|
|
||||||
Precedenza: env var > campo secret > default True (testnet).
|
Precedenza: env var > campo secret > default True (testnet).
|
||||||
|
|
||||||
|
Safety: `consistency_check` previene switch accidentali a mainnet senza
|
||||||
|
conferma esplicita nel secret JSON.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
Environment = Literal["testnet", "mainnet"]
|
Environment = Literal["testnet", "mainnet"]
|
||||||
Source = Literal["env", "credentials", "default"]
|
Source = Literal["env", "credentials", "default"]
|
||||||
|
|
||||||
TRUTHY = {"1", "true", "yes", "on"}
|
TRUTHY = {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
# Tokens nel base_url che indicano endpoint testnet (case-insensitive).
|
||||||
|
TESTNET_URL_HINTS = ("test", "testnet", "paper")
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentMismatchError(RuntimeError):
|
||||||
|
"""Boot abort: ambiente risolto non matcha conferma esplicita nel secret."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class EnvironmentInfo:
|
class EnvironmentInfo:
|
||||||
@@ -67,3 +80,59 @@ def resolve_environment(
|
|||||||
env_value=env_value,
|
env_value=env_value,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def consistency_check(
|
||||||
|
env_info: EnvironmentInfo,
|
||||||
|
creds: dict,
|
||||||
|
*,
|
||||||
|
strict_mainnet: bool = True,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Verifica coerenza environment risolto vs secret JSON. Restituisce
|
||||||
|
lista di warning string. Solleva EnvironmentMismatchError per mismatch
|
||||||
|
bloccanti.
|
||||||
|
|
||||||
|
Regole:
|
||||||
|
- Se `creds["environment"]` è presente e DIVERSO da `env_info.environment`:
|
||||||
|
→ raise EnvironmentMismatchError (declared vs resolved mismatch).
|
||||||
|
- Se `env_info.environment == "mainnet"` e `creds.get("environment") !=
|
||||||
|
"mainnet"`: con `strict_mainnet=True` → raise (richiede conferma
|
||||||
|
esplicita). Con `strict_mainnet=False` → warning.
|
||||||
|
- Se `env_info.base_url` contiene token testnet ("test", "testnet",
|
||||||
|
"paper") ma `env_info.environment == "mainnet"` (o viceversa): warning
|
||||||
|
(URL/environment incoerenti).
|
||||||
|
"""
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
declared = creds.get("environment")
|
||||||
|
if declared and declared != env_info.environment:
|
||||||
|
raise EnvironmentMismatchError(
|
||||||
|
f"{env_info.exchange}: secret declared environment={declared!r} "
|
||||||
|
f"but resolver resolved environment={env_info.environment!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if env_info.environment == "mainnet" and declared != "mainnet":
|
||||||
|
msg = (
|
||||||
|
f"{env_info.exchange}: resolved mainnet without explicit confirmation "
|
||||||
|
"in secret. Add `\"environment\": \"mainnet\"` to the credentials JSON."
|
||||||
|
)
|
||||||
|
if strict_mainnet:
|
||||||
|
raise EnvironmentMismatchError(msg)
|
||||||
|
warnings.append(msg)
|
||||||
|
|
||||||
|
url_lower = (env_info.base_url or "").lower()
|
||||||
|
has_test_hint = any(token in url_lower for token in TESTNET_URL_HINTS)
|
||||||
|
if env_info.environment == "mainnet" and has_test_hint:
|
||||||
|
warnings.append(
|
||||||
|
f"{env_info.exchange}: environment=mainnet but base_url contains "
|
||||||
|
f"testnet hint ({env_info.base_url!r})"
|
||||||
|
)
|
||||||
|
if env_info.environment == "testnet" and not has_test_hint and url_lower:
|
||||||
|
warnings.append(
|
||||||
|
f"{env_info.exchange}: environment=testnet but base_url does not "
|
||||||
|
f"appear to be a testnet endpoint ({env_info.base_url!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
for w in warnings:
|
||||||
|
logger.warning("environment consistency: %s", w)
|
||||||
|
return warnings
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from mcp_common.app_factory import ExchangeAppSpec, run_exchange_main
|
from mcp_common.app_factory import ExchangeAppSpec, run_exchange_main
|
||||||
from mcp_common.environment import EnvironmentInfo
|
from mcp_common.environment import EnvironmentInfo
|
||||||
|
|
||||||
@@ -75,7 +77,9 @@ def test_run_exchange_main_uses_default_port(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
def test_run_exchange_main_env_var_overrides_creds(tmp_path, monkeypatch):
|
def test_run_exchange_main_env_var_overrides_creds(tmp_path, monkeypatch):
|
||||||
creds_file = tmp_path / "creds.json"
|
creds_file = tmp_path / "creds.json"
|
||||||
creds_file.write_text(json.dumps({"testnet": True}))
|
# `environment: mainnet` esplicito perché env var override → mainnet
|
||||||
|
# e consistency_check richiede conferma per evitare switch accidentale.
|
||||||
|
creds_file.write_text(json.dumps({"testnet": True, "environment": "mainnet"}))
|
||||||
monkeypatch.setenv("TESTEX_CREDENTIALS_FILE", str(creds_file))
|
monkeypatch.setenv("TESTEX_CREDENTIALS_FILE", str(creds_file))
|
||||||
monkeypatch.setenv("TESTEX_TESTNET", "false")
|
monkeypatch.setenv("TESTEX_TESTNET", "false")
|
||||||
|
|
||||||
@@ -95,6 +99,33 @@ def test_run_exchange_main_env_var_overrides_creds(tmp_path, monkeypatch):
|
|||||||
assert captured["env_info"].source == "env"
|
assert captured["env_info"].source == "env"
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_exchange_main_aborts_on_mainnet_without_confirmation(tmp_path, monkeypatch):
|
||||||
|
"""Mainnet senza creds['environment']='mainnet' → boot abort fail-fast."""
|
||||||
|
from mcp_common.environment import EnvironmentMismatchError
|
||||||
|
creds_file = tmp_path / "creds.json"
|
||||||
|
creds_file.write_text(json.dumps({"testnet": False}))
|
||||||
|
monkeypatch.setenv("TESTEX_CREDENTIALS_FILE", str(creds_file))
|
||||||
|
monkeypatch.delenv("TESTEX_TESTNET", raising=False)
|
||||||
|
monkeypatch.delenv("STRICT_MAINNET", raising=False)
|
||||||
|
|
||||||
|
spec = _make_spec()
|
||||||
|
with pytest.raises(EnvironmentMismatchError):
|
||||||
|
with patch("mcp_common.app_factory.uvicorn.run"):
|
||||||
|
run_exchange_main(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_exchange_main_strict_mainnet_disabled_via_env(tmp_path, monkeypatch):
|
||||||
|
"""STRICT_MAINNET=false permette mainnet senza conferma (warning soltanto)."""
|
||||||
|
creds_file = tmp_path / "creds.json"
|
||||||
|
creds_file.write_text(json.dumps({"testnet": False}))
|
||||||
|
monkeypatch.setenv("TESTEX_CREDENTIALS_FILE", str(creds_file))
|
||||||
|
monkeypatch.setenv("STRICT_MAINNET", "false")
|
||||||
|
|
||||||
|
spec = _make_spec()
|
||||||
|
with patch("mcp_common.app_factory.uvicorn.run"):
|
||||||
|
run_exchange_main(spec) # non solleva
|
||||||
|
|
||||||
|
|
||||||
def test_run_exchange_main_missing_creds_file_exits(monkeypatch):
|
def test_run_exchange_main_missing_creds_file_exits(monkeypatch):
|
||||||
monkeypatch.delenv("TESTEX_CREDENTIALS_FILE", raising=False)
|
monkeypatch.delenv("TESTEX_CREDENTIALS_FILE", raising=False)
|
||||||
|
|
||||||
|
|||||||
@@ -95,3 +95,60 @@ def test_audit_write_op_no_principal(captured_records):
|
|||||||
)
|
)
|
||||||
rec = captured_records[0]
|
rec = captured_records[0]
|
||||||
assert rec.principal is None
|
assert rec.principal is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_write_op_writes_to_file_when_AUDIT_LOG_FILE_set(tmp_path, monkeypatch):
|
||||||
|
"""Con env AUDIT_LOG_FILE settato, una riga JSON appare nel file."""
|
||||||
|
import json
|
||||||
|
from mcp_common import audit as audit_mod
|
||||||
|
|
||||||
|
audit_file = tmp_path / "audit.jsonl"
|
||||||
|
monkeypatch.setenv("AUDIT_LOG_FILE", str(audit_file))
|
||||||
|
# Reset state idempotency flag così il test riesegue setup
|
||||||
|
audit_mod._file_handler_attached = False
|
||||||
|
# Pulisci handlers preesistenti dal logger (potrebbe avere file vecchio)
|
||||||
|
for h in list(audit_mod._logger.handlers):
|
||||||
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
|
if isinstance(h, TimedRotatingFileHandler):
|
||||||
|
audit_mod._logger.removeHandler(h)
|
||||||
|
|
||||||
|
audit_write_op(
|
||||||
|
principal=Principal("core", {"core"}),
|
||||||
|
action="place_order",
|
||||||
|
exchange="bybit",
|
||||||
|
target="BTCUSDT",
|
||||||
|
payload={"side": "Buy", "qty": 0.01},
|
||||||
|
result={"order_id": "abc123", "status": "submitted"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Forza flush dei file handler
|
||||||
|
for h in audit_mod._logger.handlers:
|
||||||
|
h.flush()
|
||||||
|
|
||||||
|
assert audit_file.exists()
|
||||||
|
content = audit_file.read_text().strip()
|
||||||
|
assert content, "audit file empty"
|
||||||
|
record = json.loads(content.splitlines()[-1])
|
||||||
|
assert record["audit_event"] == "write_op"
|
||||||
|
assert record["action"] == "place_order"
|
||||||
|
assert record["exchange"] == "bybit"
|
||||||
|
assert record["target"] == "BTCUSDT"
|
||||||
|
assert record["principal"] == "core"
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_no_file_when_env_unset(tmp_path, monkeypatch):
|
||||||
|
"""Senza AUDIT_LOG_FILE, nessun file viene creato."""
|
||||||
|
from mcp_common import audit as audit_mod
|
||||||
|
monkeypatch.delenv("AUDIT_LOG_FILE", raising=False)
|
||||||
|
audit_mod._file_handler_attached = False
|
||||||
|
|
||||||
|
audit_write_op(
|
||||||
|
principal=Principal("core", {"core"}),
|
||||||
|
action="cancel_order",
|
||||||
|
exchange="bybit",
|
||||||
|
target="ord-1",
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
# Niente file creato in tmp_path
|
||||||
|
files = list(tmp_path.iterdir())
|
||||||
|
assert files == []
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from mcp_common.environment import resolve_environment
|
from mcp_common.environment import (
|
||||||
|
EnvironmentMismatchError,
|
||||||
|
consistency_check,
|
||||||
|
resolve_environment,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_env_var_overrides_secret(monkeypatch):
|
def test_env_var_overrides_secret(monkeypatch):
|
||||||
@@ -114,3 +118,72 @@ def test_alpaca_paper_flag_key(monkeypatch):
|
|||||||
)
|
)
|
||||||
assert info.environment == "mainnet"
|
assert info.environment == "mainnet"
|
||||||
assert info.source == "credentials"
|
assert info.source == "credentials"
|
||||||
|
|
||||||
|
|
||||||
|
# ───────── consistency_check ─────────
|
||||||
|
|
||||||
|
|
||||||
|
def _info(env: str, exchange: str = "deribit") -> "EnvironmentInfo":
|
||||||
|
"""Helper costruisce EnvironmentInfo per test."""
|
||||||
|
from mcp_common.environment import EnvironmentInfo
|
||||||
|
return EnvironmentInfo(
|
||||||
|
exchange=exchange,
|
||||||
|
environment=env,
|
||||||
|
source="env",
|
||||||
|
env_value="false" if env == "mainnet" else "true",
|
||||||
|
base_url=f"https://api.{exchange}.com" if env == "mainnet" else f"https://test.{exchange}.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_consistency_check_testnet_no_confirmation_ok():
|
||||||
|
"""Testnet senza conferma esplicita → ok, ritorna []. Default safe."""
|
||||||
|
info = _info("testnet")
|
||||||
|
creds = {"api_key": "k", "api_secret": "s"}
|
||||||
|
warnings = consistency_check(info, creds)
|
||||||
|
assert warnings == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_consistency_check_mainnet_no_confirmation_raises():
|
||||||
|
"""Mainnet senza creds['environment']='mainnet' esplicito → fail-fast."""
|
||||||
|
info = _info("mainnet")
|
||||||
|
creds = {"api_key": "k", "api_secret": "s"}
|
||||||
|
with pytest.raises(EnvironmentMismatchError, match="mainnet.*explicit confirmation"):
|
||||||
|
consistency_check(info, creds)
|
||||||
|
|
||||||
|
|
||||||
|
def test_consistency_check_mainnet_with_confirmation_ok():
|
||||||
|
info = _info("mainnet")
|
||||||
|
creds = {"api_key": "k", "api_secret": "s", "environment": "mainnet"}
|
||||||
|
warnings = consistency_check(info, creds)
|
||||||
|
assert warnings == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_consistency_check_explicit_mismatch_raises():
|
||||||
|
"""Secret dichiara mainnet ma resolver risolve testnet → fail-fast."""
|
||||||
|
info = _info("testnet")
|
||||||
|
creds = {"environment": "mainnet"}
|
||||||
|
with pytest.raises(EnvironmentMismatchError, match="declared.*resolved"):
|
||||||
|
consistency_check(info, creds)
|
||||||
|
|
||||||
|
|
||||||
|
def test_consistency_check_strict_mainnet_disabled():
|
||||||
|
"""Con strict_mainnet=False mainnet senza conferma logga warning ma non raise."""
|
||||||
|
info = _info("mainnet")
|
||||||
|
creds = {"api_key": "k", "api_secret": "s"}
|
||||||
|
warnings = consistency_check(info, creds, strict_mainnet=False)
|
||||||
|
assert any("mainnet" in w for w in warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_consistency_check_url_does_not_match_environment_warns():
|
||||||
|
"""Base URL contiene 'test' ma environment='mainnet' → warning."""
|
||||||
|
from mcp_common.environment import EnvironmentInfo
|
||||||
|
info = EnvironmentInfo(
|
||||||
|
exchange="bybit",
|
||||||
|
environment="mainnet",
|
||||||
|
source="env",
|
||||||
|
env_value="false",
|
||||||
|
base_url="https://api-testnet.bybit.com", # url DICE testnet ma resolver MAINNET
|
||||||
|
)
|
||||||
|
creds = {"environment": "mainnet"}
|
||||||
|
warnings = consistency_check(info, creds)
|
||||||
|
assert any("base_url" in w.lower() for w in warnings)
|
||||||
|
|||||||
Reference in New Issue
Block a user