From 2e5e53d3c4e7bef5815e50ae4206c6df6b7950d3 Mon Sep 17 00:00:00 2001 From: Stefan Koch Date: Sun, 24 May 2026 09:22:34 +0200 Subject: [PATCH 1/3] fix(pipeline): Pre-create ollama-internal network when LiteLLM enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiteLLM's docker-compose declares `ollama-internal` as an external network so it can reach the Ollama stack's container by service name when both stacks run side-by-side. Without a network of that name already existing, `docker compose up` aborts with network ollama-internal declared as external, but could not be found BEFORE the container is even created — the operator gets a Bad Gateway on https://litellm./ because nothing is listening on the proxied port. The hard dependency on Ollama being enabled defeats LiteLLM's core value proposition (OpenAI-compatible proxy for ANY provider — operator-supplied OPENAI_API_KEY / ANTHROPIC_API_KEY should be sufficient). Fix: add an idempotent pre-compose block to compose_runner that inspects-then-creates `ollama-internal` whenever LiteLLM is enabled, mirroring the existing dify_storage_prep / metabase_storage_prep flag pattern. When Ollama is ALSO enabled, its own compose joins the same network by name (already pinned via `name: ollama-internal` on both sides) — no behaviour change for the joint-enabled case. Tests pin: (a) block presence is gated on the flag, (b) the inspect-||-create shape is idempotent under set -euo pipefail, (c) run_compose_up defaults the flag to `"litellm" in enabled`, (d) explicit override beats inference. --- src/nexus_deploy/compose_runner.py | 33 +++++++++++- stacks/litellm/docker-compose.yml | 10 ++-- tests/unit/test_compose_runner.py | 82 ++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/nexus_deploy/compose_runner.py b/src/nexus_deploy/compose_runner.py index 711d6035..af39d69a 100644 --- a/src/nexus_deploy/compose_runner.py +++ b/src/nexus_deploy/compose_runner.py @@ -149,6 +149,7 @@ def render_remote_script( leaves: list[str], dify_storage_prep: bool = False, metabase_storage_prep: bool = False, + litellm_network_prep: bool = False, stacks_dir: str = _REMOTE_STACKS_DIR, global_env: str = _REMOTE_GLOBAL_ENV, ) -> str: @@ -197,6 +198,31 @@ def render_remote_script( chown -R 1001:1001 /mnt/nexus-data/dify/storage /mnt/nexus-data/dify/plugins """ + # LiteLLM's compose declares `ollama-internal` as an external + # network so it can reach the Ollama stack's container by service + # name when both stacks are enabled. Without an existing network + # of that name, `docker compose up` aborts with "network + # ollama-internal declared as external, but could not be found" + # BEFORE the container is even created — leaving the operator + # with a Bad Gateway at https://litellm.. Idempotently + # creating the network here decouples the two stacks: LiteLLM + # can be enabled without Ollama (operator-supplied OpenAI / + # Anthropic / etc. keys), and when both are enabled the + # network already exists when Ollama's compose-up reaches its + # `external: true` declaration. + # + # `docker network create --label` is idempotent enough for our + # purposes via the inspect-guard (exit 0 if exists, exit 1 if + # not — wrapped in a short-circuit `||`). The label lets ops + # tell apart nexus-managed networks from operator-created ones + # when troubleshooting. + litellm_block = "" + if litellm_network_prep: + litellm_block = """ +docker network inspect ollama-internal >/dev/null 2>&1 || \\ + docker network create --label managed-by=nexus-stack ollama-internal +""" + metabase_block = "" if metabase_storage_prep: # Metabase runs as uid 2000 (since v0.46 official image) and @@ -234,7 +260,7 @@ def render_remote_script( PARENTS=({parents_q}) LEAVES=({leaves_q}) -{dify_block}{metabase_block} +{litellm_block}{dify_block}{metabase_block} STARTED=0 FAILED=0 PIDS=() @@ -332,6 +358,7 @@ def run_compose_up( host: str = "nexus", dify_storage_prep: bool | None = None, metabase_storage_prep: bool | None = None, + litellm_network_prep: bool | None = None, script_runner: ScriptRunner | None = None, ) -> ComposeUpResult: """Render → exec → parse. @@ -366,12 +393,16 @@ def run_compose_up( actual_metabase = ( metabase_storage_prep if metabase_storage_prep is not None else "metabase" in enabled ) + actual_litellm = ( + litellm_network_prep if litellm_network_prep is not None else "litellm" in enabled + ) script = render_remote_script( parents=parents, leaves=leaves, dify_storage_prep=actual_dify, metabase_storage_prep=actual_metabase, + litellm_network_prep=actual_litellm, ) run_script = script_runner or (lambda s: _remote.ssh_run_script(s, host=host)) diff --git a/stacks/litellm/docker-compose.yml b/stacks/litellm/docker-compose.yml index 4571aa35..0ac3dde0 100644 --- a/stacks/litellm/docker-compose.yml +++ b/stacks/litellm/docker-compose.yml @@ -114,9 +114,13 @@ networks: # the public CF Tunnel route. Both sides pin the network to the # global name `ollama-internal` (NOT the default project-prefixed # `ollama_ollama-internal`) so the external reference here finds - # the actual created network. Requires the ollama stack to be - # enabled — if not, LiteLLM will fail to start with "network - # ollama-internal not found"; disable LiteLLM or enable Ollama. + # the actual created network. When LiteLLM is enabled, the deploy + # pipeline's compose_runner pre-creates this network idempotently + # before `docker compose up`, so LiteLLM starts cleanly even when + # Ollama is disabled (operator-supplied OpenAI / Anthropic / etc. + # keys via the config.yaml template). When Ollama is also enabled, + # its own compose joins the same network by name and the + # cross-stack DNS lookup `http://ollama:11434` resolves. ollama-internal: external: true name: ollama-internal diff --git a/tests/unit/test_compose_runner.py b/tests/unit/test_compose_runner.py index fca3dc82..540d686f 100644 --- a/tests/unit/test_compose_runner.py +++ b/tests/unit/test_compose_runner.py @@ -426,6 +426,88 @@ def capture(script: str) -> subprocess.CompletedProcess[str]: assert "/mnt/nexus-data/metabase" not in captured_script["script"] +# --------------------------------------------------------------------------- +# litellm cross-stack network prep (ollama-internal) +# --------------------------------------------------------------------------- + + +def test_render_litellm_network_prep_only_when_flagged() -> None: + """LiteLLM declares `ollama-internal` as an external network so + it can reach the Ollama stack's container directly. Without the + network already created, `docker compose up` aborts BEFORE the + container is created. The pre-compose block creates the network + idempotently when litellm is enabled (or omitted otherwise) so + LiteLLM works regardless of whether Ollama is also enabled.""" + without = _render_default(litellm_network_prep=False) + assert "ollama-internal" not in without + assert "docker network create" not in without + + with_litellm = _render_default(litellm_network_prep=True) + assert "docker network inspect ollama-internal" in with_litellm + assert "docker network create --label managed-by=nexus-stack ollama-internal" in with_litellm + + +def test_render_litellm_network_prep_is_idempotent() -> None: + """The inspect-then-create guard short-circuits if the network + already exists. A bare `docker network create` would fail with + a non-zero exit on the second deploy under `set -euo pipefail` + and abort the entire compose-up loop.""" + script = _render_default(litellm_network_prep=True) + # `inspect ... >/dev/null 2>&1 || create` is the canonical + # idempotent shape — both halves must be present and on the + # short-circuit chain together. + assert "docker network inspect ollama-internal >/dev/null 2>&1" in script + # The `||` chain must wrap to the create call (line-continuation + # backslash is fine — bash treats it as a single logical line). + assert "||" in script + + +def test_run_compose_up_litellm_default_when_litellm_in_enabled() -> None: + """litellm_network_prep defaults to True iff 'litellm' is in enabled. + Mirrors the dify/metabase storage-prep default semantics.""" + captured_script: dict[str, str] = {} + + def capture(script: str) -> subprocess.CompletedProcess[str]: + captured_script["script"] = script + return subprocess.CompletedProcess( + args=["ssh"], returncode=0, stdout="RESULT started=1 failed=0", stderr="" + ) + + run_compose_up(["jupyter", "litellm"], script_runner=capture) + assert "docker network inspect ollama-internal" in captured_script["script"] + + +def test_run_compose_up_litellm_omitted_when_litellm_not_in_enabled() -> None: + """No litellm → no network-prep block (also no spurious + network created on stacks that don't need it).""" + captured_script: dict[str, str] = {} + + def capture(script: str) -> subprocess.CompletedProcess[str]: + captured_script["script"] = script + return subprocess.CompletedProcess( + args=["ssh"], returncode=0, stdout="RESULT started=1 failed=0", stderr="" + ) + + run_compose_up(["jupyter"], script_runner=capture) + assert "ollama-internal" not in captured_script["script"] + + +def test_run_compose_up_litellm_explicit_override_beats_enabled_inference() -> None: + """Caller can force litellm_network_prep=False even when 'litellm' + is in enabled — operator escape hatch if the network handling + needs to be deferred to a different mechanism.""" + captured_script: dict[str, str] = {} + + def capture(script: str) -> subprocess.CompletedProcess[str]: + captured_script["script"] = script + return subprocess.CompletedProcess( + args=["ssh"], returncode=0, stdout="RESULT started=1 failed=0", stderr="" + ) + + run_compose_up(["litellm"], script_runner=capture, litellm_network_prep=False) + assert "ollama-internal" not in captured_script["script"] + + # --------------------------------------------------------------------------- # CLI integration # --------------------------------------------------------------------------- From b2f102bd858a3ff5aeaafdb6ec8a5b72a08646d3 Mon Sep 17 00:00:00 2001 From: Stefan Koch Date: Sun, 24 May 2026 09:57:45 +0200 Subject: [PATCH 2/3] fix(docs+tests): Address PR #617 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/stacks/litellm.md: replace the "Ollama MUST be enabled" paragraph with the actual post-fix behaviour — the deploy pipeline pre-creates ollama-internal idempotently, so LiteLLM starts cleanly whether or not Ollama is also enabled. Remove the two-step "edits required for no-Ollama" instructions since the compose change is no longer needed. - tests/unit/test_compose_runner.py::test_render_litellm_network_prep_is_idempotent: tighten the assertion from a loose `\"||\" in script` (which could pass if any unrelated || appeared in the rendered bash) to a full inspect→||→create chain match. Whitespace and the bash line-continuation backslash are normalised so the test isn't brittle to renderer-side line-wrap tweaks. --- docs/stacks/litellm.md | 11 ++++++----- tests/unit/test_compose_runner.py | 26 ++++++++++++++++++-------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/stacks/litellm.md b/docs/stacks/litellm.md index d2d89457..31d9eeae 100644 --- a/docs/stacks/litellm.md +++ b/docs/stacks/litellm.md @@ -104,14 +104,15 @@ All three must be non-empty or the deploy aborts (no silent auth-bypass). ### Ollama integration -The LiteLLM compose joins the external `ollama-internal` network so it can reach `http://ollama:11434` directly without going through the public CF Tunnel route — fast and private. **The Ollama stack MUST be enabled** for LiteLLM to start: the compose declares `external: true` + `name: ollama-internal` on that network, and Docker will refuse to start the LiteLLM container if the network doesn't exist (error: `network ollama-internal not found`). +The LiteLLM compose joins the external `ollama-internal` network so it can reach `http://ollama:11434` directly without going through the public CF Tunnel route — fast and private. -If you want LiteLLM without Ollama (e.g. real-providers-only setup), you need TWO changes: +**LiteLLM works whether or not Ollama is enabled.** The deploy pipeline (`compose_runner.run_compose_up`) pre-creates the `ollama-internal` network idempotently before `docker compose up` when `litellm` is in the enabled list. When Ollama is also enabled, its own compose joins the same network by name (`name: ollama-internal` pinned on both sides), and the cross-stack DNS lookup `http://ollama:11434` resolves. When Ollama is NOT enabled, LiteLLM still starts cleanly — only requests routed to a model whose `api_base` points at Ollama will fail. -1. Remove the `ollama-internal` network declaration AND the `ollama-internal:` entry under `litellm.networks:` in `stacks/litellm/docker-compose.yml` -2. Remove the `gpt-3.5-turbo` → Ollama route from `stacks/litellm/config.yaml.template` (otherwise the proxy serves the model name but routes to an unreachable backend) +If you want to wire LiteLLM at real-provider keys only (no Ollama), the only edit needed is: -Removing just the config route without the network change still results in container start failure. +- Remove or comment out the `gpt-3.5-turbo` → Ollama route in `stacks/litellm/config.yaml.template` (otherwise the proxy serves the model name but the routed request to `http://ollama:11434` fails with a connection error). Add real-provider entries in its place — see "Option B" above. + +No edits to `stacks/litellm/docker-compose.yml` are required for the no-Ollama case. ### Persistence diff --git a/tests/unit/test_compose_runner.py b/tests/unit/test_compose_runner.py index 540d686f..ced08ee5 100644 --- a/tests/unit/test_compose_runner.py +++ b/tests/unit/test_compose_runner.py @@ -451,15 +451,25 @@ def test_render_litellm_network_prep_is_idempotent() -> None: """The inspect-then-create guard short-circuits if the network already exists. A bare `docker network create` would fail with a non-zero exit on the second deploy under `set -euo pipefail` - and abort the entire compose-up loop.""" + and abort the entire compose-up loop. + + Tests the full inspect→create chain as one contiguous expression + rather than just `||` presence: a future refactor that splits + the guard into two unrelated statements (e.g. `inspect; if [ $? + -ne 0 ]; then create; fi`) would lose the short-circuit semantics + under `set -e` and silently break idempotency. Whitespace is + normalised so the test isn't brittle to backslash-newline + continuation tweaks bash treats as one logical line. + """ script = _render_default(litellm_network_prep=True) - # `inspect ... >/dev/null 2>&1 || create` is the canonical - # idempotent shape — both halves must be present and on the - # short-circuit chain together. - assert "docker network inspect ollama-internal >/dev/null 2>&1" in script - # The `||` chain must wrap to the create call (line-continuation - # backslash is fine — bash treats it as a single logical line). - assert "||" in script + # Normalise whitespace AND strip the bash line-continuation + # backslash (`\` followed by newline) so the substring matcher + # doesn't depend on the renderer's exact line-wrap choice. + normalised = " ".join(script.replace("\\\n", " ").split()) + assert ( + "docker network inspect ollama-internal >/dev/null 2>&1 || " + "docker network create --label managed-by=nexus-stack ollama-internal" + ) in normalised def test_run_compose_up_litellm_default_when_litellm_in_enabled() -> None: From 1923edfdd5f5555eff854985334fcc06082f383c Mon Sep 17 00:00:00 2001 From: Stefan Koch Date: Wed, 27 May 2026 08:05:52 +0200 Subject: [PATCH 3/3] fix(stacks): Make ollama-internal external on both sides for symmetric ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the Ollama stack declared `ollama-internal` as a compose-managed network (`driver: bridge`, `name: ollama-internal`) while LiteLLM declared it `external: true`. The pipeline's pre-create block was gated on LiteLLM-being-enabled, which left two ambiguous cases: * Joint LiteLLM + Ollama: pre-create runs, network exists with the `managed-by=nexus-stack` label. Ollama's compose-up then tries to treat it as a project-managed network. Modern Compose v2 tolerates this with a warning, but the behaviour is version-dependent and the joint case was never tested by the previous fix — the merged PR (#617) only verified the LiteLLM-only path on production. * Ollama-only future: would have been fine before, but breaks if Ollama's compose later moves to `external: true` for any reason. Switch Ollama's compose to also declare `ollama-internal` as `external: true` + `name: ollama-internal`. Network ownership now lives entirely in the pre-create block, neither compose project tries to create it, and Compose's tolerance for pre-existing networks no longer matters. Rename the gate parameter `litellm_network_prep` → `ollama_internal_network_prep` since it's no longer LiteLLM-specific, and broaden the default inference to fire when either stack is enabled. Added two new tests: * `test_run_compose_up_network_prep_default_when_ollama_in_enabled` locks the ollama-only firing path (regression guard for the inference change). * `test_run_compose_up_network_prep_renders_once_in_joint_case` confirms the joint case renders exactly one pre-create block, not a duplicate per matching service. Addresses Copilot review comment 3308563617 on PR #617. --- src/nexus_deploy/compose_runner.py | 47 +++++++++------- stacks/litellm/docker-compose.yml | 19 +++---- stacks/ollama/docker-compose.yml | 12 ++-- tests/unit/test_compose_runner.py | 88 +++++++++++++++++++++--------- 4 files changed, 106 insertions(+), 60 deletions(-) diff --git a/src/nexus_deploy/compose_runner.py b/src/nexus_deploy/compose_runner.py index af39d69a..8647e9b0 100644 --- a/src/nexus_deploy/compose_runner.py +++ b/src/nexus_deploy/compose_runner.py @@ -149,7 +149,7 @@ def render_remote_script( leaves: list[str], dify_storage_prep: bool = False, metabase_storage_prep: bool = False, - litellm_network_prep: bool = False, + ollama_internal_network_prep: bool = False, stacks_dir: str = _REMOTE_STACKS_DIR, global_env: str = _REMOTE_GLOBAL_ENV, ) -> str: @@ -198,27 +198,30 @@ def render_remote_script( chown -R 1001:1001 /mnt/nexus-data/dify/storage /mnt/nexus-data/dify/plugins """ - # LiteLLM's compose declares `ollama-internal` as an external - # network so it can reach the Ollama stack's container by service - # name when both stacks are enabled. Without an existing network - # of that name, `docker compose up` aborts with "network - # ollama-internal declared as external, but could not be found" - # BEFORE the container is even created — leaving the operator - # with a Bad Gateway at https://litellm.. Idempotently - # creating the network here decouples the two stacks: LiteLLM - # can be enabled without Ollama (operator-supplied OpenAI / - # Anthropic / etc. keys), and when both are enabled the - # network already exists when Ollama's compose-up reaches its - # `external: true` declaration. + # `ollama-internal` is the cross-stack bridge LiteLLM uses to reach + # the Ollama container by service name when both stacks are enabled. + # Both compose files (stacks/ollama, stacks/litellm) declare the + # network as `external: true` so neither owns the lifecycle — + # without a pre-existing network of that name, `docker compose up` + # aborts with "network ollama-internal declared as external, but + # could not be found" BEFORE the container is even created. We + # pre-create here, idempotently, whenever either stack is enabled. + # + # The symmetric `external: true` design (vs. having Ollama own the + # network as compose-managed) avoids a subtle race in the joint + # LiteLLM+Ollama case: parallel `docker compose up` for both + # parents would otherwise have one project try to create a network + # the other one expects to find, and Compose's tolerance for + # pre-existing networks varies by version. # # `docker network create --label` is idempotent enough for our # purposes via the inspect-guard (exit 0 if exists, exit 1 if # not — wrapped in a short-circuit `||`). The label lets ops # tell apart nexus-managed networks from operator-created ones # when troubleshooting. - litellm_block = "" - if litellm_network_prep: - litellm_block = """ + ollama_internal_block = "" + if ollama_internal_network_prep: + ollama_internal_block = """ docker network inspect ollama-internal >/dev/null 2>&1 || \\ docker network create --label managed-by=nexus-stack ollama-internal """ @@ -260,7 +263,7 @@ def render_remote_script( PARENTS=({parents_q}) LEAVES=({leaves_q}) -{litellm_block}{dify_block}{metabase_block} +{ollama_internal_block}{dify_block}{metabase_block} STARTED=0 FAILED=0 PIDS=() @@ -358,7 +361,7 @@ def run_compose_up( host: str = "nexus", dify_storage_prep: bool | None = None, metabase_storage_prep: bool | None = None, - litellm_network_prep: bool | None = None, + ollama_internal_network_prep: bool | None = None, script_runner: ScriptRunner | None = None, ) -> ComposeUpResult: """Render → exec → parse. @@ -393,8 +396,10 @@ def run_compose_up( actual_metabase = ( metabase_storage_prep if metabase_storage_prep is not None else "metabase" in enabled ) - actual_litellm = ( - litellm_network_prep if litellm_network_prep is not None else "litellm" in enabled + actual_ollama_internal = ( + ollama_internal_network_prep + if ollama_internal_network_prep is not None + else ("litellm" in enabled or "ollama" in enabled) ) script = render_remote_script( @@ -402,7 +407,7 @@ def run_compose_up( leaves=leaves, dify_storage_prep=actual_dify, metabase_storage_prep=actual_metabase, - litellm_network_prep=actual_litellm, + ollama_internal_network_prep=actual_ollama_internal, ) run_script = script_runner or (lambda s: _remote.ssh_run_script(s, host=host)) diff --git a/stacks/litellm/docker-compose.yml b/stacks/litellm/docker-compose.yml index 0ac3dde0..0e8cfabb 100644 --- a/stacks/litellm/docker-compose.yml +++ b/stacks/litellm/docker-compose.yml @@ -111,16 +111,15 @@ networks: driver: bridge # Cross-stack join into the ollama stack's internal bridge so # LiteLLM can reach `ollama:11434` directly without going through - # the public CF Tunnel route. Both sides pin the network to the - # global name `ollama-internal` (NOT the default project-prefixed - # `ollama_ollama-internal`) so the external reference here finds - # the actual created network. When LiteLLM is enabled, the deploy - # pipeline's compose_runner pre-creates this network idempotently - # before `docker compose up`, so LiteLLM starts cleanly even when - # Ollama is disabled (operator-supplied OpenAI / Anthropic / etc. - # keys via the config.yaml template). When Ollama is also enabled, - # its own compose joins the same network by name and the - # cross-stack DNS lookup `http://ollama:11434` resolves. + # the public CF Tunnel route. Both stacks (here and stacks/ollama) + # declare this network as `external: true` + `name: ollama-internal`; + # the deploy pipeline's compose_runner pre-creates the network + # whenever either stack is enabled, so neither compose project owns + # the network's lifecycle. LiteLLM-alone: pre-create fires → + # external reference resolves → operator wires real-provider keys via + # config.yaml. Joint LiteLLM + Ollama: pre-create fires once → + # both stacks join the existing network → `http://ollama:11434` + # resolves cross-stack with no race on creation. ollama-internal: external: true name: ollama-internal diff --git a/stacks/ollama/docker-compose.yml b/stacks/ollama/docker-compose.yml index 820310c3..2f5e21d8 100644 --- a/stacks/ollama/docker-compose.yml +++ b/stacks/ollama/docker-compose.yml @@ -75,8 +75,12 @@ networks: app-network: external: true ollama-internal: - # Pin the global name (NOT project-prefixed to `ollama_ollama-internal`) - # so cross-stack consumers like LiteLLM can declare - # `external: true` + `name: ollama-internal` and reliably join it. - driver: bridge + # Treated as external on both sides (here and in stacks/litellm). The + # deploy pipeline pre-creates `ollama-internal` whenever either stack + # is enabled (see compose_runner.render_remote_script's + # `ollama_internal_network_prep` block), so network ownership lives + # outside both compose projects. This keeps the joint Ollama+LiteLLM + # deployment deterministic — neither compose project tries to "own" + # the network and race on creation. + external: true name: ollama-internal diff --git a/tests/unit/test_compose_runner.py b/tests/unit/test_compose_runner.py index ced08ee5..79988732 100644 --- a/tests/unit/test_compose_runner.py +++ b/tests/unit/test_compose_runner.py @@ -427,27 +427,26 @@ def capture(script: str) -> subprocess.CompletedProcess[str]: # --------------------------------------------------------------------------- -# litellm cross-stack network prep (ollama-internal) +# ollama-internal cross-stack network prep (shared by ollama + litellm) # --------------------------------------------------------------------------- -def test_render_litellm_network_prep_only_when_flagged() -> None: - """LiteLLM declares `ollama-internal` as an external network so - it can reach the Ollama stack's container directly. Without the - network already created, `docker compose up` aborts BEFORE the - container is created. The pre-compose block creates the network - idempotently when litellm is enabled (or omitted otherwise) so - LiteLLM works regardless of whether Ollama is also enabled.""" - without = _render_default(litellm_network_prep=False) +def test_render_ollama_internal_network_prep_only_when_flagged() -> None: + """Both `stacks/ollama` and `stacks/litellm` declare `ollama-internal` + as an `external: true` network. Without the network already created, + `docker compose up` aborts BEFORE the container is created. The + pre-compose block creates the network idempotently when either stack + is enabled (or omitted otherwise).""" + without = _render_default(ollama_internal_network_prep=False) assert "ollama-internal" not in without assert "docker network create" not in without - with_litellm = _render_default(litellm_network_prep=True) - assert "docker network inspect ollama-internal" in with_litellm - assert "docker network create --label managed-by=nexus-stack ollama-internal" in with_litellm + with_prep = _render_default(ollama_internal_network_prep=True) + assert "docker network inspect ollama-internal" in with_prep + assert "docker network create --label managed-by=nexus-stack ollama-internal" in with_prep -def test_render_litellm_network_prep_is_idempotent() -> None: +def test_render_ollama_internal_network_prep_is_idempotent() -> None: """The inspect-then-create guard short-circuits if the network already exists. A bare `docker network create` would fail with a non-zero exit on the second deploy under `set -euo pipefail` @@ -461,7 +460,7 @@ def test_render_litellm_network_prep_is_idempotent() -> None: normalised so the test isn't brittle to backslash-newline continuation tweaks bash treats as one logical line. """ - script = _render_default(litellm_network_prep=True) + script = _render_default(ollama_internal_network_prep=True) # Normalise whitespace AND strip the bash line-continuation # backslash (`\` followed by newline) so the substring matcher # doesn't depend on the renderer's exact line-wrap choice. @@ -472,9 +471,10 @@ def test_render_litellm_network_prep_is_idempotent() -> None: ) in normalised -def test_run_compose_up_litellm_default_when_litellm_in_enabled() -> None: - """litellm_network_prep defaults to True iff 'litellm' is in enabled. - Mirrors the dify/metabase storage-prep default semantics.""" +def test_run_compose_up_network_prep_default_when_litellm_in_enabled() -> None: + """ollama_internal_network_prep defaults to True iff 'litellm' OR + 'ollama' is in enabled. Mirrors the dify/metabase storage-prep + default semantics.""" captured_script: dict[str, str] = {} def capture(script: str) -> subprocess.CompletedProcess[str]: @@ -487,9 +487,47 @@ def capture(script: str) -> subprocess.CompletedProcess[str]: assert "docker network inspect ollama-internal" in captured_script["script"] -def test_run_compose_up_litellm_omitted_when_litellm_not_in_enabled() -> None: - """No litellm → no network-prep block (also no spurious - network created on stacks that don't need it).""" +def test_run_compose_up_network_prep_default_when_ollama_in_enabled() -> None: + """Ollama-only deployment (LiteLLM disabled) still needs the + pre-create because ollama's own compose declares the network as + `external: true` — symmetric ownership with litellm.""" + captured_script: dict[str, str] = {} + + def capture(script: str) -> subprocess.CompletedProcess[str]: + captured_script["script"] = script + return subprocess.CompletedProcess( + args=["ssh"], returncode=0, stdout="RESULT started=1 failed=0", stderr="" + ) + + run_compose_up(["jupyter", "ollama"], script_runner=capture) + assert "docker network inspect ollama-internal" in captured_script["script"] + + +def test_run_compose_up_network_prep_renders_once_in_joint_case() -> None: + """Joint LiteLLM + Ollama deployment: only one pre-create block, + not duplicated. The `inspect || create` guard is idempotent at + runtime regardless, but rendering the block twice would be a + silent code smell — confirms the inference doesn't double-add.""" + captured_script: dict[str, str] = {} + + def capture(script: str) -> subprocess.CompletedProcess[str]: + captured_script["script"] = script + return subprocess.CompletedProcess( + args=["ssh"], returncode=0, stdout="RESULT started=2 failed=0", stderr="" + ) + + run_compose_up(["litellm", "ollama"], script_runner=capture) + assert ( + captured_script["script"].count( + "docker network create --label managed-by=nexus-stack ollama-internal" + ) + == 1 + ) + + +def test_run_compose_up_network_prep_omitted_when_neither_in_enabled() -> None: + """Neither litellm nor ollama → no network-prep block (also no + spurious network created on stacks that don't need it).""" captured_script: dict[str, str] = {} def capture(script: str) -> subprocess.CompletedProcess[str]: @@ -502,10 +540,10 @@ def capture(script: str) -> subprocess.CompletedProcess[str]: assert "ollama-internal" not in captured_script["script"] -def test_run_compose_up_litellm_explicit_override_beats_enabled_inference() -> None: - """Caller can force litellm_network_prep=False even when 'litellm' - is in enabled — operator escape hatch if the network handling - needs to be deferred to a different mechanism.""" +def test_run_compose_up_network_prep_explicit_override_beats_enabled_inference() -> None: + """Caller can force ollama_internal_network_prep=False even when + 'litellm' or 'ollama' is in enabled — operator escape hatch if the + network handling needs to be deferred to a different mechanism.""" captured_script: dict[str, str] = {} def capture(script: str) -> subprocess.CompletedProcess[str]: @@ -514,7 +552,7 @@ def capture(script: str) -> subprocess.CompletedProcess[str]: args=["ssh"], returncode=0, stdout="RESULT started=1 failed=0", stderr="" ) - run_compose_up(["litellm"], script_runner=capture, litellm_network_prep=False) + run_compose_up(["litellm"], script_runner=capture, ollama_internal_network_prep=False) assert "ollama-internal" not in captured_script["script"]