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

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
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN addgroup --system app && adduser --system --ingroup app app
WORKDIR /service
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini pyproject.toml ./
RUN pip install --no-cache-dir .
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
+30
View File
@@ -0,0 +1,30 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://unused
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
@@ -0,0 +1,34 @@
import asyncio
import os
from alembic import context
from sqlalchemy.ext.asyncio import async_engine_from_config
config = context.config
config.set_main_option("sqlalchemy.url", os.environ["BITRIX_SYNC_DATABASE_URL"])
target_metadata = None
def run_offline() -> None:
context.configure(url=config.get_main_option("sqlalchemy.url"), literal_binds=True)
with context.begin_transaction():
context.run_migrations()
async def run_online() -> None:
engine = async_engine_from_config(config.get_section(config.config_ini_section) or {})
async with engine.connect() as connection:
await connection.run_sync(do_run)
await engine.dispose()
def do_run(connection) -> None:
context.configure(connection=connection)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_offline()
else:
asyncio.run(run_online())
@@ -0,0 +1,15 @@
"""Establish the bitrix-sync connectivity-stub migration baseline."""
revision = "0001_sync_baseline"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# The connectivity stub deliberately owns no runtime tables.
pass
def downgrade() -> None:
pass
@@ -0,0 +1 @@
"""HAN bitrix-sync DB connectivity stub."""
+319
View File
@@ -0,0 +1,319 @@
from __future__ import annotations
import asyncio
import hmac
import random
import time
import uuid
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Annotated, Protocol
import uvicorn
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
def utcnow() -> datetime:
return datetime.now(UTC)
class Settings(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
app_env: str = "production-like"
bitrix_sync_enabled: bool = True
bitrix_sync_database_url: str | None = None
bitrix_sync_service_token: str = Field(min_length=16)
bitrix_sync_db_check_interval_sec: float = Field(default=60, ge=0.05)
bitrix_sync_db_check_timeout_sec: float = Field(default=5, ge=0.05)
bitrix_sync_db_check_jitter_ratio: float = Field(default=0.1, ge=0, le=0.5)
bitrix_sync_db_retry_base_sec: float = Field(default=5, ge=0.05)
bitrix_sync_db_retry_max_sec: float = Field(default=60, ge=0.05)
bitrix_sync_ready_max_staleness_sec: float = Field(default=150, ge=1)
bitrix_sync_db_pool_size: int = Field(default=2, ge=1, le=5)
bitrix_sync_db_pool_recycle_sec: int = Field(default=300, ge=30)
@model_validator(mode="after")
def validate_enabled(self) -> Settings:
if self.bitrix_sync_enabled and not self.bitrix_sync_database_url:
raise ValueError("BITRIX_SYNC_DATABASE_URL is required when sync is enabled")
return self
@dataclass
class Snapshot:
state: str
started_at: datetime
last_started_at: datetime | None = None
last_finished_at: datetime | None = None
last_success_at: datetime | None = None
success: bool | None = None
duration_ms: int | None = None
error_code: str | None = None
consecutive_failures: int = 0
next_check_at: datetime | None = None
worker_running: bool = False
class Probe(Protocol):
async def check(self) -> None: ...
async def close(self) -> None: ...
class DatabaseProbe:
def __init__(self, engine: AsyncEngine) -> None:
self.engine = engine
async def check(self) -> None:
async with self.engine.connect() as connection:
result = await connection.scalar(text("SELECT 1"))
if result != 1:
raise RuntimeError("unexpected_result")
async def close(self) -> None:
await self.engine.dispose()
def database_url(value: str) -> str:
if value.startswith("postgresql://"):
return value.replace("postgresql://", "postgresql+asyncpg://", 1)
return value
def classify_error(exc: Exception) -> str:
name = type(exc).__name__.lower()
text_value = str(exc).lower()
if isinstance(exc, TimeoutError):
return "db_query_timeout"
if "auth" in name or "password" in text_value:
return "db_auth_failed"
if "ssl" in name or "tls" in text_value or "certificate" in text_value:
return "db_tls_failed"
if str(exc) == "unexpected_result":
return "unexpected_result"
return "db_unavailable"
def jsonable(value):
if isinstance(value, datetime):
return value.isoformat().replace("+00:00", "Z")
return value
def create_app(settings: Settings | None = None, probe: Probe | None = None) -> FastAPI:
cfg = settings or Settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.snapshot = Snapshot(
state="disabled" if not cfg.bitrix_sync_enabled else "starting",
started_at=utcnow(),
)
app.state.stop = asyncio.Event()
app.state.probe = probe
app.state.loop_task = None
if cfg.bitrix_sync_enabled:
if app.state.probe is None:
engine = create_async_engine(
database_url(cfg.bitrix_sync_database_url or ""),
pool_size=cfg.bitrix_sync_db_pool_size,
max_overflow=0,
pool_pre_ping=True,
pool_recycle=cfg.bitrix_sync_db_pool_recycle_sec,
connect_args={"server_settings": {"application_name": "han-bitrix-sync"}},
)
app.state.probe = DatabaseProbe(engine)
await run_probe(app)
app.state.snapshot.worker_running = True
app.state.loop_task = asyncio.create_task(
periodic_loop(app), name="db-connectivity-probe"
)
yield
app.state.snapshot.state = "stopping"
app.state.stop.set()
if app.state.loop_task:
app.state.loop_task.cancel()
with suppress(asyncio.CancelledError):
await app.state.loop_task
if app.state.probe:
await app.state.probe.close()
app = FastAPI(
title="HAN Bitrix Sync Connectivity Stub",
version="1.0.0",
lifespan=lifespan,
docs_url=None if cfg.app_env != "test" else "/docs",
)
app.state.settings = cfg
@app.middleware("http")
async def request_id(request: Request, call_next):
request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
response = await call_next(request)
response.headers["X-Request-ID"] = request.state.request_id
return response
@app.exception_handler(HTTPException)
async def http_error(_: Request, exc: HTTPException):
return JSONResponse(status_code=exc.status_code, content=exc.detail)
@app.get("/health/live")
async def live():
return {"status": "live"}
@app.get("/health/ready")
async def ready(request: Request):
snapshot: Snapshot = request.app.state.snapshot
if not cfg.bitrix_sync_enabled:
return JSONResponse({"status": "not_ready", "reason": "sync_disabled"}, status_code=503)
age = (
(utcnow() - snapshot.last_success_at).total_seconds()
if snapshot.last_success_at
else None
)
max_age = max(
cfg.bitrix_sync_ready_max_staleness_sec,
2 * cfg.bitrix_sync_db_check_interval_sec * (1 + cfg.bitrix_sync_db_check_jitter_ratio),
)
is_ready = (
snapshot.state == "healthy"
and snapshot.worker_running
and age is not None
and age <= max_age
)
if is_ready:
return {
"status": "ready",
"mode": "db_connectivity_stub",
"database": {
"status": "ok",
"last_success_at": jsonable(snapshot.last_success_at),
"age_seconds": round(age or 0, 3),
},
}
reason = "worker_not_running" if not snapshot.worker_running else "database_unavailable"
return JSONResponse(
{
"status": "not_ready",
"reason": reason,
"database": {
"status": "down",
"last_success_at": jsonable(snapshot.last_success_at),
"consecutive_failures": snapshot.consecutive_failures,
},
},
status_code=503,
)
@app.get("/internal/sync/v1/status")
async def status(
request: Request,
authorization: Annotated[str | None, Header()] = None,
):
candidate = (
authorization[7:] if authorization and authorization.startswith("Bearer ") else ""
)
if not hmac.compare_digest(candidate, cfg.bitrix_sync_service_token):
raise HTTPException(
401,
{
"error": {
"code": "service_unauthorized",
"message": "Service authentication failed",
"request_id": request.state.request_id,
"details": {},
}
},
)
snapshot: Snapshot = request.app.state.snapshot
last_check = None
if snapshot.last_started_at:
last_check = {
"started_at": jsonable(snapshot.last_started_at),
"finished_at": jsonable(snapshot.last_finished_at),
"success": snapshot.success,
"duration_ms": snapshot.duration_ms,
"error_code": snapshot.error_code,
}
next_in = (
max(0, (snapshot.next_check_at - utcnow()).total_seconds())
if snapshot.next_check_at
else None
)
return {
"service": "bitrix-sync",
"enabled": cfg.bitrix_sync_enabled,
"mode": "db_connectivity_stub",
"crm_sync_implemented": False,
"state": snapshot.state,
"started_at": jsonable(snapshot.started_at),
"last_check": last_check,
"last_success_at": jsonable(snapshot.last_success_at),
"consecutive_failures": snapshot.consecutive_failures,
"next_check_in_seconds": round(next_in, 3) if next_in is not None else None,
}
return app
async def run_probe(app: FastAPI) -> None:
cfg: Settings = app.state.settings
snapshot: Snapshot = app.state.snapshot
snapshot.last_started_at = utcnow()
started = time.monotonic()
try:
async with asyncio.timeout(cfg.bitrix_sync_db_check_timeout_sec):
await app.state.probe.check()
except Exception as exc:
snapshot.success = False
snapshot.error_code = classify_error(exc)
snapshot.consecutive_failures += 1
snapshot.state = "degraded"
else:
snapshot.success = True
snapshot.error_code = None
snapshot.consecutive_failures = 0
snapshot.last_success_at = utcnow()
snapshot.state = "healthy"
finally:
snapshot.last_finished_at = utcnow()
snapshot.duration_ms = round((time.monotonic() - started) * 1000)
async def periodic_loop(app: FastAPI) -> None:
cfg: Settings = app.state.settings
snapshot: Snapshot = app.state.snapshot
try:
while not app.state.stop.is_set():
if snapshot.consecutive_failures:
cap = min(
cfg.bitrix_sync_db_retry_base_sec * 2 ** (snapshot.consecutive_failures - 1),
cfg.bitrix_sync_db_retry_max_sec,
)
delay = random.uniform(0, cap)
else:
jitter = (
cfg.bitrix_sync_db_check_interval_sec * cfg.bitrix_sync_db_check_jitter_ratio
)
delay = cfg.bitrix_sync_db_check_interval_sec + random.uniform(-jitter, jitter)
snapshot.next_check_at = utcnow() + timedelta(seconds=delay)
try:
await asyncio.wait_for(app.state.stop.wait(), timeout=delay)
break
except TimeoutError:
await run_probe(app)
finally:
snapshot.worker_running = False
_settings = Settings()
app = create_app(_settings)
def run() -> None:
uvicorn.run("app.main:app", host="0.0.0.0", port=8080)
+46
View File
@@ -0,0 +1,46 @@
openapi: 3.1.0
info: {title: HAN Bitrix Sync Connectivity Stub, version: 1.0.0}
paths:
/health/live:
get:
responses:
"200":
description: Process is live
content: {application/json: {schema: {$ref: "#/components/schemas/Live"}}}
/health/ready:
get:
responses:
"200": {description: Latest PostgreSQL probe is fresh and successful}
"503": {description: Disabled, stale, or database unavailable}
/internal/sync/v1/status:
get:
security: [{BearerAuth: []}]
parameters:
- {name: X-Request-ID, in: header, required: false, schema: {type: string}}
responses:
"200":
description: Connectivity-loop status
content: {application/json: {schema: {$ref: "#/components/schemas/Status"}}}
"401": {description: Service authentication failed}
components:
securitySchemes:
BearerAuth: {type: http, scheme: bearer}
schemas:
Live:
type: object
required: [status]
properties: {status: {const: live}}
Status:
type: object
required: [service, enabled, mode, crm_sync_implemented, state, started_at]
properties:
service: {const: bitrix-sync}
enabled: {type: boolean}
mode: {const: db_connectivity_stub}
crm_sync_implemented: {const: false}
state: {type: string, enum: [starting, disabled, healthy, degraded, stopping]}
started_at: {type: string, format: date-time}
last_check: {type: [object, "null"]}
last_success_at: {type: [string, "null"], format: date-time}
consecutive_failures: {type: integer, minimum: 0}
next_check_in_seconds: {type: [number, "null"]}
@@ -0,0 +1,30 @@
[project]
name = "han-bitrix-sync"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.16,<2",
"asyncpg>=0.30,<1",
"fastapi>=0.116,<1",
"pydantic-settings>=2.10,<3",
"sqlalchemy[asyncio]>=2.0.41,<3",
"uvicorn[standard]>=0.35,<1",
]
[project.optional-dependencies]
dev = ["httpx>=0.28,<1", "pytest>=8.4,<9", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
line-length = 100
@@ -0,0 +1,65 @@
import os
os.environ.setdefault("BITRIX_SYNC_ENABLED", "false")
os.environ.setdefault("BITRIX_SYNC_SERVICE_TOKEN", "test-sync-token-32-characters")
import httpx
import pytest
from app.main import Settings, create_app
class Probe:
def __init__(self, fail=False):
self.fail = fail
self.calls = 0
async def check(self):
self.calls += 1
if self.fail:
raise OSError("down")
async def close(self):
pass
@pytest.mark.asyncio
async def test_disabled_semantics():
settings = Settings(
bitrix_sync_enabled=False,
bitrix_sync_service_token="test-sync-token-32-characters",
)
app = create_app(settings)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
assert (await client.get("/health/live")).status_code == 200
ready = await client.get("/health/ready")
assert ready.status_code == 503
assert ready.json()["reason"] == "sync_disabled"
status = await client.get(
"/internal/sync/v1/status",
headers={"Authorization": "Bearer test-sync-token-32-characters"},
)
assert status.json()["state"] == "disabled"
assert status.json()["crm_sync_implemented"] is False
@pytest.mark.asyncio
async def test_initial_probe_and_auth():
probe = Probe()
settings = Settings(
bitrix_sync_enabled=True,
bitrix_sync_database_url="postgresql://unused/unused",
bitrix_sync_service_token="test-sync-token-32-characters",
bitrix_sync_db_check_interval_sec=60,
)
app = create_app(settings, probe)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
assert probe.calls == 1
assert (await client.get("/health/ready")).status_code == 200
assert (await client.get("/internal/sync/v1/status")).status_code == 401