From febec7f1242d8c26bdb0e6230f15289d73dec81c Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Sun, 16 Aug 2026 17:23:47 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9B=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0=20=D0=B8=20Caddyfile=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B5=D1=85=D0=B0=D0=BB=D0=B8=20=D0=B2?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D1=80=D1=8C=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Файлы вне папки app обновление не доставляло: чтобы изменить порядок запуска или конфигурацию Caddy, требовалась переустановка. Теперь .bat - заглушка из трёх строк, вызывающая app/launcher.py, а Caddyfile создаётся из настроек [https] в config.toml. Отредактированный вручную Caddyfile не затирается: новая версия откладывается рядом как Caddyfile.new. Co-Authored-By: Claude Opus 5 (1M context) --- app/config.py | 9 ++- app/launcher.py | 131 ++++++++++++++++++++++++++++++++++++++ build/make_windows_zip.py | 45 ++----------- tests/test_build.py | 15 +++-- tests/test_launcher.py | 98 ++++++++++++++++++++++++++++ 5 files changed, 251 insertions(+), 47 deletions(-) create mode 100644 app/launcher.py create mode 100644 tests/test_launcher.py diff --git a/app/config.py b/app/config.py index e5c1f7d..4bbc08e 100644 --- a/app/config.py +++ b/app/config.py @@ -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")), diff --git a/app/launcher.py b/app/launcher.py new file mode 100644 index 0000000..d8991a7 --- /dev/null +++ b/app/launcher.py @@ -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()) diff --git a/build/make_windows_zip.py b/build/make_windows_zip.py index b677c11..7f6a174 100644 --- a/build/make_windows_zip.py +++ b/build/make_windows_zip.py @@ -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" diff --git a/tests/test_build.py b/tests/test_build.py index 5644b08..dc6731e 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -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() diff --git a/tests/test_launcher.py b/tests/test_launcher.py new file mode 100644 index 0000000..90c38c2 --- /dev/null +++ b/tests/test_launcher.py @@ -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