Логика запуска и 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_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
@@ -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 / "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
@@ -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()
|
||||||
|
|||||||
@@ -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