Files
tgit/app/config.py
ElebobaandClaude Opus 5.5 55286495cb
CI / ruff · mypy · pytest (pull_request) Successful in 1m11s
feat(status): раздел «Статус» — контейнеры, карточка, проверки (#14)
- docker-socket-proxy в Compose (только GET /containers/*), сокет только у него
- app/runtime/docker.py: read-only клиент; видны только контейнеры с меткой
  tgit.managed=true, чужие id отклоняются до запроса
- app/runtime/checks.py: running, health, рестарты за окно, CPU/RAM, ошибки
  в логе, HTTP-проба; у каждой проверки свой таймаут, отчёт всегда полный
- app/runtime/snapshots.py: снапшоты раз в 15 с в container_snapshots
- бот: список с 🟢🟡🔴⚪, карточка, «🩺 Запустить проверки», «🔄 Обновить»
- деплой ставит метки tgit.sha / tgit.deployed_at на контейнер бота
- имя джоба CD без неотрисованного выражения

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:29:23 +03:00

122 lines
5.1 KiB
Python

"""Typed configuration from the environment / `.env` — one settings class per subsystem (ARCHITECTURE §2.11)."""
from pathlib import Path
from typing import Annotated, Literal
from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
_ENV = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
class TelegramSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="TELEGRAM_")
bot_token: SecretStr
# http://user:pass@host:port or socks5://… — the RF server has no route to api.telegram.org (same as meditron)
proxy_url: SecretStr | None = None
class AccessSettings(BaseSettings):
model_config = _ENV
# Bootstrap admins (SPEC A-8). Comma-separated in the env: ADMIN_IDS=1,2
admin_ids: Annotated[frozenset[int], NoDecode] = frozenset()
invite_ttl_hours: float = Field(default=24.0, gt=0)
# More failed /start <token> per hour than this — silence (SPEC A-6)
max_failed_attempts: int = Field(default=5, ge=1)
@field_validator("admin_ids", mode="before")
@classmethod
def _split_ids(cls, value: object) -> object:
if isinstance(value, str):
return frozenset(int(part) for part in value.replace(" ", "").split(",") if part)
return value
class LLMSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="LLM_")
# mock: deterministic offline answers (tests, demo without network); openai_compat: any OpenAI-style API (ADR-008)
provider: Literal["mock", "openai_compat"] = "mock"
base_url: str = ""
model: str = ""
api_key: SecretStr = SecretStr("")
timeout: float = Field(default=30.0, gt=0)
max_attempts: int = Field(default=3, ge=1)
temperature: float = Field(default=0.0, ge=0, le=2)
class DatabaseSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="DATABASE_")
# Compose sets a PostgreSQL URL; the default lets the bot run locally without Docker.
url: str = "sqlite+aiosqlite:///./tgit.db"
class SourceCraftSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="SOURCECRAFT_")
token: SecretStr = SecretStr("")
api_url: str = "https://api.sourcecraft.tech"
uploads_repo: str = "rogachevegorushka/tgit-uploads"
# Web link to a CI run. The SPA answers 200 on any path, so the format is unverified — adjust if it 404s in UI.
run_url_template: str = "https://sourcecraft.dev/{repo}/cicd/runs/{run}"
timeout: float = Field(default=30.0, gt=0)
max_attempts: int = Field(default=5, ge=1)
class GitSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="GIT_")
# The bot's own deploy key with write access to tgit-uploads only (#7)
uploads_url: str = "git@ssh.sourcecraft.dev:rogachevegorushka/tgit-uploads.git"
ssh_key_path: Path | None = None
# Alternative for containers: the private key itself, base64 — a mounted host file is unreadable for the
# container user (uid 10001 vs the owner's 0600). Written to a private temp file at startup.
ssh_key_b64: SecretStr | None = None
branch: str = "main"
# Web link to a commit in the uploads repo, e.g. https://sourcecraft.dev/{repo}/commit/{sha}; empty — no link
commit_url_template: str = ""
clone_dir: Path = Path("data/uploads")
# Browsing re-fetches at most this often; a push always fetches first (#9)
fetch_interval: float = Field(default=10.0, ge=0)
timeout: float = Field(default=60.0, gt=0)
class DockerSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="DOCKER_")
# docker-socket-proxy (GET only, ADR-006); empty — the «Статус» section reports Docker as unavailable
url: str = ""
# Only containers with this label are shown: the host runs other projects too
label: str = "tgit.managed=true"
timeout: float = Field(default=10.0, gt=0)
snapshot_interval: float = Field(default=15.0, gt=0)
max_log_bytes: int = Field(default=5 * 1024 * 1024, gt=0)
class RuntimeSettings(BaseSettings):
model_config = _ENV
log_level: str = "INFO"
# Non-secret configs (texts, thresholds, prompts). Relative to the working dir: /srv/tgit in Docker.
config_dir: Path = Path("config")
# The heartbeat file is touched while the event loop is alive; the Docker healthcheck reads its age.
heartbeat_file: Path = Path("/tmp/tgit-heartbeat")
heartbeat_interval: float = Field(default=10.0, gt=0)
heartbeat_max_age: float = Field(default=30.0, gt=0)
class Settings(BaseSettings):
model_config = _ENV
telegram: TelegramSettings = Field(default_factory=TelegramSettings)
access: AccessSettings = Field(default_factory=AccessSettings)
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
llm: LLMSettings = Field(default_factory=LLMSettings)
sourcecraft: SourceCraftSettings = Field(default_factory=SourceCraftSettings)
git: GitSettings = Field(default_factory=GitSettings)
docker: DockerSettings = Field(default_factory=DockerSettings)
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)