Логика запуска и 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:
co-authored by
Claude Opus 5
parent
cd3acafa8a
commit
febec7f124
+6
-3
@@ -102,6 +102,7 @@ class Settings:
|
||||
update_server: str = "https://git.netranking.ru"
|
||||
update_repo: str = "bryzgalov/talkscore-asr"
|
||||
update_token: str = ""
|
||||
https_domains: str = ""
|
||||
base_dir: Path = field(default_factory=lambda: BASE_DIR)
|
||||
|
||||
@property
|
||||
@@ -157,15 +158,16 @@ def load_settings(config_path: Path | None = None) -> Settings:
|
||||
f"не удалось прочитать {path}\n"
|
||||
f" {exc}\n\n"
|
||||
" Скорее всего, один и тот же раздел объявлен дважды.\n"
|
||||
" Каждый раздел ([server], [security], [processing], [webhook], [update])\n"
|
||||
" должен встречаться в файле ровно один раз, а все его настройки -\n"
|
||||
" идти внутри него одним блоком."
|
||||
" Каждый раздел ([server], [security], [processing], [webhook],\n"
|
||||
" [update], [https]) должен встречаться в файле ровно один раз,\n"
|
||||
" а все его настройки - идти внутри него одним блоком."
|
||||
) from exc
|
||||
server = data.get("server", {})
|
||||
security = data.get("security", {})
|
||||
proc = data.get("processing", {})
|
||||
upd = data.get("update", {})
|
||||
hook = data.get("webhook", {})
|
||||
https = data.get("https", {})
|
||||
|
||||
return Settings(
|
||||
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)),
|
||||
webhook_url=str(hook.get("url", "")),
|
||||
webhook_secret=str(hook.get("secret", "")),
|
||||
https_domains=str(https.get("domains", "")),
|
||||
update_enabled=bool(upd.get("enabled", False)),
|
||||
update_server=str(upd.get("server", "https://git.netranking.ru")),
|
||||
update_repo=str(upd.get("repo", "bryzgalov/talkscore-asr")),
|
||||
|
||||
+131
@@ -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())
|
||||
@@ -107,7 +107,6 @@ def add_app(stage: Path) -> None:
|
||||
write_bat(stage / "start.bat", START_BAT)
|
||||
write_bat(stage / "download_models.bat", DOWNLOAD_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(
|
||||
(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 дальше пишет в той же кодировке.
|
||||
# Без этой пары русский текст в выводе превращается в мусор.
|
||||
# .bat - трёхстрочные заглушки: вся логика запуска живёт в app/launcher.py,
|
||||
# который обновление доставляет штатно. Раньше логика была здесь, и чтобы
|
||||
# изменить порядок запуска, требовалась переустановка.
|
||||
# chcp 65001 переводит консоль в UTF-8, иначе русский текст станет мусором.
|
||||
START_BAT = """@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0"
|
||||
set "TALKSCORE_ASR_HOME=%~dp0"
|
||||
set "PYTHONPATH=%~dp0"
|
||||
"%~dp0python\\python.exe" -m app.updater
|
||||
"%~dp0python\\python.exe" -m app.main
|
||||
"%~dp0python\\python.exe" -m app.launcher
|
||||
pause
|
||||
"""
|
||||
|
||||
# Caddy слушает 443 и проксирует на сервис, который при этом сидит на localhost.
|
||||
HTTPS_BAT = """@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0"
|
||||
set "TALKSCORE_ASR_HOME=%~dp0"
|
||||
set "PYTHONPATH=%~dp0"
|
||||
start "caddy" /min "%~dp0bin\\caddy.exe" run --config "%~dp0Caddyfile"
|
||||
"%~dp0python\\python.exe" -m app.updater
|
||||
"%~dp0python\\python.exe" -m app.main
|
||||
"%~dp0python\\python.exe" -m app.launcher https
|
||||
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
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0"
|
||||
|
||||
+10
-5
@@ -70,8 +70,13 @@ class TestDownloadScript:
|
||||
|
||||
|
||||
class TestUpdateInStartup:
|
||||
def test_start_bat_runs_updater_before_service(self, build_mod):
|
||||
"""Обновление должно применяться до запуска, иначе смысла в нём нет."""
|
||||
text = build_mod.START_BAT
|
||||
assert "-m app.updater" in text
|
||||
assert text.index("-m app.updater") < text.index("-m app.main")
|
||||
def test_bat_only_delegates_to_launcher(self, build_mod):
|
||||
"""Логика запуска живёт в app, иначе её не доставить обновлением.
|
||||
|
||||
Сам порядок «обновиться, потом запуститься» проверяется в
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user