mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
feat(web-svc-libretranslate): install only the needed model directions, through the cache
The image pulled both directions of every Argos pair because upstream's scripts/install_models.py matches a package when from_code AND to_code are both wanted, so a direction cannot be expressed through --load_only_lang_codes. prefetch_models.py now replaces that script instead of warming its cache, and takes --directions: `both` keeps the old set, `from_source` keeps only the pairs translating out of English, which halves the download for a deployment that never translates back. Replacing the script means carrying its guards. It refuses a language code no package offers and refuses an empty selection, and it hands MiniSBD the iso2model-converted codes rather than the raw ISO ones. Without those a typo would have built an image with fewer models, or none, and reported success. The build reaches the package cache over TLS, which needs the frontend CA in the image trust store. Rather than reimplement the installer in the Dockerfile, stage scripts/docker/cache/package-frontend/ca.sh into the build context and run it: it already picks the trust path per distribution, rebuilds the store and skips when no CA was staged. The trust-store path now exists in exactly one place. That step needs root. The base image sets USER libretranslate (uid 1032) and RUN inherits it, so update-ca-certificates fails with cannot create /etc/ssl/certs/ca-certificates.crt.new: Permission denied The CA layers therefore run as root and hand the user back before the model layers, so the models stay owned by the runtime user. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d1f1fde4c1
commit
942ca18b85
10 files changed
+323
-67
No files matched your search
@@ -2,11 +2,21 @@ ARG LIBRETRANSLATE_IMAGE
|
||||
ARG LIBRETRANSLATE_VERSION
|
||||
FROM ${LIBRETRANSLATE_IMAGE}:${LIBRETRANSLATE_VERSION}
|
||||
|
||||
ARG APP_USER=libretranslate
|
||||
|
||||
USER root
|
||||
COPY package-frontend-ca.crt /opt/package-frontend-ca.crt
|
||||
COPY package-frontend-ca.sh /usr/local/bin/package-frontend-ca.sh
|
||||
RUN /usr/local/bin/package-frontend-ca.sh
|
||||
USER ${APP_USER}
|
||||
|
||||
COPY prefetch_models.py scripts/prefetch_models.py
|
||||
|
||||
ARG LIBRETRANSLATE_LOAD_ONLY
|
||||
ARG LIBRETRANSLATE_DIRECTIONS
|
||||
RUN if [ -n "${LIBRETRANSLATE_LOAD_ONLY}" ]; then \
|
||||
set -- --load_only_lang_codes "${LIBRETRANSLATE_LOAD_ONLY}"; \
|
||||
fi; \
|
||||
./venv/bin/python scripts/prefetch_models.py "$@" \
|
||||
&& ./venv/bin/python scripts/install_models.py "$@"
|
||||
PYTHONPATH=. REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
|
||||
./venv/bin/python scripts/prefetch_models.py \
|
||||
--directions "${LIBRETRANSLATE_DIRECTIONS}" "$@"
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Fill the Argos download cache in parallel before install_models.py runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import zipfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import libretranslate.language
|
||||
from argostranslate import package
|
||||
|
||||
LANES = 8
|
||||
|
||||
|
||||
def wanted(codes: list[str]) -> list:
|
||||
"""Return the available packages install_models.py would install.
|
||||
|
||||
Args:
|
||||
codes: ISO 639-1 codes, empty for every package.
|
||||
"""
|
||||
package.update_package_index()
|
||||
available = package.get_available_packages()
|
||||
if not codes:
|
||||
return available
|
||||
keep = set(libretranslate.language.iso2model(codes))
|
||||
return [p for p in available if p.from_code in keep and p.to_code in keep]
|
||||
|
||||
|
||||
def fetch(available) -> Path:
|
||||
"""Download one package, dropping a truncated file the cache would keep.
|
||||
|
||||
``download()`` returns any existing file untouched, so an archive left
|
||||
half-written by a killed build is served to every later build as well.
|
||||
"""
|
||||
path = available.download()
|
||||
if not zipfile.is_zipfile(path):
|
||||
Path(path).unlink()
|
||||
raise RuntimeError(f"{path} is not a valid argosmodel archive")
|
||||
return path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--load_only_lang_codes", type=str, default="")
|
||||
parser.add_argument("--update", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
available = wanted([c for c in args.load_only_lang_codes.split(",") if c])
|
||||
print(f"Prefetching {len(available)} models over {LANES} lanes", flush=True)
|
||||
with ThreadPoolExecutor(LANES) as pool:
|
||||
for path in pool.map(fetch, available):
|
||||
print(f"cached {path}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Install the Argos models this deployment needs, in parallel.
|
||||
|
||||
Upstream's scripts/install_models.py keeps both directions of every pair
|
||||
because it matches a package when from_code AND to_code are both wanted, so a
|
||||
direction cannot be expressed through --load_only_lang_codes. This replaces it
|
||||
and therefore also pulls the MiniSBD models its caller would have installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import zipfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import libretranslate.language
|
||||
from argostranslate import package
|
||||
from minisbd import download_models
|
||||
|
||||
LANES = 8
|
||||
SOURCE = "en"
|
||||
DIRECTIONS = ("both", "from_source")
|
||||
|
||||
|
||||
def wanted(codes: list[str], directions: str) -> tuple[list, list[str]]:
|
||||
"""Return the packages this deployment installs and the codes MiniSBD needs.
|
||||
|
||||
Args:
|
||||
codes: ISO 639-1 codes, empty for every package.
|
||||
directions: ``both`` keeps every pair, ``from_source`` keeps only the
|
||||
ones translating out of the source language.
|
||||
|
||||
Returns:
|
||||
The packages to install, and the model codes ``download_models`` takes.
|
||||
|
||||
Raises:
|
||||
ValueError: a requested code no package offers, or an empty selection.
|
||||
"""
|
||||
package.update_package_index()
|
||||
available = package.get_available_packages()
|
||||
model_codes: list[str] = []
|
||||
if codes:
|
||||
model_codes = libretranslate.language.iso2model(codes)
|
||||
keep = set(model_codes)
|
||||
offered = {c for p in available for c in (p.from_code, p.to_code)}
|
||||
if keep - offered:
|
||||
raise ValueError(
|
||||
f"Unavailable language codes: {','.join(sorted(keep - offered))}"
|
||||
)
|
||||
available = [p for p in available if p.from_code in keep and p.to_code in keep]
|
||||
if directions == "from_source":
|
||||
available = [p for p in available if p.from_code == SOURCE]
|
||||
if not available:
|
||||
raise ValueError("no available package")
|
||||
return available, model_codes
|
||||
|
||||
|
||||
def fetch(available) -> Path:
|
||||
"""Download one package, dropping a truncated file the cache would keep.
|
||||
|
||||
``download()`` returns any existing file untouched, so an archive left
|
||||
half-written by a killed build is served to every later build as well.
|
||||
"""
|
||||
path = available.download()
|
||||
if not zipfile.is_zipfile(path):
|
||||
Path(path).unlink()
|
||||
raise RuntimeError(f"{path} is not a valid argosmodel archive")
|
||||
return path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--load_only_lang_codes", type=str, default="")
|
||||
parser.add_argument("--directions", choices=DIRECTIONS, default="both")
|
||||
args = parser.parse_args()
|
||||
|
||||
codes = [c for c in args.load_only_lang_codes.split(",") if c]
|
||||
available, model_codes = wanted(codes, args.directions)
|
||||
print(
|
||||
f"Installing {len(available)} models ({args.directions}) over {LANES} lanes",
|
||||
flush=True,
|
||||
)
|
||||
with ThreadPoolExecutor(LANES) as pool:
|
||||
paths = list(pool.map(fetch, available))
|
||||
for path in paths:
|
||||
package.install_from_path(path)
|
||||
download_models(model_codes or None, print)
|
||||
print(f"Installed {len(paths)} models", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -19,6 +19,7 @@ libretranslate:
|
||||
custom: true
|
||||
min_storage: 402MB
|
||||
load_only: []
|
||||
directions: both
|
||||
ports:
|
||||
local:
|
||||
http: 8045
|
||||
|
||||
@@ -11,12 +11,28 @@
|
||||
name: sys-stk-full
|
||||
|
||||
- name: "📦 Stage the model prefetch of {{ application_id }}"
|
||||
ansible.builtin.copy:
|
||||
src: prefetch_models.py
|
||||
stack_host_copy:
|
||||
src: python/prefetch_models.py
|
||||
dest: "{{ [lookup('container', application_id, 'directories.instance'), 'prefetch_models.py'] | path_join }}"
|
||||
mode: "0644"
|
||||
delegate_to: "{{ STACK_HOST }}"
|
||||
become: true
|
||||
notify:
|
||||
- compose-build
|
||||
- compose-up
|
||||
|
||||
- name: "🔐 Stage the package-cache CA into the build context of {{ application_id }}"
|
||||
stack_host_copy:
|
||||
content: "{{ lookup('file', LIBRETRANSLATE_CACHE_CA_FILE) if LIBRETRANSLATE_CACHE_CA_FILE is file else '' }}"
|
||||
dest: "{{ [lookup('container', application_id, 'directories.instance'), 'package-frontend-ca.crt'] | path_join }}"
|
||||
mode: "0644"
|
||||
notify:
|
||||
- compose-build
|
||||
- compose-up
|
||||
|
||||
- name: "🔐 Stage the package-cache CA installer of {{ application_id }}"
|
||||
stack_host_copy:
|
||||
src: "{{ [playbook_dir, LIBRETRANSLATE_CACHE_CA_INSTALLER] | path_join }}"
|
||||
dest: "{{ [lookup('container', application_id, 'directories.instance'), 'package-frontend-ca.sh'] | path_join }}"
|
||||
mode: "0755"
|
||||
notify:
|
||||
- compose-build
|
||||
- compose-up
|
||||
|
||||
@@ -5,11 +5,16 @@
|
||||
{{ lookup('template', 'roles/sys-svc-container/templates/build.yml.j2') | indent(4) }}
|
||||
cache_from:
|
||||
- "{{ lookup('image', application_id, 'libretranslate') }}"
|
||||
{% if LIBRETRANSLATE_CACHE_FRONTEND_IP | length > 0 %}
|
||||
extra_hosts:
|
||||
- "argos-net.com:{{ LIBRETRANSLATE_CACHE_FRONTEND_IP }}"
|
||||
{% endif %}
|
||||
args:
|
||||
BUILDKIT_INLINE_CACHE: "1"
|
||||
LIBRETRANSLATE_IMAGE: "{{ LIBRETRANSLATE_IMAGE }}"
|
||||
LIBRETRANSLATE_VERSION: "{{ LIBRETRANSLATE_VERSION }}"
|
||||
LIBRETRANSLATE_LOAD_ONLY: "{{ LIBRETRANSLATE_LOAD_ONLY }}"
|
||||
LIBRETRANSLATE_DIRECTIONS: "{{ LIBRETRANSLATE_DIRECTIONS }}"
|
||||
{{ lookup('container_image', application_id, 'libretranslate') }}
|
||||
{{ lookup('compose_only', 'container_name', LIBRETRANSLATE_CONTAINER_ADDRESS) }}
|
||||
volumes: []
|
||||
|
||||
@@ -8,6 +8,7 @@ LIBRETRANSLATE_IMAGE: "{{ lookup('config', application_id, 'services.libretransl
|
||||
LIBRETRANSLATE_GPU: "{{ lookup('applications') | resource_filter(application_id, 'gpu', 'libretranslate', false) }}"
|
||||
LIBRETRANSLATE_VERSION: "{{ lookup('config', application_id, 'services.libretranslate.version') ~ ('-cuda' if LIBRETRANSLATE_GPU | bool else '') }}"
|
||||
LIBRETRANSLATE_LOAD_ONLY: "{{ lookup('config', application_id, 'services.libretranslate.load_only') | join(',') }}"
|
||||
LIBRETRANSLATE_DIRECTIONS: "{{ lookup('config', application_id, 'services.libretranslate.directions') }}"
|
||||
LIBRETRANSLATE_THREADS: "{{ [(lookup('config', application_id, 'services.libretranslate.cpus') | float) | round(0, 'ceil') | int, 1] | max }}"
|
||||
LIBRETRANSLATE_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, 'libretranslate') }}"
|
||||
LIBRETRANSLATE_MCP_ENABLED: "{{ lookup('config', application_id, 'services.libretranslatemcp.enabled') | bool }}"
|
||||
@@ -28,3 +29,7 @@ LIBRETRANSLATE_MCP_CONTRACT:
|
||||
limits: "{{ lookup('config', application_id, 'mcp.limits') }}"
|
||||
schema_sha256: "{{ lookup('config', application_id, 'mcp.tools.schema_sha256') }}"
|
||||
LIBRETRANSLATE_MCP_UPSTREAM_KEY: ""
|
||||
|
||||
LIBRETRANSLATE_CACHE_CA_FILE: /opt/package-frontend-ca.crt
|
||||
LIBRETRANSLATE_CACHE_CA_INSTALLER: scripts/docker/cache/package-frontend/ca.sh
|
||||
LIBRETRANSLATE_CACHE_FRONTEND_IP: "{{ lookup('env', 'INFINITO_CACHE_PACKAGE_FRONTEND_IP') }}"
|
||||
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT: Path = Path(__file__).resolve().parents[6]
|
||||
@@ -0,0 +1,183 @@
|
||||
"""The model prefetch runs inside the LibreTranslate image, whose argostranslate
|
||||
and libretranslate packages are absent here, so both are stubbed before the
|
||||
module is executed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from types import ModuleType
|
||||
from unittest import mock
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
SCRIPT = (
|
||||
PROJECT_ROOT
|
||||
/ "roles"
|
||||
/ "web-svc-libretranslate"
|
||||
/ "files"
|
||||
/ "python"
|
||||
/ "prefetch_models.py"
|
||||
)
|
||||
|
||||
|
||||
class FakePackage:
|
||||
def __init__(self, from_code: str, to_code: str, path: Path | None = None) -> None:
|
||||
self.from_code = from_code
|
||||
self.to_code = to_code
|
||||
self._path = path
|
||||
|
||||
def download(self) -> Path:
|
||||
return self._path
|
||||
|
||||
|
||||
def _load() -> ModuleType:
|
||||
package = types.ModuleType("argostranslate.package")
|
||||
package.update_package_index = lambda: None
|
||||
package.get_available_packages = list
|
||||
argos = types.ModuleType("argostranslate")
|
||||
argos.package = package
|
||||
|
||||
language = types.ModuleType("libretranslate.language")
|
||||
language.iso2model = list
|
||||
libretranslate = types.ModuleType("libretranslate")
|
||||
libretranslate.language = language
|
||||
|
||||
minisbd = types.ModuleType("minisbd")
|
||||
minisbd.download_models = lambda *args: None
|
||||
|
||||
modules = {
|
||||
"argostranslate": argos,
|
||||
"argostranslate.package": package,
|
||||
"libretranslate": libretranslate,
|
||||
"libretranslate.language": language,
|
||||
"minisbd": minisbd,
|
||||
}
|
||||
with mock.patch.dict(sys.modules, modules):
|
||||
spec = importlib.util.spec_from_file_location("prefetch_models", SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class TestWanted(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.module = _load()
|
||||
self.available = [
|
||||
FakePackage("en", "de"),
|
||||
FakePackage("de", "en"),
|
||||
FakePackage("en", "fr"),
|
||||
FakePackage("fr", "ru"),
|
||||
]
|
||||
self.module.package.get_available_packages = lambda: self.available
|
||||
|
||||
def _pairs(self, codes: list[str], directions: str) -> list[tuple[str, str]]:
|
||||
kept, _ = self.module.wanted(codes, directions)
|
||||
return [(p.from_code, p.to_code) for p in kept]
|
||||
|
||||
def test_no_codes_keeps_every_package(self) -> None:
|
||||
kept, model_codes = self.module.wanted([], "both")
|
||||
|
||||
self.assertEqual(kept, self.available)
|
||||
self.assertEqual(model_codes, [])
|
||||
|
||||
def test_a_pair_survives_only_when_both_sides_are_wanted(self) -> None:
|
||||
self.assertEqual(
|
||||
self._pairs(["en", "de"], "both"), [("en", "de"), ("de", "en")]
|
||||
)
|
||||
|
||||
def test_from_source_drops_the_return_direction(self) -> None:
|
||||
self.assertEqual(self._pairs([], "from_source"), [("en", "de"), ("en", "fr")])
|
||||
|
||||
def test_from_source_composes_with_a_code_filter(self) -> None:
|
||||
self.assertEqual(self._pairs(["en", "de"], "from_source"), [("en", "de")])
|
||||
|
||||
def test_a_code_no_package_offers_is_refused(self) -> None:
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
self.module.wanted(["en", "xx"], "both")
|
||||
|
||||
self.assertIn("xx", str(caught.exception))
|
||||
|
||||
def test_an_empty_selection_is_refused(self) -> None:
|
||||
self.available = [FakePackage("de", "en")]
|
||||
self.module.package.get_available_packages = lambda: self.available
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.module.wanted([], "from_source")
|
||||
|
||||
|
||||
class TestMain(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.module = _load()
|
||||
self._tmp = TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.root = Path(self._tmp.name)
|
||||
archive = self.root / "en_zh.argosmodel"
|
||||
with zipfile.ZipFile(archive, "w") as handle:
|
||||
handle.writestr("metadata.json", "{}")
|
||||
|
||||
self.module.package.get_available_packages = lambda: [
|
||||
FakePackage("en", "zh-Hans", archive)
|
||||
]
|
||||
self.module.package.install_from_path = lambda path: None
|
||||
self.module.libretranslate.language.iso2model = lambda codes: [
|
||||
"zh-Hans" if code == "zh" else code for code in codes
|
||||
]
|
||||
self.minisbd_got: list = []
|
||||
self.module.download_models = lambda codes, _: self.minisbd_got.append(codes)
|
||||
|
||||
def test_minisbd_gets_the_model_codes_not_the_iso_codes(self) -> None:
|
||||
with mock.patch.object(
|
||||
sys, "argv", ["prefetch_models.py", "--load_only_lang_codes", "en,zh"]
|
||||
):
|
||||
self.assertEqual(self.module.main(), 0)
|
||||
|
||||
self.assertEqual(self.minisbd_got, [["en", "zh-Hans"]])
|
||||
|
||||
def test_minisbd_gets_none_when_no_codes_were_requested(self) -> None:
|
||||
with mock.patch.object(sys, "argv", ["prefetch_models.py"]):
|
||||
self.assertEqual(self.module.main(), 0)
|
||||
|
||||
self.assertEqual(self.minisbd_got, [None])
|
||||
|
||||
|
||||
class TestFetch(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.module = _load()
|
||||
self._tmp = TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.root = Path(self._tmp.name)
|
||||
|
||||
def _archive(self, name: str, *, valid: bool) -> Path:
|
||||
path = self.root / name
|
||||
if valid:
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr("metadata.json", "{}")
|
||||
else:
|
||||
path.write_bytes(b"half a download")
|
||||
return path
|
||||
|
||||
def test_a_complete_archive_is_kept(self) -> None:
|
||||
path = self._archive("good.argosmodel", valid=True)
|
||||
|
||||
self.assertEqual(self.module.fetch(FakePackage("en", "de", path)), path)
|
||||
self.assertTrue(path.exists())
|
||||
|
||||
def test_a_truncated_archive_is_dropped_so_the_cache_refetches_it(self) -> None:
|
||||
path = self._archive("bad.argosmodel", valid=False)
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.module.fetch(FakePackage("en", "de", path))
|
||||
|
||||
self.assertFalse(path.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user