Skip to content

Commit fc5a959

Browse files
committed
test: cover plugin requirements entrypoint path
Signed-off-by: lucarlig <luca.carlig@ibm.com>
1 parent 3f6559a commit fc5a959

2 files changed

Lines changed: 163 additions & 8 deletions

File tree

docker-entrypoint.sh

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ if [ "$(uname -m)" = "s390x" ]; then
99
fi
1010

1111
HTTP_SERVER="${HTTP_SERVER:-gunicorn}"
12+
APP_ROOT="${APP_ROOT:-/app}"
1213
RUST_MCP_MODE="${RUST_MCP_MODE:-off}"
1314
RUST_MCP_LOG="${RUST_MCP_LOG:-warn}"
1415
RUST_MCP_SESSION_AUTH_REUSE="${RUST_MCP_SESSION_AUTH_REUSE:-}"
@@ -47,7 +48,8 @@ RUST_MCP_PID=""
4748
SERVER_PID=""
4849

4950
apply_rust_mcp_mode_defaults() {
50-
local normalized_mode="${RUST_MCP_MODE,,}"
51+
local normalized_mode
52+
normalized_mode="$(printf '%s' "${RUST_MCP_MODE}" | tr '[:upper:]' '[:lower:]')"
5153
local runtime_enabled_default="false"
5254
local managed_default="true"
5355
local session_core_default="false"
@@ -391,27 +393,31 @@ PY
391393

392394
install_plugin_requirements() {
393395
RELOAD_PLUGIN_REQUIREMENTS_TXT="${RELOAD_PLUGIN_REQUIREMENTS_TXT:-false}"
394-
PLUGIN_REQUIREMENTS_TXT_PATH="${PLUGIN_REQUIREMENTS_TXT_PATH:-/app/plugins/requirements.txt}"
396+
PLUGIN_REQUIREMENTS_TXT_PATH="${PLUGIN_REQUIREMENTS_TXT_PATH:-${APP_ROOT}/plugins/requirements.txt}"
395397

396398
if [[ "${RELOAD_PLUGIN_REQUIREMENTS_TXT}" != "true" ]]; then
397399
return 0
398400
fi
399401

400-
# Resolve both /app and the requested path to their canonical forms, then
401-
# require the requested path to live inside /app. Canonicalizing /app too
402+
# Resolve both APP_ROOT and the requested path to their canonical forms, then
403+
# require the requested path to live inside APP_ROOT. Canonicalizing APP_ROOT too
402404
# handles the case where /app is itself a symlink (uncommon in this repo's
403405
# Containerfiles, but defensive). This prevents env-controlled path
404406
# injection like PLUGIN_REQUIREMENTS_TXT_PATH=/tmp/evil-requirements.txt.
405407
local app_root resolved_path
406-
app_root="$(readlink -f /app 2>/dev/null)"
408+
app_root="$(readlink -f "${APP_ROOT}" 2>/dev/null)"
407409
if [[ -z "${app_root}" ]]; then
408-
echo "/app could not be resolved; refusing to start with RELOAD_PLUGIN_REQUIREMENTS_TXT=true"
410+
echo "${APP_ROOT} could not be resolved; refusing to start with RELOAD_PLUGIN_REQUIREMENTS_TXT=true"
409411
return 1
410412
fi
411-
if ! resolved_path="$(readlink -f "${PLUGIN_REQUIREMENTS_TXT_PATH}" 2>/dev/null)"; then
413+
local requirements_dir requirements_file
414+
requirements_dir="$(dirname "${PLUGIN_REQUIREMENTS_TXT_PATH}")"
415+
requirements_file="$(basename "${PLUGIN_REQUIREMENTS_TXT_PATH}")"
416+
if ! resolved_path="$(readlink -f "${requirements_dir}" 2>/dev/null)"; then
412417
echo "❌ PLUGIN_REQUIREMENTS_TXT_PATH=${PLUGIN_REQUIREMENTS_TXT_PATH} could not be resolved; refusing to start"
413418
return 1
414419
fi
420+
resolved_path="${resolved_path}/${requirements_file}"
415421
if [[ "${resolved_path}" != "${app_root}/"* ]]; then
416422
echo "❌ PLUGIN_REQUIREMENTS_TXT_PATH must resolve under ${app_root}/ (got ${resolved_path}); refusing to start"
417423
return 1
@@ -428,7 +434,7 @@ install_plugin_requirements() {
428434
local max_retries=3
429435
local attempt=1
430436
while (( attempt <= max_retries )); do
431-
if /app/.venv/bin/pip install --no-cache-dir -r "${resolved_path}"; then
437+
if "${app_root}/.venv/bin/pip" install --no-cache-dir -r "${resolved_path}"; then
432438
return 0
433439
fi
434440
echo "⚠️ Plugin package install attempt ${attempt}/${max_retries} failed"
@@ -439,6 +445,10 @@ install_plugin_requirements() {
439445
return 1
440446
}
441447

448+
if [[ "${CONTEXTFORGE_TEST_ONLY_SOURCE:-false}" = "true" ]]; then
449+
return 0 2>/dev/null || exit 0
450+
fi
451+
442452
apply_rust_mcp_mode_defaults
443453
install_plugin_requirements
444454
build_server_command "$@"
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Direct unit tests for docker-entrypoint.sh plugin requirement reload logic."""
2+
3+
from __future__ import annotations
4+
5+
import stat
6+
import subprocess
7+
from pathlib import Path
8+
9+
REPO_ROOT = Path(__file__).resolve().parents[2]
10+
ENTRYPOINT = REPO_ROOT / "docker-entrypoint.sh"
11+
12+
13+
def _write_executable(path: Path, content: str) -> None:
14+
path.write_text(content, encoding="utf-8")
15+
path.chmod(path.stat().st_mode | stat.S_IXUSR)
16+
17+
18+
def _make_app_root(tmp_path: Path) -> Path:
19+
app_root = tmp_path / "app"
20+
(app_root / ".venv" / "bin").mkdir(parents=True)
21+
(app_root / "plugins").mkdir()
22+
return app_root
23+
24+
25+
def _run_install_plugin_requirements(app_root: Path, requirements_path: Path | None = None) -> subprocess.CompletedProcess[str]:
26+
command = f"""
27+
set -euo pipefail
28+
export CONTEXTFORGE_TEST_ONLY_SOURCE=true
29+
export APP_ROOT="{app_root}"
30+
source "{ENTRYPOINT}"
31+
export RELOAD_PLUGIN_REQUIREMENTS_TXT=true
32+
export PLUGIN_REQUIREMENTS_TXT_PATH="{requirements_path or app_root / 'plugins' / 'requirements.txt'}"
33+
install_plugin_requirements
34+
"""
35+
return subprocess.run(
36+
["bash", "-lc", command],
37+
capture_output=True,
38+
text=True,
39+
cwd=REPO_ROOT,
40+
check=False,
41+
)
42+
43+
44+
def test_install_plugin_requirements_refuses_path_outside_app_root(tmp_path: Path) -> None:
45+
app_root = _make_app_root(tmp_path)
46+
outside_requirements = tmp_path / "outside.txt"
47+
outside_requirements.write_text("cpex-rate-limiter==0.0.3\n", encoding="utf-8")
48+
49+
result = _run_install_plugin_requirements(app_root, outside_requirements)
50+
51+
assert result.returncode == 1
52+
assert "must resolve under" in result.stdout
53+
54+
55+
def test_install_plugin_requirements_refuses_missing_file(tmp_path: Path) -> None:
56+
app_root = _make_app_root(tmp_path)
57+
missing_requirements = app_root / "plugins" / "missing.txt"
58+
59+
result = _run_install_plugin_requirements(app_root, missing_requirements)
60+
61+
assert result.returncode == 1
62+
assert "not found" in result.stdout
63+
64+
65+
def test_install_plugin_requirements_retries_three_times_then_fails(tmp_path: Path) -> None:
66+
app_root = _make_app_root(tmp_path)
67+
requirements = app_root / "plugins" / "requirements.txt"
68+
requirements.write_text("cpex-rate-limiter==0.0.3\n", encoding="utf-8")
69+
attempts_file = tmp_path / "attempts.txt"
70+
_write_executable(
71+
app_root / ".venv" / "bin" / "pip",
72+
f"""#!/usr/bin/env bash
73+
set -euo pipefail
74+
echo attempt >> "{attempts_file}"
75+
exit 1
76+
""",
77+
)
78+
79+
result = _run_install_plugin_requirements(app_root, requirements)
80+
81+
assert result.returncode == 1
82+
assert attempts_file.read_text(encoding="utf-8").count("attempt") == 3
83+
assert "failed after 3 attempts" in result.stdout
84+
85+
86+
def test_install_plugin_requirements_succeeds_after_retry(tmp_path: Path) -> None:
87+
app_root = _make_app_root(tmp_path)
88+
requirements = app_root / "plugins" / "requirements.txt"
89+
requirements.write_text("# comment\n\ncpex-rate-limiter==0.0.3\n", encoding="utf-8")
90+
attempts_file = tmp_path / "attempts.txt"
91+
_write_executable(
92+
app_root / ".venv" / "bin" / "pip",
93+
f"""#!/usr/bin/env bash
94+
set -euo pipefail
95+
count=0
96+
if [[ -f "{attempts_file}" ]]; then
97+
count=$(wc -l < "{attempts_file}")
98+
fi
99+
echo attempt >> "{attempts_file}"
100+
if [[ "$count" -lt 1 ]]; then
101+
exit 1
102+
fi
103+
exit 0
104+
""",
105+
)
106+
107+
result = _run_install_plugin_requirements(app_root, requirements)
108+
109+
assert result.returncode == 0
110+
assert attempts_file.read_text(encoding="utf-8").count("attempt") == 2
111+
assert "Installing 1 plugin package requirement" in result.stdout
112+
assert "attempt 1/3 failed" in result.stdout
113+
114+
115+
def test_install_plugin_requirements_skips_when_reload_disabled(tmp_path: Path) -> None:
116+
app_root = _make_app_root(tmp_path)
117+
marker = tmp_path / "pip-called.txt"
118+
_write_executable(
119+
app_root / ".venv" / "bin" / "pip",
120+
f"""#!/usr/bin/env bash
121+
set -euo pipefail
122+
echo called > "{marker}"
123+
exit 0
124+
""",
125+
)
126+
command = f"""
127+
set -euo pipefail
128+
export CONTEXTFORGE_TEST_ONLY_SOURCE=true
129+
export APP_ROOT="{app_root}"
130+
source "{ENTRYPOINT}"
131+
export RELOAD_PLUGIN_REQUIREMENTS_TXT=false
132+
install_plugin_requirements
133+
"""
134+
135+
result = subprocess.run(
136+
["bash", "-lc", command],
137+
capture_output=True,
138+
text=True,
139+
cwd=REPO_ROOT,
140+
check=False,
141+
)
142+
143+
assert result.returncode == 0
144+
assert not marker.exists()
145+
assert result.stdout == ""

0 commit comments

Comments
 (0)