Логика запуска и Caddyfile переехали внутрь app

Файлы вне папки app обновление не доставляло: чтобы изменить порядок
запуска или конфигурацию Caddy, требовалась переустановка. Теперь .bat -
заглушка из трёх строк, вызывающая app/launcher.py, а Caddyfile создаётся
из настроек [https] в config.toml. Отредактированный вручную Caddyfile
не затирается: новая версия откладывается рядом как Caddyfile.new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vladimir Bryzgalov
2026-08-16 17:23:47 +05:00
co-authored by Claude Opus 5
parent cd3acafa8a
commit febec7f124
5 changed files with 251 additions and 47 deletions
+6 -3
View File
@@ -102,6 +102,7 @@ class Settings:
update_server: str = "https://git.netranking.ru" update_server: str = "https://git.netranking.ru"
update_repo: str = "bryzgalov/talkscore-asr" update_repo: str = "bryzgalov/talkscore-asr"
update_token: str = "" update_token: str = ""
https_domains: str = ""
base_dir: Path = field(default_factory=lambda: BASE_DIR) base_dir: Path = field(default_factory=lambda: BASE_DIR)
@property @property
@@ -157,15 +158,16 @@ def load_settings(config_path: Path | None = None) -> Settings:
f"не удалось прочитать {path}\n" f"не удалось прочитать {path}\n"
f" {exc}\n\n" f" {exc}\n\n"
" Скорее всего, один и тот же раздел объявлен дважды.\n" " Скорее всего, один и тот же раздел объявлен дважды.\n"
" Каждый раздел ([server], [security], [processing], [webhook], [update])\n" " Каждый раздел ([server], [security], [processing], [webhook],\n"
" должен встречаться в файле ровно один раз, а все его настройки -\n" " [update], [https]) должен встречаться в файле ровно один раз,\n"
" идти внутри него одним блоком." " а все его настройки - идти внутри него одним блоком."
) from exc ) from exc
server = data.get("server", {}) server = data.get("server", {})
security = data.get("security", {}) security = data.get("security", {})
proc = data.get("processing", {}) proc = data.get("processing", {})
upd = data.get("update", {}) upd = data.get("update", {})
hook = data.get("webhook", {}) hook = data.get("webhook", {})
https = data.get("https", {})
return Settings( return Settings(
host=server.get("host", "0.0.0.0"), host=server.get("host", "0.0.0.0"),
@@ -182,6 +184,7 @@ def load_settings(config_path: Path | None = None) -> Settings:
keep_results_hours=float(proc.get("keep_results_hours", 72)), keep_results_hours=float(proc.get("keep_results_hours", 72)),
webhook_url=str(hook.get("url", "")), webhook_url=str(hook.get("url", "")),
webhook_secret=str(hook.get("secret", "")), webhook_secret=str(hook.get("secret", "")),
https_domains=str(https.get("domains", "")),
update_enabled=bool(upd.get("enabled", False)), update_enabled=bool(upd.get("enabled", False)),
update_server=str(upd.get("server", "https://git.netranking.ru")), update_server=str(upd.get("server", "https://git.netranking.ru")),
update_repo=str(upd.get("repo", "bryzgalov/talkscore-asr")), update_repo=str(upd.get("repo", "bryzgalov/talkscore-asr")),
+131
View File
@@ -0,0 +1,131 @@
"""Запуск сервиса: обновление, Caddyfile, HTTPS.
Раньше вся эта последовательность жила в .bat-файлах, а Caddyfile правился
руками. Оба файла лежат вне папки app, поэтому обновление их не доставляло:
чтобы получить новый порядок запуска, приходилось переустанавливать сервис.
Теперь .bat - это трёхстрочная заглушка, которая просто зовёт этот модуль,
а Caddyfile создаётся здесь из настроек. Меняется только код внутри app,
который обновление возит штатно.
"""
import subprocess
import sys
from pathlib import Path
__all__ = ["render_caddyfile", "ensure_caddyfile", "main"]
# По этой строке отличаем свой файл от отредактированного вручную.
MARK = "# Создан автоматически talkscore-asr. Правки будут перезаписаны."
CADDY_WAIT_SEC = 3
def render_caddyfile(domains: str, port: int, max_upload_mb: int) -> str:
"""Собирает конфигурацию Caddy из настроек сервиса."""
listed = ", ".join(d.strip() for d in domains.split(",") if d.strip())
return f"""{MARK}
# Домены и предел размера берутся из config.toml, менять нужно там.
{{
\t# Caddy очень подробно рассказывает про сертификаты. В консоли из-за этого
\t# не видно сообщений сервиса, поэтому его вывод уходит в файл.
\tlog {{
\t\toutput file logs/caddy.log {{
\t\t\troll_size 10MiB
\t\t\troll_keep 5
\t\t}}
\t\tformat console
\t\tlevel INFO
\t}}
}}
{listed} {{
\treverse_proxy 127.0.0.1:{port} {{
\t\t# Реальный адрес клиента - иначе список разрешённых адресов
\t\t# увидит вместо него localhost и пустит кого угодно.
\t\theader_up X-Forwarded-For {{remote_host}}
\t}}
\trequest_body {{
\t\tmax_size {max_upload_mb}MB
\t}}
}}
"""
def ensure_caddyfile(base_dir: Path, content: str) -> Path | None:
"""Записывает Caddyfile, не затирая ручные правки.
Если файл создан не нами, откладываем новую версию рядом: молча стереть
чужую настройку хуже, чем оставить её устаревшей.
"""
target = base_dir / "Caddyfile"
if target.exists():
existing = target.read_text(encoding="utf-8", errors="replace")
if MARK not in existing:
spare = base_dir / "Caddyfile.new"
if existing.strip() != content.strip():
spare.write_text(content, encoding="utf-8")
return spare
return None
if existing == content:
return None
target.write_text(content, encoding="utf-8")
return target
def start_caddy(base_dir: Path) -> subprocess.Popen | None:
"""Поднимает Caddy отдельным процессом. Его отсутствие не должно ронять запуск."""
binary = base_dir / "bin" / "caddy.exe"
if not binary.is_file():
binary = base_dir / "bin" / "caddy"
if not binary.is_file():
print("\n Caddy не найден в папке bin - запускаюсь без HTTPS.\n")
return None
(base_dir / "logs").mkdir(exist_ok=True)
return subprocess.Popen(
[str(binary), "run", "--config", str(base_dir / "Caddyfile")],
cwd=str(base_dir))
def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
https = "https" in argv
from app import updater
from app.config import ConfigError, load_settings
try:
settings = load_settings()
except ConfigError as exc:
print(f"\n {exc}\n")
return 1
updater.main()
caddy = None
if https:
if not settings.https_domains.strip():
print("\n В config.toml не заданы домены: раздел [https], ключ domains."
"\n Без них сертификат выпустить не на что.\n")
return 1
written = ensure_caddyfile(
settings.base_dir,
render_caddyfile(settings.https_domains, settings.port,
settings.max_upload_mb))
if written is not None and written.name.endswith(".new"):
print(f"\n Caddyfile правили вручную, поэтому не трогаю его."
f"\n Новая версия отложена рядом: {written}\n")
caddy = start_caddy(settings.base_dir)
try:
from app.main import run
run()
finally:
if caddy is not None:
caddy.terminate()
return 0
if __name__ == "__main__":
sys.exit(main())
+6 -39
View File
@@ -107,7 +107,6 @@ def add_app(stage: Path) -> None:
write_bat(stage / "start.bat", START_BAT) write_bat(stage / "start.bat", START_BAT)
write_bat(stage / "download_models.bat", DOWNLOAD_BAT) write_bat(stage / "download_models.bat", DOWNLOAD_BAT)
write_bat(stage / "start-https.bat", HTTPS_BAT) write_bat(stage / "start-https.bat", HTTPS_BAT)
(stage / "Caddyfile").write_text(CADDYFILE, encoding="utf-8", newline="\r\n")
(stage / "download_models.py").write_text( (stage / "download_models.py").write_text(
(ROOT / "build" / "download_models.py").read_text(encoding="utf-8"), encoding="utf-8") (ROOT / "build" / "download_models.py").read_text(encoding="utf-8"), encoding="utf-8")
@@ -125,60 +124,28 @@ def write_bat(path: Path, text: str) -> None:
# chcp 65001 переводит консоль в UTF-8, и Python дальше пишет в той же кодировке. # chcp 65001 переводит консоль в UTF-8, и Python дальше пишет в той же кодировке.
# Без этой пары русский текст в выводе превращается в мусор. # Без этой пары русский текст в выводе превращается в мусор.
# .bat - трёхстрочные заглушки: вся логика запуска живёт в app/launcher.py,
# который обновление доставляет штатно. Раньше логика была здесь, и чтобы
# изменить порядок запуска, требовалась переустановка.
# chcp 65001 переводит консоль в UTF-8, иначе русский текст станет мусором.
START_BAT = """@echo off START_BAT = """@echo off
chcp 65001 >nul chcp 65001 >nul
cd /d "%~dp0" cd /d "%~dp0"
set "TALKSCORE_ASR_HOME=%~dp0" set "TALKSCORE_ASR_HOME=%~dp0"
set "PYTHONPATH=%~dp0" set "PYTHONPATH=%~dp0"
"%~dp0python\\python.exe" -m app.updater "%~dp0python\\python.exe" -m app.launcher
"%~dp0python\\python.exe" -m app.main
pause pause
""" """
# Caddy слушает 443 и проксирует на сервис, который при этом сидит на localhost.
HTTPS_BAT = """@echo off HTTPS_BAT = """@echo off
chcp 65001 >nul chcp 65001 >nul
cd /d "%~dp0" cd /d "%~dp0"
set "TALKSCORE_ASR_HOME=%~dp0" set "TALKSCORE_ASR_HOME=%~dp0"
set "PYTHONPATH=%~dp0" set "PYTHONPATH=%~dp0"
start "caddy" /min "%~dp0bin\\caddy.exe" run --config "%~dp0Caddyfile" "%~dp0python\\python.exe" -m app.launcher https
"%~dp0python\\python.exe" -m app.updater
"%~dp0python\\python.exe" -m app.main
pause pause
""" """
CADDYFILE = """# Домены, на которых отвечает сервис. Caddy сам получит сертификаты
# Let's Encrypt и будет продлевать их без напоминаний.
# Требуется: порты 80 и 443 открыты снаружи, домены указывают на эту машину.
{
# Caddy очень подробно рассказывает про сертификаты и забивает консоль, из-за
# чего не видно сообщений самого сервиса. Уводим его вывод в файл: история
# сохраняется целиком, а на экране остаётся только talkscore-asr.
log {
output file logs/caddy.log {
roll_size 10MiB
roll_keep 5
}
format console
level INFO
}
}
asr.netranking.ru, asr.talkscore.ru {
reverse_proxy 127.0.0.1:8756 {
# Реальный адрес клиента - иначе список разрешённых адресов
# увидит вместо него localhost и пустит кого угодно.
header_up X-Forwarded-For {remote_host}
}
# Записи бывают большими, стандартного предела мало.
request_body {
max_size 500MB
}
}
"""
DOWNLOAD_BAT = """@echo off DOWNLOAD_BAT = """@echo off
chcp 65001 >nul chcp 65001 >nul
cd /d "%~dp0" cd /d "%~dp0"
+10 -5
View File
@@ -70,8 +70,13 @@ class TestDownloadScript:
class TestUpdateInStartup: class TestUpdateInStartup:
def test_start_bat_runs_updater_before_service(self, build_mod): def test_bat_only_delegates_to_launcher(self, build_mod):
"""Обновление должно применяться до запуска, иначе смысла в нём нет.""" """Логика запуска живёт в app, иначе её не доставить обновлением.
text = build_mod.START_BAT
assert "-m app.updater" in text Сам порядок «обновиться, потом запуститься» проверяется в
assert text.index("-m app.updater") < text.index("-m app.main") tests/test_launcher.py - здесь важно лишь, что .bat ничего не решает.
"""
for text in (build_mod.START_BAT, build_mod.HTTPS_BAT):
assert "-m app.launcher" in text
assert "-m app.main" not in text
assert "caddy" not in text.lower()
+98
View File
@@ -0,0 +1,98 @@
"""Проверки генерации Caddyfile и запуска."""
from app.launcher import MARK, ensure_caddyfile, render_caddyfile
class TestRender:
def test_domains_go_into_site_block(self):
text = render_caddyfile("a.example.com, b.example.com", 8756, 500)
assert "a.example.com, b.example.com {" in text
def test_extra_spaces_and_empty_entries_are_dropped(self):
text = render_caddyfile(" a.example.com ,, b.example.com ,", 8756, 500)
assert "a.example.com, b.example.com {" in text
def test_port_and_limit_come_from_settings(self):
text = render_caddyfile("a.example.com", 9000, 250)
assert "reverse_proxy 127.0.0.1:9000" in text
assert "max_size 250MB" in text
def test_real_client_address_is_forwarded(self):
"""Без этого заголовка список разрешённых адресов увидит localhost."""
text = render_caddyfile("a.example.com", 8756, 500)
assert "header_up X-Forwarded-For {remote_host}" in text
def test_caddy_log_goes_to_file(self):
"""Иначе Caddy забивает консоль сообщениями про сертификаты."""
text = render_caddyfile("a.example.com", 8756, 500)
assert "output file logs/caddy.log" in text
def test_generated_file_is_marked(self):
assert render_caddyfile("a.example.com", 8756, 500).startswith(MARK)
class TestEnsure:
def test_creates_file_when_missing(self, tmp_path):
content = render_caddyfile("a.example.com", 8756, 500)
written = ensure_caddyfile(tmp_path, content)
assert written == tmp_path / "Caddyfile"
assert written.read_text(encoding="utf-8") == content
def test_overwrites_own_previous_version(self, tmp_path):
ensure_caddyfile(tmp_path, render_caddyfile("old.example.com", 8756, 500))
fresh = render_caddyfile("new.example.com", 8756, 500)
ensure_caddyfile(tmp_path, fresh)
assert (tmp_path / "Caddyfile").read_text(encoding="utf-8") == fresh
assert not (tmp_path / "Caddyfile.new").exists()
def test_keeps_hand_written_file(self, tmp_path):
"""Чужой файл не трогаем: молча стереть настройку хуже, чем оставить старую."""
handmade = "site.example.com {\n\treverse_proxy 127.0.0.1:1234\n}\n"
(tmp_path / "Caddyfile").write_text(handmade, encoding="utf-8")
written = ensure_caddyfile(tmp_path, render_caddyfile("a.example.com", 8756, 500))
assert (tmp_path / "Caddyfile").read_text(encoding="utf-8") == handmade
assert written == tmp_path / "Caddyfile.new"
assert MARK in written.read_text(encoding="utf-8")
def test_no_rewrite_when_nothing_changed(self, tmp_path):
content = render_caddyfile("a.example.com", 8756, 500)
ensure_caddyfile(tmp_path, content)
before = (tmp_path / "Caddyfile").stat().st_mtime_ns
assert ensure_caddyfile(tmp_path, content) is None
assert (tmp_path / "Caddyfile").stat().st_mtime_ns == before
class TestStartupOrder:
def test_updater_runs_before_service(self, tmp_path, monkeypatch):
"""Обновиться нужно до запуска, иначе обновление бессмысленно."""
import app.launcher as launcher
calls: list[str] = []
class FakeSettings:
base_dir = tmp_path
port = 8756
max_upload_mb = 500
https_domains = ""
monkeypatch.setattr("app.config.load_settings", lambda: FakeSettings())
monkeypatch.setattr("app.updater.main", lambda: calls.append("updater"))
monkeypatch.setitem(
__import__("sys").modules, "app.main",
type("M", (), {"run": staticmethod(lambda: calls.append("run"))}))
assert launcher.main([]) == 0
assert calls == ["updater", "run"]
def test_https_without_domains_stops_with_explanation(self, tmp_path, monkeypatch):
"""Пустые домены - это ошибка настройки, а не повод падать внутри Caddy."""
import app.launcher as launcher
class FakeSettings:
base_dir = tmp_path
port = 8756
max_upload_mb = 500
https_domains = " "
monkeypatch.setattr("app.config.load_settings", lambda: FakeSettings())
monkeypatch.setattr("app.updater.main", lambda: None)
assert launcher.main(["https"]) == 1