diff --git a/docs/ramalama-rag.1.md b/docs/ramalama-rag.1.md index b4376817e..9137062c9 100644 --- a/docs/ramalama-rag.1.md +++ b/docs/ramalama-rag.1.md @@ -25,8 +25,11 @@ The pipeline: 5. Embeddings are stored in a Qdrant on-disk collection. 6. The Qdrant database is packaged into a `FROM scratch` OCI image. -Two containers work together: a llama.cpp container serves the AI models, +Multiple containers work together: llama.cpp containers serve the AI models, and a lightweight RAG container runs the document processing pipeline. +RamaLama places them on a private container network and they reach each other +by container name; the model server ports are not exposed on the host network. +The network is created when the command starts and removed when it finishes. NOTE: this command requires a container engine (podman or docker). @@ -82,6 +85,14 @@ Show this help message and exit OCI container image to use for the llama.cpp inference servers. Defaults to the accelerator-appropriate ramalama image. +#### **--network**, **--net**=*network* +Join the pipeline containers to an existing container network instead of +creating a temporary private one. Use this to reuse a pre-existing network; +the containers still reach each other by name, so the network must provide +name-based DNS (any user-defined podman/docker network does). A network +supplied this way is left in place when the command finishes; only the +temporary network RamaLama creates by default is removed. + #### **--ngl**=*value* Number of layers to store in VRAM: a number, `auto`, or `all`. When omitted, llama-server defaults to `auto`. @@ -90,6 +101,12 @@ When omitted, llama-server defaults to `auto`. OCI container image for the RAG processing container. Defaults to the accelerator-appropriate ramalama-rag image. +#### **--skip-cleanup** +Leave the llama.cpp servers and their private network running after the command +finishes instead of tearing them down. Useful for debugging a failed run: +inspect the servers with `podman logs ` and remove them manually +afterwards (the command prints the exact cleanup command). + #### **--threads**, **-t**=*integer* Number of CPU threads to use for llama.cpp inference. Defaults to half the available cores. diff --git a/ramalama/cli.py b/ramalama/cli.py index 2331d87c6..1758f30d3 100644 --- a/ramalama/cli.py +++ b/ramalama/cli.py @@ -1090,10 +1090,9 @@ def _rag_args(args): rag_args = copy.copy(args) rag_args.MODEL = args.rag rag_args.image = args.rag_image - if args.engine == "podman": - rag_args.model_host = "host.containers.internal" - else: - rag_args.model_host = f"host.{args.engine}.internal" + # model_host is set later by _setup_rag_network to the model server's container + # name once the shared private network is created. + rag_args.model_host = None # If --name was specified, use it for the RAG proxy args.name = None # If --port was specified, use it for the RAG proxy, and diff --git a/ramalama/common.py b/ramalama/common.py index 7b7aea4dd..2987d6cb1 100644 --- a/ramalama/common.py +++ b/ramalama/common.py @@ -351,8 +351,8 @@ def verify_checksum(filename: str) -> bool: return sha256_hash.hexdigest() == expected_checksum -def genname(): - return "ramalama-" + "".join(random.choices(string.ascii_letters + string.digits, k=10)) +def genname(prefix="ramalama-"): + return prefix + "".join(random.choices(string.ascii_letters + string.digits, k=10)) @lru_cache diff --git a/ramalama/engine.py b/ramalama/engine.py index ec683bdbf..90aa125eb 100644 --- a/ramalama/engine.py +++ b/ramalama/engine.py @@ -15,7 +15,7 @@ # Live reference for checking global vars import ramalama.common from ramalama.arg_types import BaseEngineArgsType -from ramalama.common import check_nvidia, engine_cmd, exec_cmd, get_accel_env_vars, host_path, perror, run_cmd +from ramalama.common import check_nvidia, engine_cmd, exec_cmd, genname, get_accel_env_vars, host_path, perror, run_cmd from ramalama.compat import NamedTemporaryFile from ramalama.config import ActiveConfig from ramalama.host_utils import ( @@ -479,6 +479,50 @@ def stop_container(args, name: str, remove: bool = False): raise +def create_network(args) -> str: + """Create a private, user-defined container network and return its name. + + Helper containers and their consumer join this network so they can reach + each other by container name (podman/docker provide name-based DNS on + user-defined networks), with nothing published to the network. On a dry + run the network is not created, but a name is still returned so the + generated command reflects it. + """ + conman = str(args.engine) if args.engine is not None else None + if conman == "" or conman is None: + raise ValueError("no container manager (Podman, Docker) found") + + name = genname("ramalama-net-") + if not getattr(args, "dryrun", False): + run_cmd([*engine_cmd(conman), "network", "create", name]) + return name + + +def remove_network(args, name: str) -> None: + """Remove a network created by create_network. Best effort; errors ignored. + + On a dry run nothing was created, so nothing is removed. + """ + if not name: + return + conman = str(args.engine) if args.engine is not None else None + if conman == "" or conman is None: + return + if getattr(args, "dryrun", False): + return + + # `podman network rm -f` disconnects any lingering containers; docker has no + # such flag but the attached containers are removed before this is called. + conman_args = [*engine_cmd(conman), "network", "rm"] + if conman == "podman": + conman_args += ["-f"] + conman_args += [name] + try: + run_cmd(conman_args, ignore_all=True) + except Exception as e: # Cleanup is best effort. + logger.debug(f"Failed to remove network {name}: {e}") + + def add_labels(args, add_label: Callable[[str], None]): label_map = { "MODEL": "ai.ramalama.model", diff --git a/ramalama/plugins/runtimes/inference/llama_cpp.py b/ramalama/plugins/runtimes/inference/llama_cpp.py index 75d43566e..97a8fefe5 100644 --- a/ramalama/plugins/runtimes/inference/llama_cpp.py +++ b/ramalama/plugins/runtimes/inference/llama_cpp.py @@ -9,7 +9,7 @@ import subprocess import sys import tempfile -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from http.client import HTTPConnection @@ -655,13 +655,7 @@ def _run_rag(self, args: argparse.Namespace, model: Any) -> None: args = _rag_args(args) model = RagTransport(model, assemble_command(args.model_args), args) model.ensure_model_exists(args) - embed_serve_args, embed_proc = self._start_rag_embedding_server(args) - try: - model.run(args, assemble_command(args)) - finally: - from ramalama.plugins.runtimes.inference.rag.handler import _cleanup_servers - - _cleanup_servers(args, [embed_serve_args], [embed_proc]) + self._serve_rag_pipeline(args, lambda: model.run(args, assemble_command(args))) def _serve_rag(self, args: argparse.Namespace, model: Any) -> None: if not args.container: @@ -669,13 +663,28 @@ def _serve_rag(self, args: argparse.Namespace, model: Any) -> None: args = _rag_args(args) model = RagTransport(model, assemble_command(args.model_args), args) model.ensure_model_exists(args) - embed_serve_args, embed_proc = self._start_rag_embedding_server(args) + self._serve_rag_pipeline(args, lambda: model.serve(args, assemble_command(args))) + + def _serve_rag_pipeline(self, args: argparse.Namespace, dispatch: Callable[[], None]) -> None: + """Start the RAG helper servers on a private network, then run ``dispatch``. + + The model server, embedding server, and RAG proxy all join a shared + private network so they reach each other by container name without + publishing the helper servers to the host. + """ + from ramalama.engine import remove_network + from ramalama.plugins.runtimes.inference.rag.handler import _cleanup_servers, _setup_rag_network + + network_created = _setup_rag_network(args) try: - model.serve(args, assemble_command(args)) + embed_serve_args, embed_proc = self._start_rag_embedding_server(args) + try: + dispatch() + finally: + _cleanup_servers(args, [embed_serve_args], [embed_proc]) finally: - from ramalama.plugins.runtimes.inference.rag.handler import _cleanup_servers - - _cleanup_servers(args, [embed_serve_args], [embed_proc]) + if network_created: + remove_network(args, args.network) def _start_rag_embedding_server(self, args): """Start a llama.cpp embedding server for RAG inference and set embed_url on args.""" @@ -697,17 +706,14 @@ def _start_rag_embedding_server(self, args): embed_transport.ensure_model_exists(embed_serve_args) embed_cmd = assemble_command(embed_serve_args) - embed_proc = embed_transport.serve_nonblocking(embed_serve_args, embed_cmd, expose_to_containers=True) + embed_proc = embed_transport.serve_nonblocking(embed_serve_args, embed_cmd) if not args.dryrun: _wait_for_server(self, embed_serve_args, embed_transport.model_alias) - if args.model_args.engine == "podman": - embed_host = "host.containers.internal" - else: - embed_host = f"host.{args.model_args.engine}.internal" - - args.embed_url = f"http://{embed_host}:{embed_port}" + # Reach the embedding server by its container name over the shared private + # network (serve_nonblocking assigned the name above). + args.embed_url = f"http://{embed_serve_args.name}:{embed_port}" return embed_serve_args, embed_proc def _register_run_subcommand(self, subparsers: "argparse._SubParsersAction") -> "argparse.ArgumentParser": diff --git a/ramalama/plugins/runtimes/inference/rag/cli.py b/ramalama/plugins/runtimes/inference/rag/cli.py index bcaf3b0d4..76c5052fc 100644 --- a/ramalama/plugins/runtimes/inference/rag/cli.py +++ b/ramalama/plugins/runtimes/inference/rag/cli.py @@ -103,6 +103,24 @@ def register_rag_subcommand(plugin, subparsers): help=f"number of CPU threads to use (default: {rt_config.threads})", completer=suppressCompleter, ) + parser.add_argument( + "--network", + "--net", + dest="network", + type=str, + default=None, + help="join the pipeline containers to an existing container network " + "instead of creating a temporary private one; a user-supplied network " + "is left in place on exit", + completer=suppressCompleter, + ) + parser.add_argument( + "--skip-cleanup", + dest="skip_cleanup", + action="store_true", + help="leave the llama.cpp servers and their network running after the " + "command for debugging (inspect with `podman logs`)", + ) parser.set_defaults(func=lambda args: _rag_dispatch(plugin, args)) diff --git a/ramalama/plugins/runtimes/inference/rag/handler.py b/ramalama/plugins/runtimes/inference/rag/handler.py index 1d2f59132..ad6ef961e 100644 --- a/ramalama/plugins/runtimes/inference/rag/handler.py +++ b/ramalama/plugins/runtimes/inference/rag/handler.py @@ -10,8 +10,9 @@ from http.client import HTTPConnection, HTTPException from typing import Optional -from ramalama.common import ensure_image, perror, set_accel_env_vars +from ramalama.common import ensure_image, genname, perror, set_accel_env_vars from ramalama.config import ActiveConfig +from ramalama.engine import create_network, remove_network from ramalama.plugins.interface import RuntimePlugin from ramalama.plugins.loader import assemble_command from ramalama.transports.api import APITransport @@ -50,63 +51,71 @@ def rag_handler(plugin: RuntimePlugin, args: argparse.Namespace) -> None: caption_port = compute_serving_port(args, quiet=True, exclude=allocated_ports) allocated_ports.append(caption_port) - # Build serve args for the VLM and embedding servers - vlm_ctx_size = getattr(args, "ctx_size", 8192) - embed_ctx_size = getattr(args, "embed_ctx_size", None) - docling_serve_args = _build_serve_args( - args, docling_model, docling_port, runtime_args=["--special"], ctx_size=vlm_ctx_size - ) - embed_serve_args = _build_serve_args( - args, - embedding_model, - embed_port, - runtime_args=["--embedding"], - ctx_size=embed_ctx_size, - cache_reuse=0, - ) - - caption_serve_args = None - if caption_model and caption_port: - caption_serve_args = _build_serve_args(args, caption_model, caption_port, ctx_size=vlm_ctx_size, cache_reuse=0) - - # Pull models - docling_transport = New(docling_model, docling_serve_args) - docling_transport.ensure_model_exists(docling_serve_args) - embed_transport = New(embedding_model, embed_serve_args) - embed_transport.ensure_model_exists(embed_serve_args) - - caption_transport = None - if caption_model and caption_serve_args: - caption_transport = New(caption_model, caption_serve_args) - if isinstance(caption_transport, APITransport): - raise ValueError(f"caption model {caption_model} resolved to an API transport, which cannot serve locally") - caption_transport.ensure_model_exists(caption_serve_args) - - # Start llama.cpp servers - docling_cmd = assemble_command(docling_serve_args) - embed_cmd = assemble_command(embed_serve_args) + # Put the llama.cpp servers and the doc2rag container on a shared network so + # doc2rag can reach them by container name without publishing to the host. + # Honor a user-supplied network (and leave it in place); otherwise create a + # private one for this run and remove it when done. + network_created = False + if not getattr(args, "network", None): + args.network = create_network(args) + network_created = True docling_proc = None embed_proc = None caption_proc = None - all_serve_args = [docling_serve_args, embed_serve_args] + all_serve_args = [] try: - perror("Starting VLM server...") - docling_proc = docling_transport.serve_nonblocking( # type: ignore[union-attr] - docling_serve_args, docling_cmd, expose_to_containers=True + # Build serve args for the VLM and embedding servers + vlm_ctx_size = getattr(args, "ctx_size", 8192) + embed_ctx_size = getattr(args, "embed_ctx_size", None) + docling_serve_args = _build_serve_args( + args, docling_model, docling_port, runtime_args=["--special"], ctx_size=vlm_ctx_size ) - perror("Starting embedding server...") - embed_proc = embed_transport.serve_nonblocking( # type: ignore[union-attr] - embed_serve_args, embed_cmd, expose_to_containers=True + embed_serve_args = _build_serve_args( + args, + embedding_model, + embed_port, + runtime_args=["--embedding"], + ctx_size=embed_ctx_size, + cache_reuse=0, ) + all_serve_args = [docling_serve_args, embed_serve_args] + + caption_serve_args = None + if caption_model and caption_port: + caption_serve_args = _build_serve_args( + args, caption_model, caption_port, ctx_size=vlm_ctx_size, cache_reuse=0 + ) + all_serve_args.append(caption_serve_args) + + # Pull models + docling_transport = New(docling_model, docling_serve_args) + docling_transport.ensure_model_exists(docling_serve_args) + embed_transport = New(embedding_model, embed_serve_args) + embed_transport.ensure_model_exists(embed_serve_args) + + caption_transport = None + if caption_model and caption_serve_args: + caption_transport = New(caption_model, caption_serve_args) + if isinstance(caption_transport, APITransport): + raise ValueError( + f"caption model {caption_model} resolved to an API transport, which cannot serve locally" + ) + caption_transport.ensure_model_exists(caption_serve_args) + + # Start llama.cpp servers + docling_cmd = assemble_command(docling_serve_args) + embed_cmd = assemble_command(embed_serve_args) + + perror("Starting VLM server...") + docling_proc = docling_transport.serve_nonblocking(docling_serve_args, docling_cmd) # type: ignore[union-attr] + perror("Starting embedding server...") + embed_proc = embed_transport.serve_nonblocking(embed_serve_args, embed_cmd) # type: ignore[union-attr] if caption_transport and caption_serve_args: caption_cmd = assemble_command(caption_serve_args) perror("Starting image captioning server...") - caption_proc = caption_transport.serve_nonblocking( # type: ignore[union-attr] - caption_serve_args, caption_cmd, expose_to_containers=True - ) - all_serve_args.append(caption_serve_args) + caption_proc = caption_transport.serve_nonblocking(caption_serve_args, caption_cmd) # type: ignore[union-attr] if not args.dryrun: _wait_for_server(plugin, docling_serve_args, docling_transport.model_alias) @@ -117,15 +126,14 @@ def rag_handler(plugin: RuntimePlugin, args: argparse.Namespace) -> None: _wait_for_server(plugin, caption_serve_args, caption_transport.model_alias) perror("Caption server is ready.") - # Determine the host URL the RAG container will use to reach llama.cpp - if args.engine == "podman": - llm_host = "host.containers.internal" - else: - llm_host = f"host.{args.engine}.internal" - - api_url = f"http://{llm_host}:{docling_port}" - embed_url = f"http://{llm_host}:{embed_port}" - caption_url = f"http://{llm_host}:{caption_port}" if caption_port else None + # The doc2rag container reaches each llama.cpp server by its container + # name over the shared private network (serve_nonblocking assigned the + # names above). + api_url = f"http://{docling_serve_args.name}:{docling_port}" + embed_url = f"http://{embed_serve_args.name}:{embed_port}" + caption_url = None + if caption_serve_args and caption_port: + caption_url = f"http://{caption_serve_args.name}:{caption_port}" # Run doc2rag in the RAG container rag = Rag(args.DESTINATION) @@ -151,7 +159,15 @@ def rag_handler(plugin: RuntimePlugin, args: argparse.Namespace) -> None: rag.generate(args, assemble_command(args)) finally: - _cleanup_servers(args, all_serve_args, [docling_proc, embed_proc, caption_proc]) + if getattr(args, "skip_cleanup", False): + _report_skipped_cleanup(args, all_serve_args, network_created) + else: + _cleanup_servers(args, all_serve_args, [docling_proc, embed_proc, caption_proc]) + # Remove the private network after its containers are gone (docker refuses + # to remove a network that still has containers attached). Only remove a + # network we created; a user-supplied one is left in place. + if network_created: + remove_network(args, args.network) def _build_serve_args(args, model_name, port, runtime_args=None, ctx_size=None, cache_reuse=None): @@ -173,7 +189,7 @@ def _build_serve_args(args, model_name, port, runtime_args=None, ctx_size=None, noout=True, image=args.image, pull=getattr(args, "pull", config.pull), - network=None, + network=getattr(args, "network", None), oci_runtime=None, selinux=False, nocapdrop=False, @@ -184,7 +200,7 @@ def _build_serve_args(args, model_name, port, runtime_args=None, ctx_size=None, detach=True, name=None, dri="on", - host="localhost", + host="127.0.0.1", port=str(port), ctx_size=ctx_size, cache_reuse=cache_reuse, @@ -206,6 +222,29 @@ def _build_serve_args(args, model_name, port, runtime_args=None, ctx_size=None, ) +def _setup_rag_network(args: argparse.Namespace) -> bool: + """Put the RAG proxy and its helper servers on a shared private network. + + ``args`` is the RAG proxy namespace; ``args.model_args`` is the backing model + server (the embedding server args are derived from it and inherit the network). + The containers reach each other by name over the network, so nothing is + published to the host. Honor a user-supplied ``--network`` and leave it in + place; otherwise create a private network for this run. Returns True when a + network was created (and the caller must remove it when done). + """ + network_created = False + if not getattr(args, "network", None): + args.network = create_network(args) + network_created = True + args.model_args.network = args.network + # Reach the model server by its container name over the private network rather + # than a host-published port; assign the name now so both the container and the + # proxy's --model-host agree on it. + args.model_args.name = getattr(args.model_args, "name", None) or genname() + args.model_host = args.model_args.name + return network_created + + def _wait_for_server(plugin: RuntimePlugin, args: argparse.Namespace, model_alias: str, timeout: int = 180): """Block until a llama.cpp /health endpoint returns 200.""" end = time.monotonic() + timeout @@ -224,20 +263,46 @@ def _wait_for_server(plugin: RuntimePlugin, args: argparse.Namespace, model_alia raise TimeoutError(f"Server {args.name} did not become ready on port {args.port} within {timeout}s") +def _report_skipped_cleanup(args, all_serve_args, network_created): + """Report the servers and network left running when --skip-cleanup is set.""" + names = [name for name in (getattr(sa, "name", None) for sa in all_serve_args) if name] + engine = args.engine + # Only a network we created is ours to report/clean up. + network = getattr(args, "network", None) if network_created else None + perror("--skip-cleanup: leaving the following running for debugging:") + for name in names: + perror(f" container {name} (inspect with `{engine} logs {name}`)") + if network: + perror(f" network {network}") + cleanup = " ".join([engine, "rm", "-f", *names]) if names else "" + if network: + # `podman network rm -f` disconnects lingering containers; docker has no + # such flag, so mirror engine.remove_network and omit it there. + net_rm = f"{engine} network rm{' -f' if engine == 'podman' else ''} {network}" + cleanup = f"{cleanup}; {net_rm}" if cleanup else net_rm + if cleanup: + perror(f"clean up with: {cleanup}") + + def _cleanup_servers(args, all_serve_args, all_procs): """Stop llama.cpp server containers and terminate any lingering processes.""" from ramalama.engine import stop_container - for serve_args in all_serve_args: - name = getattr(serve_args, "name", None) - if name: - try: - stop_args = argparse.Namespace(engine=args.engine, ignore=True) - stop_container(stop_args, name) - except Exception as e: - from ramalama.logger import logger - - logger.debug(f"Failed to stop container {name}: {e}") + # On a dry run nothing was actually started, so there are no containers to + # stop; skip the engine calls (create_network/remove_network guard this too). + if not getattr(args, "dryrun", False): + for serve_args in all_serve_args: + name = getattr(serve_args, "name", None) + if name: + try: + stop_args = argparse.Namespace(engine=args.engine, ignore=True) + # Remove (not just stop) so the private network can be torn down; + # docker network rm refuses while containers are still attached. + stop_container(stop_args, name, remove=True) + except Exception as e: + from ramalama.logger import logger + + logger.debug(f"Failed to stop container {name}: {e}") for proc in all_procs: if proc is not None and proc.poll() is None: proc.terminate() diff --git a/ramalama/rag.py b/ramalama/rag.py index 650b963dd..14bb4825f 100644 --- a/ramalama/rag.py +++ b/ramalama/rag.py @@ -197,7 +197,7 @@ def _handle_container_chat(self, args: RagArgsType, server_process: int) -> Lite def serve(self, args: RagArgsType, cmd: list[str]): args.model_args.name = self.imodel.get_container_name(args.model_args) - process = self.imodel.serve_nonblocking(args.model_args, self.model_cmd, expose_to_containers=True) + process = self.imodel.serve_nonblocking(args.model_args, self.model_cmd) if not args.dryrun: if process and process.wait() != 0: raise subprocess.CalledProcessError( @@ -213,7 +213,7 @@ def serve(self, args: RagArgsType, cmd: list[str]): def run(self, args: RagArgsType, cmd: list[str]): args.model_args.name = self.imodel.get_container_name(args.model_args) - process = self.imodel.serve_nonblocking(args.model_args, self.model_cmd, expose_to_containers=True) + process = self.imodel.serve_nonblocking(args.model_args, self.model_cmd) rag_process = self.serve_nonblocking(args, cmd) if args.dryrun: diff --git a/ramalama/transports/base.py b/ramalama/transports/base.py index 6fc5a3856..1d78c1a68 100644 --- a/ramalama/transports/base.py +++ b/ramalama/transports/base.py @@ -17,7 +17,7 @@ from ramalama import chat from ramalama.common import ContainerEntryPoint from ramalama.compose import Compose -from ramalama.config import ActiveConfig, get_wildcard_host +from ramalama.config import ActiveConfig from ramalama.engine import Engine, dry_run, is_healthy, wait_for_healthy from ramalama.kube import Kube from ramalama.model_inspect.base_info import ModelInfoBase @@ -466,17 +466,14 @@ def setup_mounts(self, args): [f"--mount=type=bind,src={container_blob_path},destination={mount_path},ro{self.engine.relabel()}"] ) - def serve_nonblocking(self, args, cmd: list[str], expose_to_containers: bool = False) -> Optional[subprocess.Popen]: + def serve_nonblocking(self, args, cmd: list[str]) -> Optional[subprocess.Popen]: if args.container: args.name = self.get_container_name(args) - # Helper servers that a sibling container reaches via - # host.containers.internal must bind the wildcard; everything else - # honors the configured host (loopback by default). - if expose_to_containers: - args.host = get_wildcard_host() - else: - args.host = getattr(args, "host", None) or ActiveConfig().host + # Honor the configured host (loopback by default). Helper servers that a + # sibling container must reach join a shared private network and are + # addressed by container name rather than a host-published port. + args.host = getattr(args, "host", None) or ActiveConfig().host args.detach = True set_accel_env_vars() diff --git a/test/e2e/test_rag.py b/test/e2e/test_rag.py index 0677e2b46..033ee8c09 100644 --- a/test/e2e/test_rag.py +++ b/test/e2e/test_rag.py @@ -34,6 +34,34 @@ HTTP_FILE, [], False, ".*--network none", id="check --network is not set by default" ), + pytest.param( + HTTP_FILE, [], True, r".*--network ramalama-net-\w+", + id="check pipeline containers join a private network" + ), + pytest.param( + HTTP_FILE, [], True, r".*--api-url http://ramalama-\w+:\d+", + id="check doc2rag reaches the VLM server by container name" + ), + pytest.param( + HTTP_FILE, [], True, r".*--embed-url http://ramalama-\w+:\d+", + id="check doc2rag reaches the embedding server by container name" + ), + pytest.param( + HTTP_FILE, [], False, r".*host\.containers\.internal", + id="check host.containers.internal is no longer used" + ), + pytest.param( + HTTP_FILE, ["--skip-cleanup"], True, f".*doc2rag .*/output {HTTP_FILE}", + id="check --skip-cleanup is accepted" + ), + pytest.param( + HTTP_FILE, ["--network", "mynet"], True, r".*--network mynet", + id="check user-supplied --network is used" + ), + pytest.param( + HTTP_FILE, ["--network", "mynet"], False, r".*--network ramalama-net-\w+", + id="check no private network is created when --network is given" + ), pytest.param( HTTP_FILE, [], True, f".*doc2rag .*/output {HTTP_FILE}", id="check with http file" @@ -215,6 +243,26 @@ def test_rag_error_when_file_is_missing(): OLLAMA_MODEL, ["--rag", RAG_MODEL], True, r".*rag_framework serve --port \d+", id="check rag_framework" ), + pytest.param( + OLLAMA_MODEL, ["--rag", RAG_MODEL], True, r".*--network ramalama-net-\w+", + id="check pipeline containers join a private network" + ), + pytest.param( + OLLAMA_MODEL, ["--rag", RAG_MODEL], True, r".*--model-host ramalama-\w+", + id="check rag proxy reaches the model server by container name" + ), + pytest.param( + OLLAMA_MODEL, ["--rag", RAG_MODEL], True, r".*--embed-url http://ramalama-\w+:\d+", + id="check rag proxy reaches the embedding server by container name" + ), + pytest.param( + OLLAMA_MODEL, ["--rag", RAG_MODEL], False, r".*host\.containers\.internal", + id="check host.containers.internal is no longer used" + ), + pytest.param( + OLLAMA_MODEL, ["--rag", RAG_MODEL], False, r".*-p 0\.0\.0\.0:", + id="check helper servers are not published on all interfaces" + ), pytest.param( OLLAMA_MODEL, ["--rag", RAG_MODEL], True, ".*--mount=type=image,source=quay.io/ramalama/myrag:1.2,destination=/rag,rw=true", diff --git a/test/unit/test_engine.py b/test/unit/test_engine.py index 909e93f30..b86d16bc0 100644 --- a/test/unit/test_engine.py +++ b/test/unit/test_engine.py @@ -144,6 +144,49 @@ def test_dry_run(self): ramalama.engine.dry_run(["podman", "run", "--rm", "test-image"]) mock_stdout.write.assert_called() + @patch('ramalama.engine.run_cmd') + def test_create_network(self, mock_run_cmd): + args = Namespace(engine="podman", dryrun=False) + name = ramalama.engine.create_network(args) + self.assertTrue(name.startswith("ramalama-net-")) + mock_run_cmd.assert_called_once_with(["podman", "network", "create", name]) + + @patch('ramalama.engine.run_cmd') + def test_create_network_dryrun_skips_create(self, mock_run_cmd): + args = Namespace(engine="podman", dryrun=True) + name = ramalama.engine.create_network(args) + self.assertTrue(name.startswith("ramalama-net-")) + mock_run_cmd.assert_not_called() + + def test_create_network_no_engine(self): + args = Namespace(engine=None, dryrun=False) + with self.assertRaises(ValueError): + ramalama.engine.create_network(args) + + @patch('ramalama.engine.run_cmd') + def test_remove_network_podman_forces(self, mock_run_cmd): + args = Namespace(engine="podman") + ramalama.engine.remove_network(args, "ramalama-net-abc") + mock_run_cmd.assert_called_once_with(["podman", "network", "rm", "-f", "ramalama-net-abc"], ignore_all=True) + + @patch('ramalama.engine.run_cmd') + def test_remove_network_docker_no_force(self, mock_run_cmd): + args = Namespace(engine="docker") + ramalama.engine.remove_network(args, "ramalama-net-abc") + mock_run_cmd.assert_called_once_with(["docker", "network", "rm", "ramalama-net-abc"], ignore_all=True) + + @patch('ramalama.engine.run_cmd') + def test_remove_network_empty_name_noop(self, mock_run_cmd): + args = Namespace(engine="podman") + ramalama.engine.remove_network(args, "") + mock_run_cmd.assert_not_called() + + @patch('ramalama.engine.run_cmd') + def test_remove_network_dryrun_skips_remove(self, mock_run_cmd): + args = Namespace(engine="podman", dryrun=True) + ramalama.engine.remove_network(args, "ramalama-net-abc") + mock_run_cmd.assert_not_called() + @pytest.mark.parametrize( "host, port, expected_port_arg", diff --git a/test/unit/test_rag_handler.py b/test/unit/test_rag_handler.py new file mode 100644 index 000000000..62e45c0ce --- /dev/null +++ b/test/unit/test_rag_handler.py @@ -0,0 +1,74 @@ +from argparse import Namespace + +import pytest + +import ramalama.plugins.runtimes.inference.rag.handler as handler + + +def _capture_report(args, all_serve_args, network_created, monkeypatch): + lines: list[str] = [] + monkeypatch.setattr(handler, "perror", lambda *a, **k: lines.append(" ".join(str(x) for x in a))) + handler._report_skipped_cleanup(args, all_serve_args, network_created) + return lines + + +@pytest.mark.parametrize( + "engine,expected_net_rm", + [ + # podman network rm supports -f to disconnect lingering containers + ("podman", "podman network rm -f ramalama-net-abc"), + # docker network rm has no -f flag; the suggested command must omit it + ("docker", "docker network rm ramalama-net-abc"), + ], +) +def test_report_skipped_cleanup_network_rm_flag(engine, expected_net_rm, monkeypatch): + args = Namespace(engine=engine, network="ramalama-net-abc") + serve_args = [Namespace(name="rag-embed"), Namespace(name="rag-docling")] + lines = _capture_report(args, serve_args, network_created=True, monkeypatch=monkeypatch) + + cleanup = next(line for line in lines if line.startswith("clean up with:")) + assert f"{engine} rm -f rag-embed rag-docling" in cleanup + assert expected_net_rm in cleanup + # docker must never be told to force-remove a network + if engine == "docker": + assert "network rm -f" not in cleanup + + +def test_report_skipped_cleanup_omits_network_when_not_created(monkeypatch): + args = Namespace(engine="docker", network="ramalama-net-abc") + serve_args = [Namespace(name="rag-embed")] + lines = _capture_report(args, serve_args, network_created=False, monkeypatch=monkeypatch) + + cleanup = next(line for line in lines if line.startswith("clean up with:")) + assert "network rm" not in cleanup + assert "docker rm -f rag-embed" in cleanup + + +def test_cleanup_servers_dryrun_does_not_touch_engine(monkeypatch): + # On a dry run nothing was started, so cleanup must not shell out to the + # engine to stop/remove containers that never existed. + calls = [] + monkeypatch.setattr( + "ramalama.engine.stop_container", + lambda *a, **k: calls.append(a), + ) + args = Namespace(engine="podman", dryrun=True) + serve_args = [Namespace(name="rag-embed"), Namespace(name="rag-docling")] + + handler._cleanup_servers(args, serve_args, [None, None]) + + assert calls == [] + + +def test_cleanup_servers_stops_containers_when_not_dryrun(monkeypatch): + calls = [] + monkeypatch.setattr( + "ramalama.engine.stop_container", + lambda stop_args, name, remove=False: calls.append((name, remove)), + ) + args = Namespace(engine="podman", dryrun=False) + serve_args = [Namespace(name="rag-embed"), Namespace(name="rag-docling")] + + handler._cleanup_servers(args, serve_args, [None, None]) + + assert calls == [("rag-embed", True), ("rag-docling", True)]