feat(i18n): deploy libretranslate into the i18n runner before translating

server() now resolves a translation endpoint in three steps: use the running
service, rebuild when the host has a GPU but no accelerated deploy left its
stamp, otherwise deploy. A plain run therefore costs nothing, and the rebuild
fires once, on the move from a CPU-built container to an accelerated one.

deploy() brings the runner up, waits for State.Running and deploys the role
inside it. The exec steps address `docker compose exec i18n` directly:
cli.administration.deploy.development exec hardcodes the `infinito` service
(compose.py:261), so every deploy through it silently ran in the main
container, where mailu already holds port 25 and postfix cannot start.
check=True stops a failing step instead of carrying on against the wrong
target.

The deploy router writes its pid to build/deploy.pid, which survives the exec
into the subscripts, and deploying() refuses a translation while that process
lives. The running deploy is left untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-24 01:53:19 +02:00
1 parent bb1dbaa022
commit a0a24e2c0e
5 files changed
+286 -7

No files matched your search

@@ -1,9 +1,50 @@
import os
import subprocess
import unittest
import unittest.mock as mock
import urllib.error
from pathlib import Path
from tempfile import TemporaryDirectory
from utils.i18n.libretranslate import LibreTranslate, accelerated
from utils.i18n.libretranslate import (
LibreTranslate,
accelerated,
deploying,
server,
)
class TestParallelDeployIsRefused(unittest.TestCase):
def test_a_running_deploy_stops_the_translation(self) -> None:
with (
mock.patch("utils.i18n.libretranslate.deploying", return_value=True),
mock.patch("utils.i18n.libretranslate.deploy") as started,
self.assertRaises(RuntimeError),
server(Path("/repo"), ["de"], 1),
):
pass
started.assert_not_called()
def test_a_live_router_pid_reports_a_deploy(self) -> None:
self.assertTrue(deploying(self._root_holding(os.getpid())))
def test_a_stale_pid_file_reports_no_deploy(self) -> None:
root = self._root_holding(4242)
with mock.patch("os.kill", side_effect=ProcessLookupError):
self.assertFalse(deploying(root))
def test_a_missing_pid_file_reports_no_deploy(self) -> None:
self.assertFalse(deploying(Path(self._tmp.name)))
def _root_holding(self, pid: int) -> Path:
root = Path(self._tmp.name)
(root / "build").mkdir(exist_ok=True)
(root / "build" / "deploy.pid").write_text(str(pid))
return root
def setUp(self) -> None:
self._tmp = TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
class TestTranslateFailures(unittest.TestCase):