ops(dev): add secrets rotation runbook and sofiia-console preflight checks
Made-with: Cursor
This commit is contained in:
100
docs/runbook/secrets-rotation.md
Normal file
100
docs/runbook/secrets-rotation.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Runbook: Secrets Rotation (Sofiia Stack)
|
||||
|
||||
Ціль: безпечно ротувати секрети для `sofiia-console`/router/gateway/memory без простою або з мінімальним вікном деградації.
|
||||
|
||||
## 1) Інвентар секретів (класи)
|
||||
|
||||
- Telegram bot tokens (`TELEGRAM_BOT_TOKEN`, агентні токени).
|
||||
- LLM/model provider keys (`OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `ANTHROPIC_API_KEY`, інші provider keys).
|
||||
- Redis auth (`SOFIIA_REDIS_URL` з паролем, якщо увімкнено auth).
|
||||
- DB encryption / signing keys (якщо використовується шифрування або signing).
|
||||
- JWT/session signing keys (якщо застосовуються в BFF/API).
|
||||
- Internal service tokens (`SUPERVISOR_API_KEY`, `ROUTER_API_KEY`, внутрішні service-to-service ключі).
|
||||
|
||||
## 2) Де вони живуть
|
||||
|
||||
- `.env` / docker compose env blocks.
|
||||
- systemd/launchd env (для процесів поза Docker).
|
||||
- Цільовий стан: централізований secrets manager (Vault/1Password/GitHub Encrypted Secrets) + мінімум секретів у файлах.
|
||||
|
||||
## 3) Процедура ротації (порядок і команди)
|
||||
|
||||
### 3.1 Підготувати нові секрети
|
||||
|
||||
```bash
|
||||
# Приклад генерації (локально, не комітити)
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Зафіксувати у change-log: які ключі, коли, хто, який rollout order.
|
||||
|
||||
### 3.2 Внести нові значення у runtime env (без друку значень)
|
||||
|
||||
```bash
|
||||
cd /path/to/microdao-daarion
|
||||
# оновити .env/.env.node* або secrets manager
|
||||
```
|
||||
|
||||
### 3.3 Dual-accept / dual-write (де можливо)
|
||||
|
||||
- Якщо сервіс підтримує два ключі: додати `NEW`, залишити `OLD` на перехідний період.
|
||||
- Якщо не підтримує: ротувати покроково з перевірками після кожного restart.
|
||||
|
||||
### 3.4 Rollout порядок по нодах
|
||||
|
||||
Рекомендований порядок для цього стеку:
|
||||
|
||||
1. **NODA2 (control-plane) — precheck**
|
||||
- Переконатися, що нові env підхоплюються локально.
|
||||
2. **NODA1 (production runtime)**
|
||||
- Оновити env, перезапустити критичні сервіси по черзі.
|
||||
3. **NODA2 (control-plane) — finalize**
|
||||
- Перезапустити `sofiia-console`, підтвердити керування нодами.
|
||||
|
||||
### 3.5 Команди перезапуску (приклади)
|
||||
|
||||
```bash
|
||||
# NODA1 (remote)
|
||||
ssh root@144.76.224.179 "cd /opt/microdao-daarion && docker compose -f docker-compose.node1.yml up -d router gateway memory-service"
|
||||
|
||||
# NODA2 (local control plane)
|
||||
cd /Users/apple/github-projects/microdao-daarion
|
||||
docker compose -f docker-compose.node2-sofiia.yml up -d sofiia-console router
|
||||
```
|
||||
|
||||
## 4) Перевірки після ротації
|
||||
|
||||
```bash
|
||||
# 1) статус API
|
||||
curl -fsS http://127.0.0.1:8002/api/status/full > /dev/null
|
||||
|
||||
# 2) дашборд нод
|
||||
curl -fsS http://127.0.0.1:8002/api/nodes/dashboard > /dev/null
|
||||
|
||||
# 3) метрики живі
|
||||
curl -fsS http://127.0.0.1:8002/metrics | rg "sofiia_"
|
||||
|
||||
# 4) distributed idempotency smoke (A/B)
|
||||
bash ops/redis_idempotency_smoke.sh
|
||||
```
|
||||
|
||||
Паралельно виконати preflight:
|
||||
|
||||
```bash
|
||||
bash ops/preflight_sofiia_console.sh
|
||||
STRICT=1 bash ops/preflight_sofiia_console.sh
|
||||
```
|
||||
|
||||
## 5) Rollback
|
||||
|
||||
1. Повернути попередні значення секретів (із безпечного backup/manager).
|
||||
2. Перезапустити сервіси в зворотному порядку:
|
||||
- NODA2 control-plane -> NODA1 runtime (або за вашим SOP).
|
||||
3. Повторити перевірки з розділу 4.
|
||||
4. Зафіксувати інцидент/rollback у операційному журналі.
|
||||
|
||||
## 6) Операційні правила
|
||||
|
||||
- Ніколи не комітити реальні секрети в git.
|
||||
- Логувати лише факт наявності/відсутності секрету, не значення.
|
||||
- Після ротації закривати перехідний dual-accept вікно (видалити старий ключ).
|
||||
151
ops/preflight_sofiia_console.sh
Executable file
151
ops/preflight_sofiia_console.sh
Executable file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env bash
|
||||
# Preflight checks before sofiia-console deploy.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 = OK
|
||||
# 1 = FAIL (or WARN in STRICT=1)
|
||||
# 2 = WARN-only (STRICT=0)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SOFIIA_URL="${SOFIIA_URL:-http://127.0.0.1:8002}"
|
||||
STRICT="${STRICT:-0}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
PASS=0
|
||||
WARN=0
|
||||
FAIL=0
|
||||
|
||||
_pass() { echo -e "${GREEN}PASS${NC}: $1"; PASS=$((PASS+1)); }
|
||||
_warn() { echo -e "${YELLOW}WARN${NC}: $1"; WARN=$((WARN+1)); }
|
||||
_fail() { echo -e "${RED}FAIL${NC}: $1"; FAIL=$((FAIL+1)); }
|
||||
_section() { echo -e "\n-- $1 --"; }
|
||||
|
||||
for cmd in bash curl rg python3; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || { echo "Missing prerequisite: $cmd"; exit 1; }
|
||||
done
|
||||
|
||||
echo "Sofiia Console Preflight"
|
||||
echo " SOFIIA_URL = ${SOFIIA_URL}"
|
||||
echo " STRICT = ${STRICT}"
|
||||
echo " $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
|
||||
_section "1) Secrets exposure scan (tracked files, best-effort)"
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
mapfile -t scan_files < <(git ls-files | rg '(^|/)(docker-compose.*\.ya?ml|\.env(\..*)?$|config/.*\.ya?ml)$' || true)
|
||||
if [ "${#scan_files[@]}" -eq 0 ]; then
|
||||
_warn "No tracked compose/env/config files found for secrets scan"
|
||||
else
|
||||
# Patterns focus on obvious accidental leaks in committed files.
|
||||
secret_pattern='(OPENAI_API_KEY\s*=\s*[^\s$]|DEEPSEEK_API_KEY\s*=\s*[^\s$]|ANTHROPIC_API_KEY\s*=\s*[^\s$]|TELEGRAM_BOT_TOKEN\s*=\s*[^\s$]|SUPERVISOR_API_KEY\s*=\s*[^\s$]|ROUTER_API_KEY\s*=\s*[^\s$]|Authorization:\s*Bearer\s+[A-Za-z0-9._-]+|ghp_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9_-]{20,})'
|
||||
if mapfile -t exposed < <(rg --files-with-matches -e "$secret_pattern" -- "${scan_files[@]}" 2>/dev/null); then
|
||||
if [ "${#exposed[@]}" -gt 0 ]; then
|
||||
_fail "Potential secrets detected in tracked files:"
|
||||
for f in "${exposed[@]}"; do
|
||||
echo " - $f"
|
||||
done
|
||||
else
|
||||
_pass "No obvious plaintext secrets found in tracked compose/env/config files"
|
||||
fi
|
||||
else
|
||||
_warn "Secrets scan command returned non-zero (review manually)"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
_warn "Not inside git repo; skipped tracked-file secrets scan"
|
||||
fi
|
||||
|
||||
_section "2) Required environment variables (presence only)"
|
||||
required_env=("ROUTER_URL" "SOFIIA_DATA_DIR")
|
||||
if [ "${SOFIIA_IDEMPOTENCY_BACKEND:-inmemory}" = "redis" ] || [ "${SOFIIA_RATE_LIMIT_BACKEND:-inmemory}" = "redis" ]; then
|
||||
required_env+=("SOFIIA_REDIS_URL")
|
||||
fi
|
||||
|
||||
for var_name in "${required_env[@]}"; do
|
||||
if [ -n "${!var_name:-}" ]; then
|
||||
_pass "env ${var_name} is set"
|
||||
else
|
||||
_warn "env ${var_name} is not set"
|
||||
fi
|
||||
done
|
||||
|
||||
_section "3) Reachability (health + metrics)"
|
||||
if curl -fsS --max-time 5 "${SOFIIA_URL}/api/health" >/dev/null 2>&1; then
|
||||
_pass "GET ${SOFIIA_URL}/api/health reachable"
|
||||
else
|
||||
_warn "GET ${SOFIIA_URL}/api/health unreachable (service may be down pre-deploy)"
|
||||
fi
|
||||
|
||||
if curl -fsS --max-time 5 "${SOFIIA_URL}/metrics" >/dev/null 2>&1; then
|
||||
_pass "GET ${SOFIIA_URL}/metrics reachable"
|
||||
else
|
||||
_warn "GET ${SOFIIA_URL}/metrics unreachable (service may be down pre-deploy)"
|
||||
fi
|
||||
|
||||
_section "4) Filesystem / data dir writable"
|
||||
DATA_DIR="${SOFIIA_DATA_DIR:-/app/data}"
|
||||
if mkdir -p "${DATA_DIR}" 2>/dev/null; then
|
||||
probe="${DATA_DIR}/.preflight_write_probe_$$"
|
||||
if ( : > "${probe}" ) 2>/dev/null; then
|
||||
rm -f "${probe}" || true
|
||||
_pass "SOFIIA_DATA_DIR is writable (${DATA_DIR})"
|
||||
else
|
||||
_fail "SOFIIA_DATA_DIR is not writable (${DATA_DIR})"
|
||||
fi
|
||||
else
|
||||
_fail "Cannot create SOFIIA_DATA_DIR (${DATA_DIR})"
|
||||
fi
|
||||
|
||||
_section "5) Redis connectivity (if redis backend enabled)"
|
||||
if [ "${SOFIIA_IDEMPOTENCY_BACKEND:-inmemory}" = "redis" ] || [ "${SOFIIA_RATE_LIMIT_BACKEND:-inmemory}" = "redis" ]; then
|
||||
REDIS_URL="${SOFIIA_REDIS_URL:-}"
|
||||
if [ -z "${REDIS_URL}" ]; then
|
||||
_fail "Redis backend enabled but SOFIIA_REDIS_URL is empty"
|
||||
else
|
||||
if python3 - <<'PYEOF' >/dev/null 2>&1
|
||||
import os
|
||||
url = os.getenv("SOFIIA_REDIS_URL", "")
|
||||
if not url:
|
||||
raise SystemExit(2)
|
||||
try:
|
||||
import redis
|
||||
c = redis.Redis.from_url(url, socket_connect_timeout=2, socket_timeout=2, decode_responses=True)
|
||||
if c.ping():
|
||||
raise SystemExit(0)
|
||||
raise SystemExit(1)
|
||||
except Exception:
|
||||
raise SystemExit(1)
|
||||
PYEOF
|
||||
then
|
||||
_pass "Redis ping OK (redis-python)"
|
||||
elif command -v redis-cli >/dev/null 2>&1 && redis-cli -u "${REDIS_URL}" --no-auth-warning ping >/dev/null 2>&1; then
|
||||
_pass "Redis ping OK (redis-cli)"
|
||||
else
|
||||
_fail "Redis ping failed (backend=redis)"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
_pass "Redis connectivity check skipped (backend != redis)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Preflight summary: PASS=${PASS} WARN=${WARN} FAIL=${FAIL}"
|
||||
echo "========================================"
|
||||
|
||||
if [ "${FAIL}" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${WARN}" -gt 0 ]; then
|
||||
if [ "${STRICT}" = "1" ]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user