Разработана первая версия приложений

This commit is contained in:
mi
2026-07-10 18:06:14 +03:00
parent aa8761d1b3
commit 8c7b4074c4
162 changed files with 12178 additions and 16 deletions
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Fail-fast validation for the single HAN Chat deployment environment."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from urllib.parse import urlparse
REQUIRED = {
"APP_ENV", "RELEASE_VERSION", "HAN_PG_HOST", "DATABASE_URL",
"BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL",
"MESSAGE_SAFETY_DATABASE_URL", "KEYCLOAK_DB_URL", "PUBLIC_HOST",
"PUBLIC_WEB_URL", "PUBLIC_API_URL", "PUBLIC_AUTH_URL",
"KEYCLOAK_PUBLIC_URL", "KEYCLOAK_INTERNAL_URL", "REDIS_URL",
"REDIS_REALTIME_URL", "MESSAGE_SAFETY_REDIS_URL",
"MESSAGE_SAFETY_SERVICE_TOKEN", "BITRIX_LOCAL_APP_INTERNAL_TOKEN",
"BITRIX_INTERNAL_API_TOKEN", "BITRIX_API_FORWARD_TOKEN",
"BITRIX_API_INBOX_TOKEN", "BITRIX_SYNC_SERVICE_TOKEN",
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN", "KEYCLOAK_OTP_HMAC_KEY",
"KEYCLOAK_ADMIN", "KEYCLOAK_ADMIN_PASSWORD", "CURSOR_HMAC_SECRET",
"BITRIX_TOKEN_ENCRYPTION_KEY",
"SELECTEL_S3_ENDPOINT_URL",
"SELECTEL_S3_ACCESS_KEY", "SELECTEL_S3_SECRET_KEY",
"SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY",
"SELECTEL_S3_QUARANTINE_READ_SECRET_KEY", "REDIS_API_PASSWORD",
"REDIS_SAFETY_PASSWORD", "REDIS_HEALTH_PASSWORD",
}
SECRET_KEYS = {
key for key in REQUIRED
if any(word in key for word in ("TOKEN", "PASSWORD", "SECRET_KEY", "ACCESS_KEY"))
} | {"KEYCLOAK_OTP_MOCK_CODE", "BITRIX_CLIENT_SECRET", "BITRIX_APPLICATION_TOKEN"}
PLACEHOLDER = re.compile(r"(change-me|example\.(com|ru|invalid)|<[^>]+>)", re.I)
def load(path: Path) -> tuple[dict[str, str], list[str]]:
values: dict[str, str] = {}
errors: list[str] = []
for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
errors.append(f"строка {number}: ожидается KEY=VALUE")
continue
key, value = line.split("=", 1)
key = key.strip()
if key in values:
errors.append(f"строка {number}: повтор ключа {key}")
values[key] = value.strip()
return values, errors
def main() -> int:
path = Path(sys.argv[1] if len(sys.argv) > 1 else ".env")
if not path.is_file():
print(f"ERROR: файл не найден: {path}", file=sys.stderr)
return 2
env, errors = load(path)
for key in sorted(REQUIRED):
if not env.get(key):
errors.append(f"{key}: обязательное значение отсутствует")
for key in sorted(SECRET_KEYS):
value = env.get(key, "")
if value and (len(value) < 16 or PLACEHOLDER.search(value)):
errors.append(f"{key}: секрет должен быть непустым, уникальным и длиной >=16")
production = env.get("APP_ENV") in {"production-like", "production"}
if production and env.get("FRONTEND_DEV_PROXY_ENABLED", "").lower() != "false":
errors.append("FRONTEND_DEV_PROXY_ENABLED: production-like/production требует false")
if production and env.get("NGINX_TLS_ENABLED", "").lower() != "true":
errors.append("NGINX_TLS_ENABLED: production-like/production требует true")
for key in ("PUBLIC_WEB_URL", "PUBLIC_API_URL", "PUBLIC_AUTH_URL", "KEYCLOAK_PUBLIC_URL"):
if env.get(key) and urlparse(env[key]).scheme != "https":
errors.append(f"{key}: публичный URL должен использовать https")
for key in ("KEYCLOAK_INTERNAL_URL", "MESSAGE_SAFETY_URL", "BITRIX_LOCAL_APP_BASE_URL"):
parsed = urlparse(env.get(key, ""))
if parsed.scheme != "http" or "." in (parsed.hostname or ""):
errors.append(f"{key}: ожидается http URL с Docker DNS service name")
if not env.get("KEYCLOAK_INTERNAL_URL", "").rstrip("/").endswith("/auth"):
errors.append("KEYCLOAK_INTERNAL_URL: внутренний URL должен заканчиваться на /auth")
redis_contract = (
("REDIS_URL", "api_backend", "REDIS_API_PASSWORD", "/0"),
("REDIS_REALTIME_URL", "api_backend", "REDIS_API_PASSWORD", "/1"),
("MESSAGE_SAFETY_REDIS_URL", "message_safety", "REDIS_SAFETY_PASSWORD", "/2"),
)
for key, username, password_key, database in redis_contract:
parsed = urlparse(env.get(key, ""))
if (
parsed.scheme != "redis"
or parsed.hostname != "redis"
or parsed.username != username
or parsed.password != env.get(password_key)
or parsed.path != database
):
errors.append(f"{key}: ACL user/password/host/DB не согласованы с {password_key}")
for key in (
"DATABASE_URL", "BITRIX_DATABASE_URL", "BITRIX_SYNC_DATABASE_URL",
"MESSAGE_SAFETY_DATABASE_URL", "KEYCLOAK_DB_URL",
):
value = env.get(key, "")
if "sslmode=verify-full" not in value or "sslrootcert=" not in value:
errors.append(f"{key}: требуется sslmode=verify-full и sslrootcert")
pairs = (
("BITRIX_LOCAL_APP_INTERNAL_TOKEN", "BITRIX_INTERNAL_API_TOKEN"),
("BITRIX_API_FORWARD_TOKEN", "BITRIX_API_INBOX_TOKEN"),
)
for left, right in pairs:
if env.get(left) != env.get(right):
errors.append(f"{left} должен совпадать с {right}")
try:
poll = int(env.get("MESSAGE_SAFETY_TASK_POLL_MAX_SEC", "0"))
nginx = int(env.get("NGINX_MESSAGE_READ_TIMEOUT_SEC", "0"))
ttl = int(env.get("MESSAGE_SAFETY_TASK_TTL_SEC", "0"))
if nginx < poll + 30:
errors.append("NGINX_MESSAGE_READ_TIMEOUT_SEC должен быть >= poll max + 30")
if ttl <= poll + 60:
errors.append("MESSAGE_SAFETY_TASK_TTL_SEC должен превышать poll max + recovery margin")
except ValueError:
errors.append("Safety/NGINX timeout values должны быть целыми числами")
if production and env.get("KEYCLOAK_OTP_MOCK_ENABLED", "").lower() == "true":
if env.get("KEYCLOAK_OTP_MOCK_RISK_ACCEPTED", "").lower() != "true":
errors.append("KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true обязателен для mock OTP")
if env.get("HAN_PG_HOST") in {"localhost", "127.0.0.1", "postgres", "db"}:
errors.append("HAN_PG_HOST: PostgreSQL должен быть внешним managed endpoint")
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
return 1
print(f"OK: {path} прошёл проверку ({len(env)} переменных)")
return 0
if __name__ == "__main__":
raise SystemExit(main())