test_misc.py 65 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044
  1. # Regression tests for interactive setup wizard.
  2. # Classification: keep tests here when they cover cross-cutting setup helpers, finalization/migration, backup, or security-check behavior that does not fit collect_/env_/generate_/validate_ buckets.
  3. from __future__ import annotations
  4. import subprocess
  5. from pathlib import Path
  6. import pytest
  7. from tests.setup._helpers import (
  8. REPO_ROOT,
  9. assert_single_compose_backup,
  10. parse_lines,
  11. run_bash,
  12. run_bash_process,
  13. run_bash_lines,
  14. write_text_lines,
  15. )
  16. pytestmark = pytest.mark.offline
  17. def test_prepare_compose_runtime_overrides_keeps_env_unchanged() -> None:
  18. """Loopback endpoints should be rewritten only for compose overrides."""
  19. output = run_bash(f"""
  20. set -euo pipefail
  21. source "{REPO_ROOT}/scripts/setup/setup.sh"
  22. reset_state
  23. ENV_VALUES[LLM_BINDING_HOST]="http://localhost:11434"
  24. ENV_VALUES[EMBEDDING_BINDING_HOST]="http://127.0.0.1:11434"
  25. ENV_VALUES[RERANK_BINDING_HOST]="http://localhost:8000/rerank"
  26. prepare_compose_runtime_overrides
  27. printf 'ENV_LLM=%s\\n' "${{ENV_VALUES[LLM_BINDING_HOST]}}"
  28. printf 'ENV_EMBEDDING=%s\\n' "${{ENV_VALUES[EMBEDDING_BINDING_HOST]}}"
  29. printf 'ENV_RERANK=%s\\n' "${{ENV_VALUES[RERANK_BINDING_HOST]}}"
  30. printf 'COMPOSE_LLM=%s\\n' "${{COMPOSE_ENV_OVERRIDES[LLM_BINDING_HOST]}}"
  31. printf 'COMPOSE_EMBEDDING=%s\\n' "${{COMPOSE_ENV_OVERRIDES[EMBEDDING_BINDING_HOST]}}"
  32. printf 'COMPOSE_RERANK=%s\\n' "${{COMPOSE_ENV_OVERRIDES[RERANK_BINDING_HOST]}}\"
  33. """)
  34. values = parse_lines(output)
  35. assert values["ENV_LLM"] == "http://localhost:11434"
  36. assert values["ENV_EMBEDDING"] == "http://127.0.0.1:11434"
  37. assert values["ENV_RERANK"] == "http://localhost:8000/rerank"
  38. assert values["COMPOSE_LLM"] == "http://host.docker.internal:11434"
  39. assert values["COMPOSE_EMBEDDING"] == "http://host.docker.internal:11434"
  40. assert values["COMPOSE_RERANK"] == "http://host.docker.internal:8000/rerank"
  41. def test_existing_ssl_env_keeps_compose_mount_overrides(tmp_path: Path) -> None:
  42. """Compose regeneration should preserve working SSL mounts without implying `.env` is permanently dual-purpose."""
  43. compose_file = tmp_path / "docker-compose.yml"
  44. compose_file.write_text(
  45. "\n".join(
  46. [
  47. "services:",
  48. " lightrag:",
  49. " image: example/lightrag:test",
  50. " env_file:",
  51. " - .env",
  52. ]
  53. )
  54. + "\n",
  55. encoding="utf-8",
  56. )
  57. cert_path = tmp_path / "cert.pem"
  58. cert_path.write_text("cert", encoding="utf-8")
  59. key_path = tmp_path / "key.pem"
  60. key_path.write_text("key", encoding="utf-8")
  61. env_file = tmp_path / ".env"
  62. env_file.write_text(
  63. "\n".join(["SSL=true", f"SSL_CERTFILE={cert_path}", f"SSL_KEYFILE={key_path}"])
  64. + "\n",
  65. encoding="utf-8",
  66. )
  67. run_bash(f"""
  68. set -euo pipefail
  69. source "{REPO_ROOT}/scripts/setup/setup.sh"
  70. REPO_ROOT="{tmp_path}"
  71. reset_state
  72. load_existing_env_if_present
  73. prepare_compose_env_overrides
  74. stage_ssl_assets "$SSL_CERT_SOURCE_PATH" "$SSL_KEY_SOURCE_PATH"
  75. generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml\"
  76. """)
  77. generated_compose = (tmp_path / "docker-compose.generated.yml").read_text(
  78. encoding="utf-8"
  79. )
  80. assert 'SSL_CERTFILE: "/app/data/certs/cert.pem"' in generated_compose
  81. assert 'SSL_KEYFILE: "/app/data/certs/key.pem"' in generated_compose
  82. assert "./data/certs/cert.pem:/app/data/certs/cert.pem:ro" in generated_compose
  83. assert "./data/certs/key.pem:/app/data/certs/key.pem:ro" in generated_compose
  84. def test_finalize_base_setup_rewrites_ssl_env_to_preserved_compose_paths(
  85. tmp_path: Path,
  86. ) -> None:
  87. """Compose-target reruns should rewrite broken SSL source paths to preserved staged compose paths."""
  88. staged_dir = tmp_path / "data" / "certs"
  89. staged_dir.mkdir(parents=True)
  90. (staged_dir / "server.pem").write_text("cert", encoding="utf-8")
  91. (staged_dir / "server.key").write_text("key", encoding="utf-8")
  92. write_text_lines(
  93. tmp_path / ".env",
  94. [
  95. "SSL=true",
  96. "SSL_CERTFILE=/missing/original-cert.pem",
  97. "SSL_KEYFILE=/missing/original-key.pem",
  98. "LIGHTRAG_KV_STORAGE=JsonKVStorage",
  99. "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage",
  100. "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage",
  101. "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage",
  102. ],
  103. )
  104. write_text_lines(
  105. tmp_path / "env.example",
  106. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  107. )
  108. write_text_lines(
  109. tmp_path / "docker-compose.final.yml",
  110. [
  111. "services:",
  112. " lightrag:",
  113. " image: example/lightrag:test",
  114. " volumes:",
  115. " - ./.env:/app/.env",
  116. " - ./data/certs/server.pem:/app/data/certs/server.pem:ro",
  117. " - ./data/certs/server.key:/app/data/certs/server.key:ro",
  118. " environment:",
  119. " SSL_CERTFILE: /app/data/certs/server.pem",
  120. " SSL_KEYFILE: /app/data/certs/server.key",
  121. ],
  122. )
  123. output = run_bash(f"""
  124. set -euo pipefail
  125. source "{REPO_ROOT}/scripts/setup/setup.sh"
  126. REPO_ROOT="{tmp_path}"
  127. reset_state
  128. load_existing_env_if_present
  129. initialize_default_storage_backends
  130. show_summary() {{ :; }}
  131. confirm_default_yes() {{
  132. case "$1" in
  133. "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 1 ;;
  134. *) return 0 ;;
  135. esac
  136. }}
  137. confirm_required_yes_no() {{ return 0; }}
  138. finalize_base_setup
  139. if validate_env_file; then
  140. printf 'VALID=yes\\n'
  141. else
  142. printf 'VALID=no\\n'
  143. fi
  144. """)
  145. values = parse_lines(output)
  146. generated_env = (tmp_path / ".env").read_text(encoding="utf-8")
  147. assert "SSL_CERTFILE=/app/data/certs/server.pem" in generated_env
  148. assert "SSL_KEYFILE=/app/data/certs/server.key" in generated_env
  149. assert values["VALID"] == "yes"
  150. def test_removing_ssl_strips_wizard_bind_mounts_from_compose(tmp_path: Path) -> None:
  151. """Re-running setup without SSL must remove only wizard-managed SSL mounts."""
  152. compose_file = tmp_path / "docker-compose.final.yml"
  153. compose_file.write_text(
  154. "\n".join(
  155. [
  156. "services:",
  157. " lightrag:",
  158. " image: example/lightrag:test",
  159. " volumes:",
  160. ' - "./data/certs/cert.pem:/app/data/certs/cert.pem:ro"',
  161. ' - "./data/certs/key.pem:/app/data/certs/key.pem:ro"',
  162. ' - "./data/rag_storage:/app/data/rag_storage"',
  163. ' - "./data/inputs:/app/data/inputs"',
  164. ' - "./custom-data:/app/data/custom"',
  165. " environment:",
  166. ' SSL_CERTFILE: "/app/data/certs/cert.pem"',
  167. ' SSL_KEYFILE: "/app/data/certs/key.pem"',
  168. ]
  169. )
  170. + "\n",
  171. encoding="utf-8",
  172. )
  173. (tmp_path / "env.example").write_text(
  174. (REPO_ROOT / "env.example").read_text(encoding="utf-8"), encoding="utf-8"
  175. )
  176. run_bash(f"""
  177. set -euo pipefail
  178. source "{REPO_ROOT}/scripts/setup/setup.sh"
  179. REPO_ROOT="{tmp_path}"
  180. reset_state
  181. generate_docker_compose "{tmp_path}/docker-compose.final.yml\"
  182. """)
  183. result = compose_file.read_text(encoding="utf-8")
  184. assert "/app/data/certs/cert.pem" not in result
  185. assert "/app/data/certs/key.pem" not in result
  186. assert "./data/rag_storage:/app/data/rag_storage" in result
  187. assert "./data/inputs:/app/data/inputs" in result
  188. assert "./custom-data:/app/data/custom" in result
  189. def test_find_generated_compose_file_prefers_final_compose_file(tmp_path: Path) -> None:
  190. """Compose discovery should prefer docker-compose.final.yml over legacy files."""
  191. write_text_lines(tmp_path / ".env", ["HOST=0.0.0.0"])
  192. write_text_lines(
  193. tmp_path / "docker-compose.final.yml",
  194. ["services:", " lightrag:", " image: final/lightrag"],
  195. )
  196. write_text_lines(
  197. tmp_path / "docker-compose.development.yml",
  198. ["services:", " lightrag:", " image: dev/lightrag"],
  199. )
  200. write_text_lines(
  201. tmp_path / "docker-compose.production.yml",
  202. ["services:", " lightrag:", " image: prod/lightrag"],
  203. )
  204. output = run_bash(f"""
  205. set -euo pipefail
  206. source "{REPO_ROOT}/scripts/setup/setup.sh"
  207. REPO_ROOT="{tmp_path}"
  208. printf 'COMPOSE=%s\\n' "$(find_generated_compose_file)\"
  209. """)
  210. values = parse_lines(output)
  211. assert values["COMPOSE"] == str(tmp_path / "docker-compose.final.yml")
  212. def test_find_generated_compose_file_falls_back_to_order_without_profile(
  213. tmp_path: Path,
  214. ) -> None:
  215. """Without legacy profile metadata, compose migration should use the default order."""
  216. write_text_lines(tmp_path / ".env", ["HOST=0.0.0.0"])
  217. write_text_lines(
  218. tmp_path / "docker-compose.development.yml",
  219. ["services:", " lightrag:", " image: dev/lightrag"],
  220. )
  221. write_text_lines(
  222. tmp_path / "docker-compose.production.yml",
  223. ["services:", " lightrag:", " image: prod/lightrag"],
  224. )
  225. output = run_bash(f"""
  226. set -euo pipefail
  227. source "{REPO_ROOT}/scripts/setup/setup.sh"
  228. REPO_ROOT="{tmp_path}"
  229. printf 'COMPOSE=%s\\n' "$(find_generated_compose_file)\"
  230. """)
  231. values = parse_lines(output)
  232. assert values["COMPOSE"] == str(tmp_path / "docker-compose.development.yml")
  233. def test_switching_both_providers_off_bedrock_preserves_saved_aws_credentials(
  234. tmp_path: Path,
  235. ) -> None:
  236. """Switching LLM/Embedding away from Bedrock must preserve user-set AWS_* values.
  237. AWS credentials are process-level SDK settings and may be used by code paths
  238. outside the active LLM/embedding binding (S3, SecretsManager, etc.), so the
  239. wizard must not erase them just because the active binding is no longer
  240. ``bedrock``. Only the explicit ``collect_bedrock_credentials`` ambient branch
  241. is allowed to clear them.
  242. """
  243. write_text_lines(
  244. tmp_path / ".env",
  245. [
  246. "LLM_BINDING=bedrock",
  247. "LLM_MODEL=anthropic.claude-3-5-sonnet-20241022-v2:0",
  248. "LLM_BINDING_HOST=https://bedrock.amazonaws.com",
  249. "EMBEDDING_BINDING=bedrock",
  250. "EMBEDDING_MODEL=amazon.titan-embed-text-v2:0",
  251. "EMBEDDING_DIM=1024",
  252. "EMBEDDING_BINDING_HOST=https://bedrock.amazonaws.com",
  253. "AWS_ACCESS_KEY_ID=AKIAOLDKEY",
  254. "AWS_SECRET_ACCESS_KEY=oldsecretvalue",
  255. "AWS_SESSION_TOKEN=oldsess",
  256. "AWS_REGION=us-east-1",
  257. ],
  258. )
  259. write_text_lines(
  260. tmp_path / "env.example",
  261. [
  262. "# AWS_ACCESS_KEY_ID=your_aws_access_key_id",
  263. "# AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key",
  264. "# AWS_SESSION_TOKEN=your_optional_aws_session_token",
  265. "# AWS_REGION=us-east-1",
  266. "LLM_BINDING=openai",
  267. "LLM_MODEL=gpt-4o",
  268. "LLM_BINDING_HOST=https://api.openai.com/v1",
  269. "LLM_BINDING_API_KEY=your_api_key",
  270. "EMBEDDING_BINDING=openai",
  271. "EMBEDDING_MODEL=text-embedding-3-large",
  272. "EMBEDDING_DIM=3072",
  273. "EMBEDDING_BINDING_HOST=https://api.openai.com/v1",
  274. "EMBEDDING_BINDING_API_KEY=your_api_key",
  275. ],
  276. )
  277. output = run_bash(f"""
  278. set -euo pipefail
  279. source "{REPO_ROOT}/scripts/setup/setup.sh"
  280. REPO_ROOT="{tmp_path}"
  281. reset_state
  282. load_existing_env_if_present
  283. prompt_choice() {{ printf 'openai'; }}
  284. prompt_with_default() {{ printf '%s' "$2"; }}
  285. prompt_secret_until_valid_with_default() {{ printf 'fresh-key'; }}
  286. collect_llm_config
  287. collect_embedding_config
  288. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env.generated"
  289. printf 'AWS_ACCESS_KEY_ID=%s\\n' "${{ENV_VALUES[AWS_ACCESS_KEY_ID]-}}"
  290. printf 'AWS_SECRET_ACCESS_KEY=%s\\n' "${{ENV_VALUES[AWS_SECRET_ACCESS_KEY]-}}"
  291. printf 'AWS_SESSION_TOKEN=%s\\n' "${{ENV_VALUES[AWS_SESSION_TOKEN]-}}"
  292. printf 'AWS_REGION=%s\\n' "${{ENV_VALUES[AWS_REGION]-}}"
  293. """)
  294. values = parse_lines(output)
  295. generated_lines = (
  296. (tmp_path / ".env.generated").read_text(encoding="utf-8").splitlines()
  297. )
  298. assert values["AWS_ACCESS_KEY_ID"] == "AKIAOLDKEY"
  299. assert values["AWS_SECRET_ACCESS_KEY"] == "oldsecretvalue"
  300. assert values["AWS_SESSION_TOKEN"] == "oldsess"
  301. assert values["AWS_REGION"] == "us-east-1"
  302. assert "AWS_ACCESS_KEY_ID=AKIAOLDKEY" in generated_lines
  303. assert "AWS_SECRET_ACCESS_KEY=oldsecretvalue" in generated_lines
  304. assert "AWS_SESSION_TOKEN=oldsess" in generated_lines
  305. assert "AWS_REGION=us-east-1" in generated_lines
  306. def test_load_existing_env_forces_cohere_binding_for_vllm_rerank(
  307. tmp_path: Path,
  308. ) -> None:
  309. """Loading a Docker-managed vLLM rerank config should normalize the binding to cohere."""
  310. write_text_lines(
  311. tmp_path / ".env",
  312. [
  313. "RERANK_BINDING=jina",
  314. "LIGHTRAG_SETUP_RERANK_PROVIDER=vllm",
  315. "RERANK_BINDING_HOST=http://localhost:8000/rerank",
  316. ],
  317. )
  318. values = run_bash_lines(f"""
  319. set -euo pipefail
  320. source "{REPO_ROOT}/scripts/setup/setup.sh"
  321. REPO_ROOT="{tmp_path}"
  322. reset_state
  323. load_existing_env_if_present
  324. printf 'RERANK_BINDING=%s\\n' "${{ENV_VALUES[RERANK_BINDING]}}"
  325. printf 'LIGHTRAG_SETUP_RERANK_PROVIDER=%s\\n' "${{ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]}}\"
  326. """)
  327. assert values["RERANK_BINDING"] == "cohere"
  328. assert values["LIGHTRAG_SETUP_RERANK_PROVIDER"] == "vllm"
  329. @pytest.mark.parametrize(
  330. ("llm_binding", "embedding_binding", "expected_llm_host", "expected_embed_host"),
  331. [
  332. ("bedrock", "bedrock", "DEFAULT_BEDROCK_ENDPOINT", "DEFAULT_BEDROCK_ENDPOINT"),
  333. ("gemini", "gemini", "DEFAULT_GEMINI_ENDPOINT", "DEFAULT_GEMINI_ENDPOINT"),
  334. ("bedrock", "openai", "DEFAULT_BEDROCK_ENDPOINT", ""),
  335. ],
  336. ids=["bedrock-both", "gemini-both", "bedrock-llm-only"],
  337. )
  338. def test_load_existing_env_backfills_sentinel_hosts_for_bedrock_and_gemini(
  339. tmp_path: Path,
  340. llm_binding: str,
  341. embedding_binding: str,
  342. expected_llm_host: str,
  343. expected_embed_host: str,
  344. ) -> None:
  345. """Flows that skip collect_*_config (--server, --storage) must not let env.example's openai URL leak through for sentinel-based providers."""
  346. write_text_lines(
  347. tmp_path / ".env",
  348. [
  349. f"LLM_BINDING={llm_binding}",
  350. f"EMBEDDING_BINDING={embedding_binding}",
  351. ],
  352. )
  353. write_text_lines(
  354. tmp_path / "env.example",
  355. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  356. )
  357. values = run_bash_lines(f"""
  358. set -euo pipefail
  359. source "{REPO_ROOT}/scripts/setup/setup.sh"
  360. REPO_ROOT="{tmp_path}"
  361. reset_state
  362. load_existing_env_if_present
  363. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env.generated"
  364. printf 'LOADED_LLM_HOST=%s\\n' "${{ENV_VALUES[LLM_BINDING_HOST]:-}}"
  365. printf 'LOADED_EMBED_HOST=%s\\n' "${{ENV_VALUES[EMBEDDING_BINDING_HOST]:-}}\"
  366. """)
  367. assert values["LOADED_LLM_HOST"] == expected_llm_host
  368. generated_lines = (
  369. (tmp_path / ".env.generated").read_text(encoding="utf-8").splitlines()
  370. )
  371. llm_host_line = next(
  372. line for line in generated_lines if line.startswith("LLM_BINDING_HOST=")
  373. )
  374. assert llm_host_line == f"LLM_BINDING_HOST={expected_llm_host}"
  375. if expected_embed_host:
  376. assert values["LOADED_EMBED_HOST"] == expected_embed_host
  377. embed_host_line = next(
  378. line
  379. for line in generated_lines
  380. if line.startswith("EMBEDDING_BINDING_HOST=")
  381. )
  382. assert embed_host_line == f"EMBEDDING_BINDING_HOST={expected_embed_host}"
  383. def test_finalize_base_setup_uses_compose_native_storage_endpoints_on_rerun(
  384. tmp_path: Path,
  385. ) -> None:
  386. """Preserved managed storage services should inject compose-native endpoints on base reruns."""
  387. write_text_lines(
  388. tmp_path / ".env",
  389. [
  390. "LIGHTRAG_RUNTIME_TARGET=compose",
  391. "LIGHTRAG_SETUP_NEO4J_DEPLOYMENT=docker",
  392. "LIGHTRAG_SETUP_MILVUS_DEPLOYMENT=docker",
  393. "NEO4J_URI=neo4j://localhost:7687",
  394. "MILVUS_URI=http://localhost:19530",
  395. "LIGHTRAG_KV_STORAGE=JsonKVStorage",
  396. "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage",
  397. "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage",
  398. "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage",
  399. ],
  400. )
  401. write_text_lines(
  402. tmp_path / "env.example",
  403. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  404. )
  405. write_text_lines(
  406. tmp_path / "docker-compose.final.yml",
  407. [
  408. "services:",
  409. " lightrag:",
  410. " image: example/lightrag:test",
  411. " neo4j:",
  412. " image: neo4j:latest",
  413. " milvus:",
  414. " image: milvusdb/milvus:v2.6.11",
  415. " milvus-etcd:",
  416. " image: quay.io/coreos/etcd:v3.5.16",
  417. " milvus-minio:",
  418. " image: minio/minio:latest",
  419. "volumes:",
  420. " neo4j_data:",
  421. " milvus_data:",
  422. " milvus-etcd_data:",
  423. " milvus-minio_data:",
  424. ],
  425. )
  426. run_bash(f"""
  427. set -euo pipefail
  428. source "{REPO_ROOT}/scripts/setup/setup.sh"
  429. REPO_ROOT="{tmp_path}"
  430. reset_state
  431. load_existing_env_if_present
  432. show_summary() {{ :; }}
  433. confirm_required_yes_no() {{ return 0; }}
  434. confirm_default_yes() {{ return 0; }}
  435. validate_sensitive_env_literals() {{ return 0; }}
  436. finalize_base_setup
  437. """)
  438. result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8")
  439. assert 'NEO4J_URI: "neo4j://neo4j:7687"' in result
  440. assert 'MILVUS_URI: "http://milvus:19530"' in result
  441. assert 'NEO4J_URI: "neo4j://host.docker.internal:7687"' not in result
  442. assert 'MILVUS_URI: "http://host.docker.internal:19530"' not in result
  443. assert (
  444. """ milvus:
  445. condition: service_healthy"""
  446. in result
  447. )
  448. assert (
  449. """ milvus-etcd:
  450. condition: service_healthy"""
  451. not in result
  452. )
  453. assert (
  454. """ milvus-minio:
  455. condition: service_healthy"""
  456. not in result
  457. )
  458. def test_finalize_base_setup_migrates_mongodb_to_atlas_local_for_mongo_vector_storage(
  459. tmp_path: Path,
  460. ) -> None:
  461. """Base reruns should upgrade docker-managed MongoDB to Atlas Local when Mongo vector storage needs it."""
  462. write_text_lines(
  463. tmp_path / ".env",
  464. [
  465. "LIGHTRAG_RUNTIME_TARGET=compose",
  466. "LIGHTRAG_SETUP_MONGODB_DEPLOYMENT=docker",
  467. "LIGHTRAG_KV_STORAGE=MongoKVStorage",
  468. "LIGHTRAG_VECTOR_STORAGE=MongoVectorDBStorage",
  469. "LIGHTRAG_GRAPH_STORAGE=MongoGraphStorage",
  470. "LIGHTRAG_DOC_STATUS_STORAGE=MongoDocStatusStorage",
  471. "MONGO_URI=mongodb://localhost:27017/?directConnection=true",
  472. "MONGO_DATABASE=LightRAG",
  473. ],
  474. )
  475. write_text_lines(
  476. tmp_path / "env.example",
  477. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  478. )
  479. write_text_lines(
  480. tmp_path / "docker-compose.final.yml",
  481. [
  482. "services:",
  483. " lightrag:",
  484. " image: example/lightrag:test",
  485. " mongodb:",
  486. " image: mongo:8.2.4",
  487. " volumes:",
  488. " - mongo_data:/data/db",
  489. "volumes:",
  490. " mongo_data:",
  491. ],
  492. )
  493. run_bash(f"""
  494. set -euo pipefail
  495. source "{REPO_ROOT}/scripts/setup/setup.sh"
  496. REPO_ROOT="{tmp_path}"
  497. reset_state
  498. load_existing_env_if_present
  499. show_summary() {{ :; }}
  500. confirm_required_yes_no() {{ return 0; }}
  501. confirm_default_yes() {{ return 0; }}
  502. validate_sensitive_env_literals() {{ return 0; }}
  503. finalize_base_setup
  504. """)
  505. result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8")
  506. assert "image: mongodb/mongodb-atlas-local:" in result
  507. assert "mongo_config_data:/data/configdb" in result
  508. assert "mongo_mongot_data:/data/mongot" in result
  509. assert "image: mongo:8.2.4" not in result
  510. def test_finalize_base_setup_rejects_invalid_preserved_mongo_vector_config(
  511. tmp_path: Path,
  512. ) -> None:
  513. """Base reruns should fail before writing when preserved Mongo vector config is invalid."""
  514. write_text_lines(
  515. tmp_path / ".env",
  516. [
  517. "LIGHTRAG_RUNTIME_TARGET=compose",
  518. "LIGHTRAG_SETUP_MONGODB_DEPLOYMENT=docker",
  519. "LIGHTRAG_KV_STORAGE=MongoKVStorage",
  520. "LIGHTRAG_VECTOR_STORAGE=MongoVectorDBStorage",
  521. "LIGHTRAG_GRAPH_STORAGE=MongoGraphStorage",
  522. "LIGHTRAG_DOC_STATUS_STORAGE=MongoDocStatusStorage",
  523. "MONGO_URI=mongodb://mongo.example.com:27017/?directConnection=true",
  524. "MONGO_DATABASE=LightRAG",
  525. ],
  526. )
  527. write_text_lines(
  528. tmp_path / "env.example",
  529. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  530. )
  531. write_text_lines(
  532. tmp_path / "docker-compose.final.yml",
  533. [
  534. "services:",
  535. " lightrag:",
  536. " image: example/lightrag:test",
  537. " mongodb:",
  538. " image: mongo:8.2.4",
  539. " volumes:",
  540. " - mongo_data:/data/db",
  541. "volumes:",
  542. " mongo_data:",
  543. ],
  544. )
  545. result = run_bash_process(
  546. f"""
  547. set -euo pipefail
  548. source "{REPO_ROOT}/scripts/setup/setup.sh"
  549. REPO_ROOT="{tmp_path}"
  550. reset_state
  551. load_existing_env_if_present
  552. show_summary() {{ :; }}
  553. confirm_required_yes_no() {{ return 0; }}
  554. confirm_default_yes() {{ return 0; }}
  555. validate_sensitive_env_literals() {{ return 0; }}
  556. finalize_base_setup
  557. """,
  558. cwd=tmp_path,
  559. )
  560. assert result.returncode != 0
  561. assert (
  562. "MongoVectorDBStorage requires the bundled Atlas Local endpoint"
  563. in result.stderr
  564. )
  565. assert "image: mongo:8.2.4" in (tmp_path / "docker-compose.final.yml").read_text(
  566. encoding="utf-8"
  567. )
  568. def test_finalize_base_setup_drops_stale_storage_services_missing_from_env_markers(
  569. tmp_path: Path,
  570. ) -> None:
  571. """env-base should treat storage Docker state in `.env` as authoritative."""
  572. write_text_lines(
  573. tmp_path / ".env",
  574. [
  575. "LIGHTRAG_RUNTIME_TARGET=compose",
  576. "LLM_BINDING=openai",
  577. "LLM_MODEL=gpt-4o-mini",
  578. "LLM_BINDING_HOST=https://api.openai.com/v1",
  579. "LLM_BINDING_API_KEY=sk-existing",
  580. "EMBEDDING_BINDING=openai",
  581. "EMBEDDING_MODEL=text-embedding-3-small",
  582. "EMBEDDING_DIM=1536",
  583. "EMBEDDING_BINDING_HOST=https://api.openai.com/v1",
  584. "EMBEDDING_BINDING_API_KEY=sk-existing",
  585. "LIGHTRAG_KV_STORAGE=JsonKVStorage",
  586. "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage",
  587. "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage",
  588. "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage",
  589. ],
  590. )
  591. write_text_lines(
  592. tmp_path / "env.example",
  593. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  594. )
  595. write_text_lines(
  596. tmp_path / "docker-compose.final.yml",
  597. [
  598. "services:",
  599. " lightrag:",
  600. " image: example/lightrag:test",
  601. " redis:",
  602. " image: redis:latest",
  603. " qdrant:",
  604. " image: qdrant/qdrant:latest",
  605. "volumes:",
  606. " redis_data:",
  607. " qdrant_data:",
  608. ],
  609. )
  610. run_bash(f"""
  611. set -euo pipefail
  612. source "{REPO_ROOT}/scripts/setup/setup.sh"
  613. REPO_ROOT="{tmp_path}"
  614. reset_state
  615. load_existing_env_if_present
  616. show_summary() {{ :; }}
  617. confirm_required_yes_no() {{ return 0; }}
  618. confirm_default_yes() {{ return 1; }}
  619. confirm_default_no() {{ return 1; }}
  620. validate_sensitive_env_literals() {{ return 0; }}
  621. finalize_base_setup
  622. """)
  623. result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8")
  624. generated_env = (tmp_path / ".env").read_text(encoding="utf-8")
  625. assert " lightrag:" in result
  626. assert " redis:" not in result
  627. assert " qdrant:" not in result
  628. assert "redis_data:" not in result
  629. assert "qdrant_data:" not in result
  630. assert "LIGHTRAG_RUNTIME_TARGET=compose" in generated_env
  631. @pytest.mark.parametrize(
  632. ("changed_key", "changed_value", "expected_rewrite"),
  633. [
  634. ("NEO4J_PASSWORD", "updated-password", "no"),
  635. ("NEO4J_DATABASE", "updated-database", "yes"),
  636. ],
  637. ids=["neo4j-password-does-not-rewrite", "neo4j-database-rewrites"],
  638. )
  639. def test_configure_storage_compose_rewrites_only_rewrites_neo4j_on_database_change(
  640. changed_key: str, changed_value: str, expected_rewrite: str
  641. ) -> None:
  642. """Neo4j service rewrites should be driven by database changes, not credentials."""
  643. output = run_bash(f"""
  644. set -euo pipefail
  645. source "{REPO_ROOT}/scripts/setup/setup.sh"
  646. reset_state
  647. EXISTING_MANAGED_ROOT_SERVICE_SET[neo4j]=1
  648. DOCKER_SERVICE_SET[neo4j]=1
  649. ORIGINAL_ENV_VALUES[NEO4J_PASSWORD]="original-password"
  650. ORIGINAL_ENV_VALUES[NEO4J_DATABASE]="neo4j"
  651. ENV_VALUES[NEO4J_PASSWORD]="original-password"
  652. ENV_VALUES[NEO4J_DATABASE]="neo4j"
  653. ENV_VALUES[{changed_key}]="{changed_value}"
  654. configure_storage_compose_rewrites
  655. if [[ -n "${{COMPOSE_REWRITE_SERVICE_SET[neo4j]+set}}" ]]; then
  656. printf 'REWRITE=yes\\n'
  657. else
  658. printf 'REWRITE=no\\n'
  659. fi
  660. """)
  661. values = parse_lines(output)
  662. assert values["REWRITE"] == expected_rewrite
  663. @pytest.mark.parametrize(
  664. ("changed_key", "changed_value", "expected_rewrite"),
  665. [
  666. ("POSTGRES_HOST", "db.example.com", "no"),
  667. ("POSTGRES_PORT", "6543", "no"),
  668. ("POSTGRES_USER", "updated-user", "yes"),
  669. ("POSTGRES_PASSWORD", "updated-password", "yes"),
  670. ("POSTGRES_DATABASE", "updated-database", "yes"),
  671. ],
  672. ids=[
  673. "postgres-host-does-not-rewrite",
  674. "postgres-port-does-not-rewrite",
  675. "postgres-user-rewrites",
  676. "postgres-password-rewrites",
  677. "postgres-database-rewrites",
  678. ],
  679. )
  680. def test_configure_storage_compose_rewrites_only_rewrites_postgres_for_service_env_changes(
  681. changed_key: str, changed_value: str, expected_rewrite: str
  682. ) -> None:
  683. """Postgres service rewrites should only follow changes emitted into the postgres block."""
  684. output = run_bash(f"""
  685. set -euo pipefail
  686. source "{REPO_ROOT}/scripts/setup/setup.sh"
  687. reset_state
  688. EXISTING_MANAGED_ROOT_SERVICE_SET[postgres]=1
  689. DOCKER_SERVICE_SET[postgres]=1
  690. ORIGINAL_ENV_VALUES[POSTGRES_HOST]="localhost"
  691. ORIGINAL_ENV_VALUES[POSTGRES_PORT]="5432"
  692. ORIGINAL_ENV_VALUES[POSTGRES_USER]="rag"
  693. ORIGINAL_ENV_VALUES[POSTGRES_PASSWORD]="rag"
  694. ORIGINAL_ENV_VALUES[POSTGRES_DATABASE]="lightrag"
  695. ENV_VALUES[POSTGRES_HOST]="localhost"
  696. ENV_VALUES[POSTGRES_PORT]="5432"
  697. ENV_VALUES[POSTGRES_USER]="rag"
  698. ENV_VALUES[POSTGRES_PASSWORD]="rag"
  699. ENV_VALUES[POSTGRES_DATABASE]="lightrag"
  700. ENV_VALUES[{changed_key}]="{changed_value}"
  701. configure_storage_compose_rewrites
  702. if [[ -n "${{COMPOSE_REWRITE_SERVICE_SET[postgres]+set}}" ]]; then
  703. printf 'REWRITE=yes\\n'
  704. else
  705. printf 'REWRITE=no\\n'
  706. fi
  707. """)
  708. values = parse_lines(output)
  709. assert values["REWRITE"] == expected_rewrite
  710. @pytest.mark.parametrize(
  711. ("vector_storage", "deployment_marker", "expected_rewrite"),
  712. [
  713. ("MongoVectorDBStorage", "docker", "yes"),
  714. ("NanoVectorDBStorage", "docker", "no"),
  715. ("MongoVectorDBStorage", "", "no"),
  716. ],
  717. ids=[
  718. "mongo-vector-with-docker-rewrites",
  719. "non-mongo-vector-does-not-rewrite",
  720. "mongo-vector-without-docker-does-not-rewrite",
  721. ],
  722. )
  723. def test_configure_mongodb_compose_migration_rewrite_only_runs_for_atlas_local_vector_path(
  724. tmp_path: Path, vector_storage: str, deployment_marker: str, expected_rewrite: str
  725. ) -> None:
  726. """Atlas Local migration should only run for docker-managed MongoDB vector storage."""
  727. write_text_lines(
  728. tmp_path / "docker-compose.final.yml",
  729. [
  730. "services:",
  731. " lightrag:",
  732. " image: example/lightrag:test",
  733. " mongodb:",
  734. " image: mongo:8.2.4",
  735. " volumes:",
  736. " - mongo_data:/data/db",
  737. "volumes:",
  738. " mongo_data:",
  739. ],
  740. )
  741. values = run_bash_lines(f"""
  742. set -euo pipefail
  743. source "{REPO_ROOT}/scripts/setup/setup.sh"
  744. REPO_ROOT="{tmp_path}"
  745. reset_state
  746. ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="{vector_storage}"
  747. ENV_VALUES[LIGHTRAG_SETUP_MONGODB_DEPLOYMENT]="{deployment_marker}"
  748. EXISTING_MANAGED_ROOT_SERVICE_SET[mongodb]=1
  749. DOCKER_SERVICE_SET[mongodb]=1
  750. configure_mongodb_compose_migration_rewrite "$REPO_ROOT/docker-compose.final.yml"
  751. if [[ -n "${{COMPOSE_REWRITE_SERVICE_SET[mongodb]+set}}" ]]; then
  752. printf 'REWRITE=yes\\n'
  753. else
  754. printf 'REWRITE=no\\n'
  755. fi
  756. """)
  757. assert values["REWRITE"] == expected_rewrite
  758. def test_configure_mongodb_compose_migration_rewrite_repairs_missing_mongot_volume(
  759. tmp_path: Path,
  760. ) -> None:
  761. """Atlas Local compose rewrites should repair stale MongoDB services missing mongot persistence."""
  762. write_text_lines(
  763. tmp_path / "docker-compose.final.yml",
  764. [
  765. "services:",
  766. " lightrag:",
  767. " image: example/lightrag:test",
  768. " mongodb:",
  769. " image: mongodb/mongodb-atlas-local:8",
  770. " volumes:",
  771. " - mongo_data:/data/db",
  772. " - mongo_config_data:/data/configdb",
  773. "volumes:",
  774. " mongo_data:",
  775. " mongo_config_data:",
  776. ],
  777. )
  778. values = run_bash_lines(f"""
  779. set -euo pipefail
  780. source "{REPO_ROOT}/scripts/setup/setup.sh"
  781. REPO_ROOT="{tmp_path}"
  782. reset_state
  783. ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="MongoVectorDBStorage"
  784. ENV_VALUES[LIGHTRAG_SETUP_MONGODB_DEPLOYMENT]="docker"
  785. EXISTING_MANAGED_ROOT_SERVICE_SET[mongodb]=1
  786. DOCKER_SERVICE_SET[mongodb]=1
  787. configure_mongodb_compose_migration_rewrite "$REPO_ROOT/docker-compose.final.yml"
  788. if [[ -n "${{COMPOSE_REWRITE_SERVICE_SET[mongodb]+set}}" ]]; then
  789. printf 'REWRITE=yes\\n'
  790. else
  791. printf 'REWRITE=no\\n'
  792. fi
  793. """)
  794. assert values["REWRITE"] == "yes"
  795. def test_switching_to_non_docker_storage_removes_stale_services_from_compose(
  796. tmp_path: Path,
  797. ) -> None:
  798. """env-storage must strip managed storage services while preserving user sidecars."""
  799. compose_file = tmp_path / "docker-compose.final.yml"
  800. compose_file.write_text(
  801. "\n".join(
  802. [
  803. "services:",
  804. " lightrag:",
  805. " image: example/lightrag:test",
  806. " postgres:",
  807. " image: gzdaniel/postgres-for-rag:pg18-age-pgvector",
  808. " neo4j:",
  809. " image: neo4j:5.26.21-community",
  810. " sidecar:",
  811. " image: busybox",
  812. ' command: ["sleep", "infinity"]',
  813. " volumes:",
  814. " - sidecar_data:/data",
  815. "volumes:",
  816. " postgres_data:",
  817. " neo4j_data:",
  818. " sidecar_data:",
  819. ]
  820. )
  821. + "\n",
  822. encoding="utf-8",
  823. )
  824. env_file = tmp_path / ".env"
  825. env_file.write_text("LLM_BINDING=openai\n", encoding="utf-8")
  826. (tmp_path / "env.example").write_text(
  827. (REPO_ROOT / "env.example").read_text(encoding="utf-8"), encoding="utf-8"
  828. )
  829. (tmp_path / "docker-compose.yml").write_text(
  830. (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8"), encoding="utf-8"
  831. )
  832. run_bash(f"""
  833. set -euo pipefail
  834. source "{REPO_ROOT}/scripts/setup/setup.sh"
  835. REPO_ROOT="{tmp_path}"
  836. reset_state
  837. select_storage_backends() {{
  838. ENV_VALUES[LIGHTRAG_KV_STORAGE]="JsonKVStorage"
  839. ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="NanoVectorDBStorage"
  840. ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="NetworkXStorage"
  841. ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="JsonDocStatusStorage"
  842. }}
  843. collect_database_config() {{ :; }}
  844. collect_docker_image_tags() {{ :; }}
  845. validate_required_variables() {{ return 0; }}
  846. confirm_default_yes() {{ return 0; }}
  847. confirm_default_no() {{ return 1; }}
  848. confirm_required_yes_no() {{ return 0; }}
  849. env_storage_flow
  850. """)
  851. result = compose_file.read_text(encoding="utf-8")
  852. assert "postgres:" not in result
  853. assert "neo4j:" not in result
  854. assert "postgres_data:" not in result
  855. assert "neo4j_data:" not in result
  856. assert " lightrag:" in result
  857. assert " sidecar:" in result
  858. assert "sidecar_data:" in result
  859. @pytest.mark.parametrize(
  860. ("env_key", "env_value", "expected_value"),
  861. [
  862. ("POSTGRES_HOST", "127.0.0.1", "host.docker.internal"),
  863. ("REDIS_URI", "redis://localhost:6379", "redis://host.docker.internal:6379"),
  864. (
  865. "MONGO_URI",
  866. "mongodb://127.0.0.1:27017/",
  867. "mongodb://host.docker.internal:27017/",
  868. ),
  869. (
  870. "MONGO_URI",
  871. "mongodb://root:root@localhost:27017/",
  872. "mongodb://root:root@host.docker.internal:27017/",
  873. ),
  874. ("NEO4J_URI", "neo4j://localhost:7687", "neo4j://host.docker.internal:7687"),
  875. ("MILVUS_URI", "http://localhost:19530", "http://host.docker.internal:19530"),
  876. ("QDRANT_URL", "http://127.0.0.1:6333", "http://host.docker.internal:6333"),
  877. ("MEMGRAPH_URI", "bolt://localhost:7687", "bolt://host.docker.internal:7687"),
  878. ("POSTGRES_HOST", "0.0.0.0", "host.docker.internal"),
  879. (
  880. "LLM_BINDING_HOST",
  881. "http://0.0.0.0:11434",
  882. "http://host.docker.internal:11434",
  883. ),
  884. (
  885. "RERANK_BINDING_HOST",
  886. "http://0.0.0.0:8000/rerank",
  887. "http://host.docker.internal:8000/rerank",
  888. ),
  889. ],
  890. ids=[
  891. "postgres-loopback-host",
  892. "redis-loopback-uri",
  893. "mongo-loopback-uri",
  894. "mongo-authenticated-loopback-uri",
  895. "neo4j-loopback-uri",
  896. "milvus-loopback-uri",
  897. "qdrant-loopback-uri",
  898. "memgraph-loopback-uri",
  899. "postgres-zero-host",
  900. "llm-zero-host",
  901. "rerank-zero-host",
  902. ],
  903. )
  904. def test_prepare_compose_runtime_overrides_rewrites_container_endpoints(
  905. env_key: str, env_value: str, expected_value: str
  906. ) -> None:
  907. """Loopback and 0.0.0.0 endpoints should be rewritten for container reachability."""
  908. values = run_bash_lines(f"""
  909. set -euo pipefail
  910. source "{REPO_ROOT}/scripts/setup/setup.sh"
  911. reset_state
  912. ENV_VALUES[{env_key}]="{env_value}"
  913. prepare_compose_runtime_overrides
  914. printf '{env_key}=%s\\n' "${{COMPOSE_ENV_OVERRIDES[{env_key}]}}\"
  915. """)
  916. assert values[env_key] == expected_value
  917. @pytest.mark.parametrize(
  918. ("host_value", "expected_port_mapping"),
  919. [
  920. ("127.0.0.1", "${HOST:-0.0.0.0}:${PORT:-9621}:9621"),
  921. ("192.168.1.10", "${HOST:-0.0.0.0}:${PORT:-9621}:9621"),
  922. ],
  923. ids=["loopback-bind", "lan-bind"],
  924. )
  925. def test_prepare_compose_runtime_overrides_normalizes_server_binding(
  926. host_value: str, expected_port_mapping: str
  927. ) -> None:
  928. """Compose runtime should keep variable-based publishing while fixing container bind values."""
  929. values = run_bash_lines(f"""
  930. set -euo pipefail
  931. source "{REPO_ROOT}/scripts/setup/setup.sh"
  932. reset_state
  933. ENV_VALUES[HOST]="{host_value}"
  934. ENV_VALUES[PORT]="8080"
  935. prepare_compose_runtime_overrides
  936. printf 'HOST=%s\\n' "${{COMPOSE_ENV_OVERRIDES[HOST]}}"
  937. printf 'PORT=%s\\n' "${{COMPOSE_ENV_OVERRIDES[PORT]}}"
  938. printf 'PORT_MAPPING=%s\\n' "${{LIGHTRAG_COMPOSE_SERVER_PORT_MAPPING}}\"
  939. """)
  940. assert values["HOST"] == "0.0.0.0"
  941. assert values["PORT"] == "9621"
  942. assert values["PORT_MAPPING"] == expected_port_mapping
  943. def test_finalize_server_setup_skips_embedded_milvus_sub_services(
  944. tmp_path: Path,
  945. ) -> None:
  946. """finalize_server_setup must keep prefixed Milvus child services on rerun."""
  947. compose_file = tmp_path / "docker-compose.final.yml"
  948. compose_file.write_text(
  949. "\n".join(
  950. [
  951. "services:",
  952. " lightrag:",
  953. " image: example/lightrag:test",
  954. " milvus:",
  955. " image: milvusdb/milvus:v2.6.11",
  956. " milvus-etcd:",
  957. " image: quay.io/coreos/etcd:v3.5.16",
  958. " milvus-minio:",
  959. " image: minio/minio:RELEASE.2024-12-13T22-19-12Z",
  960. "volumes:",
  961. " milvus_data:",
  962. " milvus-etcd_data:",
  963. " milvus-minio_data:",
  964. ]
  965. )
  966. + "\n",
  967. encoding="utf-8",
  968. )
  969. (tmp_path / "env.example").write_text(
  970. (REPO_ROOT / "env.example").read_text(encoding="utf-8"), encoding="utf-8"
  971. )
  972. write_text_lines(tmp_path / ".env", ["LIGHTRAG_SETUP_MILVUS_DEPLOYMENT=docker"])
  973. run_bash(f"""
  974. set -euo pipefail
  975. source "{REPO_ROOT}/scripts/setup/setup.sh"
  976. REPO_ROOT="{tmp_path}"
  977. reset_state
  978. load_existing_env_if_present
  979. collect_server_config() {{ :; }}
  980. collect_security_config() {{ :; }}
  981. collect_ssl_config() {{ :; }}
  982. confirm_required_yes_no() {{ return 0; }}
  983. finalize_server_setup
  984. """)
  985. result = compose_file.read_text(encoding="utf-8")
  986. assert "milvus" in result
  987. assert "milvus-etcd" in result
  988. assert "milvus-minio" in result
  989. assert (
  990. """ milvus:
  991. condition: service_healthy"""
  992. in result
  993. )
  994. assert (
  995. """ milvus-etcd:
  996. condition: service_healthy"""
  997. not in result
  998. )
  999. assert (
  1000. """ milvus-minio:
  1001. condition: service_healthy"""
  1002. not in result
  1003. )
  1004. def test_finalize_server_setup_uses_compose_native_neo4j_endpoint_on_rerun(
  1005. tmp_path: Path,
  1006. ) -> None:
  1007. """Preserved managed services should inject compose-native endpoints on server reruns."""
  1008. write_text_lines(
  1009. tmp_path / ".env",
  1010. ["LIGHTRAG_SETUP_NEO4J_DEPLOYMENT=docker", "NEO4J_URI=neo4j://localhost:7687"],
  1011. )
  1012. write_text_lines(
  1013. tmp_path / "env.example",
  1014. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  1015. )
  1016. write_text_lines(
  1017. tmp_path / "docker-compose.final.yml",
  1018. [
  1019. "services:",
  1020. " lightrag:",
  1021. " image: example/lightrag:test",
  1022. " neo4j:",
  1023. " image: neo4j:latest",
  1024. ],
  1025. )
  1026. run_bash(f"""
  1027. set -euo pipefail
  1028. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1029. REPO_ROOT="{tmp_path}"
  1030. reset_state
  1031. load_existing_env_if_present
  1032. show_summary() {{ :; }}
  1033. confirm_required_yes_no() {{ return 0; }}
  1034. validate_sensitive_env_literals() {{ return 0; }}
  1035. validate_security_config() {{ return 0; }}
  1036. finalize_server_setup
  1037. """)
  1038. result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8")
  1039. assert 'NEO4J_URI: "neo4j://neo4j:7687"' in result
  1040. assert 'NEO4J_URI: "neo4j://host.docker.internal:7687"' not in result
  1041. def test_finalize_server_setup_migrates_mongodb_to_atlas_local_for_mongo_vector_storage(
  1042. tmp_path: Path,
  1043. ) -> None:
  1044. """Server reruns should upgrade docker-managed MongoDB to Atlas Local when Mongo vector storage needs it."""
  1045. write_text_lines(
  1046. tmp_path / ".env",
  1047. [
  1048. "LIGHTRAG_RUNTIME_TARGET=compose",
  1049. "LIGHTRAG_SETUP_MONGODB_DEPLOYMENT=docker",
  1050. "LIGHTRAG_KV_STORAGE=MongoKVStorage",
  1051. "LIGHTRAG_VECTOR_STORAGE=MongoVectorDBStorage",
  1052. "LIGHTRAG_GRAPH_STORAGE=MongoGraphStorage",
  1053. "LIGHTRAG_DOC_STATUS_STORAGE=MongoDocStatusStorage",
  1054. "MONGO_URI=mongodb://localhost:27017/?directConnection=true",
  1055. "MONGO_DATABASE=LightRAG",
  1056. "HOST=0.0.0.0",
  1057. "PORT=9621",
  1058. ],
  1059. )
  1060. write_text_lines(
  1061. tmp_path / "env.example",
  1062. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  1063. )
  1064. write_text_lines(
  1065. tmp_path / "docker-compose.final.yml",
  1066. [
  1067. "services:",
  1068. " lightrag:",
  1069. " image: example/lightrag:test",
  1070. " mongodb:",
  1071. " image: mongo:8.2.4",
  1072. " volumes:",
  1073. " - mongo_data:/data/db",
  1074. "volumes:",
  1075. " mongo_data:",
  1076. ],
  1077. )
  1078. run_bash(f"""
  1079. set -euo pipefail
  1080. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1081. REPO_ROOT="{tmp_path}"
  1082. reset_state
  1083. load_existing_env_if_present
  1084. show_summary() {{ :; }}
  1085. confirm_required_yes_no() {{ return 0; }}
  1086. validate_sensitive_env_literals() {{ return 0; }}
  1087. validate_security_config() {{ return 0; }}
  1088. finalize_server_setup
  1089. """)
  1090. result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8")
  1091. assert "image: mongodb/mongodb-atlas-local:" in result
  1092. assert "mongo_config_data:/data/configdb" in result
  1093. assert "mongo_mongot_data:/data/mongot" in result
  1094. assert "image: mongo:8.2.4" not in result
  1095. def test_finalize_server_setup_rejects_invalid_preserved_mongo_vector_config(
  1096. tmp_path: Path,
  1097. ) -> None:
  1098. """Server reruns should fail before writing when preserved Mongo vector config is invalid."""
  1099. write_text_lines(
  1100. tmp_path / ".env",
  1101. [
  1102. "LIGHTRAG_RUNTIME_TARGET=compose",
  1103. "LIGHTRAG_SETUP_MONGODB_DEPLOYMENT=docker",
  1104. "LIGHTRAG_KV_STORAGE=MongoKVStorage",
  1105. "LIGHTRAG_VECTOR_STORAGE=MongoVectorDBStorage",
  1106. "LIGHTRAG_GRAPH_STORAGE=MongoGraphStorage",
  1107. "LIGHTRAG_DOC_STATUS_STORAGE=MongoDocStatusStorage",
  1108. "MONGO_URI=mongodb://mongo.example.com:27017/?directConnection=true",
  1109. "MONGO_DATABASE=LightRAG",
  1110. "HOST=0.0.0.0",
  1111. "PORT=9621",
  1112. ],
  1113. )
  1114. write_text_lines(
  1115. tmp_path / "env.example",
  1116. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  1117. )
  1118. write_text_lines(
  1119. tmp_path / "docker-compose.final.yml",
  1120. [
  1121. "services:",
  1122. " lightrag:",
  1123. " image: example/lightrag:test",
  1124. " mongodb:",
  1125. " image: mongo:8.2.4",
  1126. " volumes:",
  1127. " - mongo_data:/data/db",
  1128. "volumes:",
  1129. " mongo_data:",
  1130. ],
  1131. )
  1132. result = run_bash_process(
  1133. f"""
  1134. set -euo pipefail
  1135. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1136. REPO_ROOT="{tmp_path}"
  1137. reset_state
  1138. load_existing_env_if_present
  1139. show_summary() {{ :; }}
  1140. confirm_required_yes_no() {{ return 0; }}
  1141. validate_sensitive_env_literals() {{ return 0; }}
  1142. validate_security_config() {{ return 0; }}
  1143. finalize_server_setup
  1144. """,
  1145. cwd=tmp_path,
  1146. )
  1147. assert result.returncode != 0
  1148. assert (
  1149. "MongoVectorDBStorage requires the bundled Atlas Local endpoint"
  1150. in result.stderr
  1151. )
  1152. assert "image: mongo:8.2.4" in (tmp_path / "docker-compose.final.yml").read_text(
  1153. encoding="utf-8"
  1154. )
  1155. def test_finalize_server_setup_drops_stale_managed_services_missing_from_env_markers(
  1156. tmp_path: Path,
  1157. ) -> None:
  1158. """env-server should remove stale wizard-managed services not marked in `.env`."""
  1159. write_text_lines(tmp_path / ".env", ["HOST=0.0.0.0", "PORT=9621"])
  1160. write_text_lines(
  1161. tmp_path / "env.example",
  1162. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  1163. )
  1164. write_text_lines(
  1165. tmp_path / "docker-compose.final.yml",
  1166. [
  1167. "services:",
  1168. " lightrag:",
  1169. " image: example/lightrag:test",
  1170. " redis:",
  1171. " image: redis:latest",
  1172. " vllm-embed:",
  1173. " image: vllm/vllm-openai:latest",
  1174. "volumes:",
  1175. " redis_data:",
  1176. " vllm_embed_cache:",
  1177. ],
  1178. )
  1179. run_bash(f"""
  1180. set -euo pipefail
  1181. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1182. REPO_ROOT="{tmp_path}"
  1183. reset_state
  1184. load_existing_env_if_present
  1185. show_summary() {{ :; }}
  1186. collect_server_config() {{ :; }}
  1187. collect_security_config() {{ :; }}
  1188. collect_ssl_config() {{ :; }}
  1189. confirm_required_yes_no() {{ return 0; }}
  1190. confirm_default_yes() {{
  1191. case "$1" in
  1192. "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 1 ;;
  1193. *) return 0 ;;
  1194. esac
  1195. }}
  1196. validate_sensitive_env_literals() {{ return 0; }}
  1197. validate_security_config() {{ return 0; }}
  1198. finalize_server_setup
  1199. """)
  1200. result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8")
  1201. generated_env = (tmp_path / ".env").read_text(encoding="utf-8")
  1202. assert " redis:" not in result
  1203. assert " vllm-embed:" not in result
  1204. assert "redis_data:" not in result
  1205. assert "vllm_embed_cache:" not in result
  1206. assert " lightrag:" in result
  1207. assert "LIGHTRAG_RUNTIME_TARGET=compose" in generated_env
  1208. def test_detect_managed_root_services_deduplicates_embedded_milvus_children(
  1209. tmp_path: Path,
  1210. ) -> None:
  1211. """Managed service discovery should collapse Milvus child services to the root service."""
  1212. write_text_lines(
  1213. tmp_path / "docker-compose.final.yml",
  1214. [
  1215. "services:",
  1216. " lightrag:",
  1217. " image: example/lightrag:test",
  1218. " milvus:",
  1219. " image: milvusdb/milvus:v2.6.11",
  1220. " milvus-etcd:",
  1221. " image: quay.io/coreos/etcd:v3.5.16",
  1222. " milvus-minio:",
  1223. " image: minio/minio:latest",
  1224. " neo4j:",
  1225. " image: neo4j:latest",
  1226. ],
  1227. )
  1228. output = run_bash(f"""
  1229. set -euo pipefail
  1230. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1231. detect_managed_root_services "{tmp_path}/docker-compose.final.yml\"
  1232. """)
  1233. assert output.splitlines() == ["milvus", "neo4j"]
  1234. def test_detect_managed_root_services_groups_opensearch_dashboards_under_opensearch(
  1235. tmp_path: Path,
  1236. ) -> None:
  1237. """opensearch-dashboards must collapse to the opensearch root so orphan dashboards blocks don't masquerade as external services."""
  1238. write_text_lines(
  1239. tmp_path / "docker-compose.final.yml",
  1240. [
  1241. "services:",
  1242. " lightrag:",
  1243. " image: example/lightrag:test",
  1244. " opensearch:",
  1245. " image: opensearchproject/opensearch:3",
  1246. " dashboards:",
  1247. " image: opensearchproject/opensearch-dashboards:3",
  1248. ],
  1249. )
  1250. output = run_bash(f"""
  1251. set -euo pipefail
  1252. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1253. detect_managed_root_services "{tmp_path}/docker-compose.final.yml\"
  1254. """)
  1255. assert output.splitlines() == ["opensearch"]
  1256. def test_compose_has_non_wizard_services_ignores_orphan_dashboards(
  1257. tmp_path: Path,
  1258. ) -> None:
  1259. """A leftover opensearch-dashboards alone must not be treated as an external service that blocks the host-mode prompt."""
  1260. compose_file = tmp_path / "docker-compose.final.yml"
  1261. write_text_lines(
  1262. compose_file,
  1263. [
  1264. "services:",
  1265. " lightrag:",
  1266. " image: example/lightrag:test",
  1267. " dashboards:",
  1268. " image: opensearchproject/opensearch-dashboards:3",
  1269. ],
  1270. )
  1271. output = run_bash(f"""
  1272. set -euo pipefail
  1273. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1274. if compose_has_non_wizard_services "{compose_file}"; then
  1275. printf 'RESULT=non_wizard_detected\\n'
  1276. else
  1277. printf 'RESULT=wizard_only\\n'
  1278. fi
  1279. """)
  1280. values = parse_lines(output)
  1281. assert values["RESULT"] == "wizard_only"
  1282. def test_finalize_server_setup_allows_risky_security_config_and_security_check_reports_it(
  1283. tmp_path: Path,
  1284. ) -> None:
  1285. """Wizard writes `.env` without blocking, while security-check reports risky settings."""
  1286. write_text_lines(
  1287. tmp_path / ".env",
  1288. [
  1289. "AUTH_ACCOUNTS=admin:secret",
  1290. "TOKEN_SECRET=jwt-secret",
  1291. "WHITELIST_PATHS=/health,/api/*",
  1292. ],
  1293. )
  1294. write_text_lines(
  1295. tmp_path / "env.example",
  1296. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  1297. )
  1298. output = run_bash(f"""
  1299. set -euo pipefail
  1300. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1301. REPO_ROOT="{tmp_path}"
  1302. reset_state
  1303. load_existing_env_if_present
  1304. show_summary() {{ :; }}
  1305. confirm_default_yes() {{ return 0; }}
  1306. confirm_required_yes_no() {{ return 0; }}
  1307. if finalize_server_setup; then
  1308. printf 'RESULT=success\\n'
  1309. else
  1310. printf 'RESULT=failure\\n'
  1311. fi
  1312. """)
  1313. values = parse_lines(output)
  1314. assert values["RESULT"] == "success"
  1315. result = subprocess.run(
  1316. [
  1317. "bash",
  1318. "--norc",
  1319. "--noprofile",
  1320. "-c",
  1321. f"""
  1322. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1323. REPO_ROOT="{tmp_path}"
  1324. security_check_env_file
  1325. """,
  1326. ],
  1327. cwd=REPO_ROOT,
  1328. capture_output=True,
  1329. text=True,
  1330. check=False,
  1331. )
  1332. assert result.returncode == 1
  1333. assert "WHITELIST_PATHS exposes /api routes" in result.stdout
  1334. def test_finalize_server_setup_allows_predictable_auth_passwords_and_security_check_reports_it(
  1335. tmp_path: Path,
  1336. ) -> None:
  1337. """Server setup should not block on weak password prefixes that belong to security audit."""
  1338. write_text_lines(
  1339. tmp_path / ".env",
  1340. [
  1341. "AUTH_ACCOUNTS=admin:Passw0rd!",
  1342. "TOKEN_SECRET=jwt-secret",
  1343. "WHITELIST_PATHS=/health",
  1344. ],
  1345. )
  1346. write_text_lines(
  1347. tmp_path / "env.example",
  1348. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  1349. )
  1350. output = run_bash(f"""
  1351. set -euo pipefail
  1352. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1353. REPO_ROOT="{tmp_path}"
  1354. reset_state
  1355. load_existing_env_if_present
  1356. show_summary() {{ :; }}
  1357. confirm_default_yes() {{ return 0; }}
  1358. confirm_required_yes_no() {{ return 0; }}
  1359. if finalize_server_setup; then
  1360. printf 'RESULT=success\\n'
  1361. else
  1362. printf 'RESULT=failure\\n'
  1363. fi
  1364. """)
  1365. values = parse_lines(output)
  1366. assert values["RESULT"] == "success"
  1367. result = subprocess.run(
  1368. [
  1369. "bash",
  1370. "--norc",
  1371. "--noprofile",
  1372. "-c",
  1373. f"""
  1374. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1375. REPO_ROOT="{tmp_path}"
  1376. security_check_env_file
  1377. """,
  1378. ],
  1379. cwd=REPO_ROOT,
  1380. capture_output=True,
  1381. text=True,
  1382. check=False,
  1383. )
  1384. assert result.returncode == 1
  1385. assert "AUTH_ACCOUNTS uses a predictable password prefix." in result.stdout
  1386. def test_finalize_server_setup_rejects_malformed_auth_accounts(tmp_path: Path) -> None:
  1387. """Server setup should fail fast instead of persisting invalid AUTH_ACCOUNTS syntax."""
  1388. write_text_lines(tmp_path / ".env", ["HOST=0.0.0.0"])
  1389. write_text_lines(
  1390. tmp_path / "env.example",
  1391. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  1392. )
  1393. output = run_bash(
  1394. f"""
  1395. set -euo pipefail
  1396. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1397. REPO_ROOT="{tmp_path}"
  1398. reset_state
  1399. load_existing_env_if_present
  1400. collect_server_config() {{ :; }}
  1401. collect_ssl_config() {{ :; }}
  1402. ENV_VALUES[AUTH_ACCOUNTS]="admin"
  1403. ENV_VALUES[TOKEN_SECRET]="jwt-secret"
  1404. show_summary() {{ :; }}
  1405. confirm_default_yes() {{ return 0; }}
  1406. confirm_required_yes_no() {{ return 0; }}
  1407. if finalize_server_setup; then
  1408. printf 'RESULT=success\\n'
  1409. else
  1410. printf 'RESULT=failure\\n'
  1411. fi
  1412. printf 'ENV=%s\\n' "$(cat "$REPO_ROOT/.env")\"
  1413. """,
  1414. cwd=tmp_path,
  1415. )
  1416. values = parse_lines(output)
  1417. assert values["RESULT"] == "failure"
  1418. assert values["ENV"] == "HOST=0.0.0.0"
  1419. def test_ssl_staging_uses_distinct_names_for_same_basename_inputs(
  1420. tmp_path: Path,
  1421. ) -> None:
  1422. """Cert/key files with the same basename should stage to distinct paths."""
  1423. env_example = tmp_path / "env.example"
  1424. env_example.write_text(
  1425. "\n".join(
  1426. ["SSL_CERTFILE=/placeholder/cert.pem", "SSL_KEYFILE=/placeholder/key.pem"]
  1427. )
  1428. + "\n",
  1429. encoding="utf-8",
  1430. )
  1431. compose_file = tmp_path / "docker-compose.yml"
  1432. compose_file.write_text(
  1433. "\n".join(
  1434. [
  1435. "services:",
  1436. " lightrag:",
  1437. " image: example/lightrag:test",
  1438. " env_file:",
  1439. " - .env",
  1440. ]
  1441. )
  1442. + "\n",
  1443. encoding="utf-8",
  1444. )
  1445. cert_dir = tmp_path / "certs"
  1446. key_dir = tmp_path / "keys"
  1447. cert_dir.mkdir()
  1448. key_dir.mkdir()
  1449. cert_path = cert_dir / "server.pem"
  1450. cert_path.write_text("cert", encoding="utf-8")
  1451. key_path = key_dir / "server.pem"
  1452. key_path.write_text("key", encoding="utf-8")
  1453. run_bash(f"""
  1454. set -euo pipefail
  1455. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1456. REPO_ROOT="{tmp_path}"
  1457. reset_state
  1458. ENV_VALUES[SSL_CERTFILE]="{cert_path}"
  1459. ENV_VALUES[SSL_KEYFILE]="{key_path}"
  1460. SSL_CERT_SOURCE_PATH="{cert_path}"
  1461. SSL_KEY_SOURCE_PATH="{key_path}"
  1462. prepare_compose_env_overrides
  1463. stage_ssl_assets "$SSL_CERT_SOURCE_PATH" "$SSL_KEY_SOURCE_PATH"
  1464. generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml\"
  1465. """)
  1466. generated_compose = (tmp_path / "docker-compose.generated.yml").read_text(
  1467. encoding="utf-8"
  1468. )
  1469. staged_cert = tmp_path / "data" / "certs" / "cert-server.pem"
  1470. staged_key = tmp_path / "data" / "certs" / "key-server.pem"
  1471. assert staged_cert.read_text(encoding="utf-8") == "cert"
  1472. assert staged_key.read_text(encoding="utf-8") == "key"
  1473. assert 'SSL_CERTFILE: "/app/data/certs/cert-server.pem"' in generated_compose
  1474. assert 'SSL_KEYFILE: "/app/data/certs/key-server.pem"' in generated_compose
  1475. assert (
  1476. "./data/certs/cert-server.pem:/app/data/certs/cert-server.pem:ro"
  1477. in generated_compose
  1478. )
  1479. assert (
  1480. "./data/certs/key-server.pem:/app/data/certs/key-server.pem:ro"
  1481. in generated_compose
  1482. )
  1483. def test_ssl_staging_skips_copy_for_already_staged_relative_paths(
  1484. tmp_path: Path,
  1485. ) -> None:
  1486. """Re-running setup with already-staged certs should not fail on identical copies."""
  1487. staged_dir = tmp_path / "data" / "certs"
  1488. staged_dir.mkdir(parents=True)
  1489. cert_path = staged_dir / "server.pem"
  1490. key_path = staged_dir / "server.key"
  1491. cert_path.write_text("cert", encoding="utf-8")
  1492. key_path.write_text("key", encoding="utf-8")
  1493. run_bash(f"""
  1494. set -euo pipefail
  1495. cd "{tmp_path}"
  1496. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1497. REPO_ROOT="{tmp_path}"
  1498. reset_state
  1499. stage_ssl_assets "./data/certs/server.pem" "./data/certs/server.key\"
  1500. """)
  1501. assert cert_path.read_text(encoding="utf-8") == "cert"
  1502. assert key_path.read_text(encoding="utf-8") == "key"
  1503. @pytest.mark.parametrize(
  1504. ("name", "env_lines", "setup_snippet", "finalize_call"),
  1505. [
  1506. (
  1507. "base",
  1508. [],
  1509. "\n".join(
  1510. [
  1511. 'ENV_VALUES[VLLM_EMBED_DEVICE]="cpu"',
  1512. 'ENV_VALUES[VLLM_EMBED_MODEL]="BAAI/bge-m3"',
  1513. 'ENV_VALUES[VLLM_EMBED_PORT]="8001"',
  1514. 'ENV_VALUES[VLLM_EMBED_API_KEY]="local-key"',
  1515. 'add_docker_service "vllm-embed"',
  1516. "confirm_default_no() { return 1; }",
  1517. ]
  1518. ),
  1519. "finalize_base_setup",
  1520. ),
  1521. (
  1522. "storage",
  1523. [
  1524. "LIGHTRAG_KV_STORAGE=PGKVStorage",
  1525. "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage",
  1526. "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage",
  1527. "LIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage",
  1528. "POSTGRES_USER=lightrag",
  1529. "POSTGRES_PASSWORD=secret",
  1530. "POSTGRES_DATABASE=lightrag",
  1531. ],
  1532. 'add_docker_service "postgres"',
  1533. "finalize_storage_setup",
  1534. ),
  1535. ],
  1536. ids=["base", "storage"],
  1537. )
  1538. def test_finalize_flows_stage_inherited_ssl_assets_for_compose(
  1539. tmp_path: Path,
  1540. name: str,
  1541. env_lines: list[str],
  1542. setup_snippet: str,
  1543. finalize_call: str,
  1544. ) -> None:
  1545. """Compose-writing finalize flows should stage inherited SSL assets before mounting them."""
  1546. cert_path = tmp_path / f"{name}-source-cert.pem"
  1547. key_path = tmp_path / f"{name}-source-key.pem"
  1548. cert_path.write_text("cert", encoding="utf-8")
  1549. key_path.write_text("key", encoding="utf-8")
  1550. write_text_lines(
  1551. tmp_path / ".env",
  1552. [
  1553. *env_lines,
  1554. "SSL=true",
  1555. f"SSL_CERTFILE={cert_path}",
  1556. f"SSL_KEYFILE={key_path}",
  1557. ],
  1558. )
  1559. write_text_lines(
  1560. tmp_path / "env.example",
  1561. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  1562. )
  1563. (tmp_path / "docker-compose.yml").write_text(
  1564. (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8"), encoding="utf-8"
  1565. )
  1566. run_bash(f"""
  1567. set -euo pipefail
  1568. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1569. REPO_ROOT="{tmp_path}"
  1570. reset_state
  1571. load_existing_env_if_present
  1572. {setup_snippet}
  1573. show_summary() {{ :; }}
  1574. confirm_default_yes() {{ return 0; }}
  1575. confirm_required_yes_no() {{ return 0; }}
  1576. {finalize_call}
  1577. """)
  1578. generated_compose = (tmp_path / "docker-compose.final.yml").read_text(
  1579. encoding="utf-8"
  1580. )
  1581. staged_cert = tmp_path / "data" / "certs" / f"{name}-source-cert.pem"
  1582. staged_key = tmp_path / "data" / "certs" / f"{name}-source-key.pem"
  1583. assert staged_cert.read_text(encoding="utf-8") == "cert"
  1584. assert staged_key.read_text(encoding="utf-8") == "key"
  1585. assert (
  1586. f"./data/certs/{name}-source-cert.pem:/app/data/certs/{name}-source-cert.pem:ro"
  1587. in generated_compose
  1588. )
  1589. assert (
  1590. f"./data/certs/{name}-source-key.pem:/app/data/certs/{name}-source-key.pem:ro"
  1591. in generated_compose
  1592. )
  1593. def test_security_check_reports_missing_authentication(tmp_path: Path) -> None:
  1594. """Security audit should flag unauthenticated API exposure."""
  1595. write_text_lines(tmp_path / ".env", ["HOST=0.0.0.0"])
  1596. result = subprocess.run(
  1597. [
  1598. "bash",
  1599. "--norc",
  1600. "--noprofile",
  1601. "-c",
  1602. f"""
  1603. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1604. REPO_ROOT="{tmp_path}"
  1605. security_check_env_file
  1606. """,
  1607. ],
  1608. cwd=REPO_ROOT,
  1609. capture_output=True,
  1610. text=True,
  1611. check=False,
  1612. )
  1613. assert result.returncode == 1
  1614. assert "No API protection is configured." in result.stdout
  1615. def test_security_check_passes_for_authenticated_minimal_config(tmp_path: Path) -> None:
  1616. """Security audit should pass for a minimally hardened config."""
  1617. write_text_lines(
  1618. tmp_path / ".env",
  1619. [
  1620. "AUTH_ACCOUNTS=admin:secret",
  1621. "TOKEN_SECRET=jwt-secret",
  1622. "WHITELIST_PATHS=/health",
  1623. ],
  1624. )
  1625. result = subprocess.run(
  1626. [
  1627. "bash",
  1628. "--norc",
  1629. "--noprofile",
  1630. "-c",
  1631. f"""
  1632. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1633. REPO_ROOT="{tmp_path}"
  1634. security_check_env_file
  1635. """,
  1636. ],
  1637. cwd=REPO_ROOT,
  1638. capture_output=True,
  1639. text=True,
  1640. check=False,
  1641. )
  1642. assert result.returncode == 0
  1643. def test_security_check_reports_predictable_auth_password_prefix(
  1644. tmp_path: Path,
  1645. ) -> None:
  1646. """Security audit should flag AUTH_ACCOUNTS passwords with predictable prefixes."""
  1647. write_text_lines(
  1648. tmp_path / ".env",
  1649. [
  1650. "AUTH_ACCOUNTS=admin:admin123!",
  1651. "TOKEN_SECRET=jwt-secret",
  1652. "WHITELIST_PATHS=/health",
  1653. ],
  1654. )
  1655. result = subprocess.run(
  1656. [
  1657. "bash",
  1658. "--norc",
  1659. "--noprofile",
  1660. "-c",
  1661. f"""
  1662. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1663. REPO_ROOT="{tmp_path}"
  1664. security_check_env_file
  1665. """,
  1666. ],
  1667. cwd=REPO_ROOT,
  1668. capture_output=True,
  1669. text=True,
  1670. check=False,
  1671. )
  1672. assert result.returncode == 1
  1673. assert "AUTH_ACCOUNTS uses a predictable password prefix." in result.stdout
  1674. def test_security_check_reports_api_key_only_with_default_whitelist(
  1675. tmp_path: Path,
  1676. ) -> None:
  1677. """API-key-only deployment with unset WHITELIST_PATHS inherits /api/* and must be flagged."""
  1678. write_text_lines(tmp_path / ".env", ["LIGHTRAG_API_KEY=my-secret-key"])
  1679. result = subprocess.run(
  1680. [
  1681. "bash",
  1682. "--norc",
  1683. "--noprofile",
  1684. "-c",
  1685. f"""
  1686. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1687. REPO_ROOT="{tmp_path}"
  1688. security_check_env_file
  1689. """,
  1690. ],
  1691. cwd=REPO_ROOT,
  1692. capture_output=True,
  1693. text=True,
  1694. check=False,
  1695. )
  1696. assert result.returncode == 1
  1697. assert "WHITELIST_PATHS exposes /api routes" in result.stdout
  1698. def test_security_check_reports_api_key_only_with_explicit_api_wildcard_whitelist(
  1699. tmp_path: Path,
  1700. ) -> None:
  1701. """API-key-only deployment with WHITELIST_PATHS=/health,/api/* must be flagged."""
  1702. write_text_lines(
  1703. tmp_path / ".env",
  1704. ["LIGHTRAG_API_KEY=my-secret-key", "WHITELIST_PATHS=/health,/api/*"],
  1705. )
  1706. result = subprocess.run(
  1707. [
  1708. "bash",
  1709. "--norc",
  1710. "--noprofile",
  1711. "-c",
  1712. f"""
  1713. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1714. REPO_ROOT="{tmp_path}"
  1715. security_check_env_file
  1716. """,
  1717. ],
  1718. cwd=REPO_ROOT,
  1719. capture_output=True,
  1720. text=True,
  1721. check=False,
  1722. )
  1723. assert result.returncode == 1
  1724. assert "WHITELIST_PATHS exposes /api routes" in result.stdout
  1725. def test_security_check_passes_for_api_key_only_with_safe_whitelist(
  1726. tmp_path: Path,
  1727. ) -> None:
  1728. """API-key-only deployment with a safe WHITELIST_PATHS should pass the security check."""
  1729. write_text_lines(
  1730. tmp_path / ".env", ["LIGHTRAG_API_KEY=my-secret-key", "WHITELIST_PATHS=/health"]
  1731. )
  1732. result = subprocess.run(
  1733. [
  1734. "bash",
  1735. "--norc",
  1736. "--noprofile",
  1737. "-c",
  1738. f"""
  1739. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1740. REPO_ROOT="{tmp_path}"
  1741. security_check_env_file
  1742. """,
  1743. ],
  1744. cwd=REPO_ROOT,
  1745. capture_output=True,
  1746. text=True,
  1747. check=False,
  1748. )
  1749. assert result.returncode == 0
  1750. assert "No obvious security issues found" in result.stdout
  1751. def test_security_check_ignores_default_opensearch_password_when_opensearch_unused(
  1752. tmp_path: Path,
  1753. ) -> None:
  1754. """Security audit should ignore OpenSearch defaults when no OpenSearch storage is selected."""
  1755. write_text_lines(
  1756. tmp_path / ".env",
  1757. [
  1758. "AUTH_ACCOUNTS=admin:secret",
  1759. "TOKEN_SECRET=jwt-secret",
  1760. "WHITELIST_PATHS=/health",
  1761. "LIGHTRAG_KV_STORAGE=JsonKVStorage",
  1762. "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage",
  1763. "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage",
  1764. "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage",
  1765. "OPENSEARCH_PASSWORD=LightRAG2026_!@",
  1766. ],
  1767. )
  1768. result = subprocess.run(
  1769. [
  1770. "bash",
  1771. "--norc",
  1772. "--noprofile",
  1773. "-c",
  1774. f"""
  1775. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1776. REPO_ROOT="{tmp_path}"
  1777. security_check_env_file
  1778. """,
  1779. ],
  1780. cwd=REPO_ROOT,
  1781. capture_output=True,
  1782. text=True,
  1783. check=False,
  1784. )
  1785. assert result.returncode == 0
  1786. assert "OPENSEARCH_PASSWORD uses a well-known default value." not in result.stdout
  1787. def test_security_check_reports_default_opensearch_password_when_opensearch_selected(
  1788. tmp_path: Path,
  1789. ) -> None:
  1790. """Security audit should flag the default OpenSearch password when OpenSearch is selected."""
  1791. write_text_lines(
  1792. tmp_path / ".env",
  1793. [
  1794. "AUTH_ACCOUNTS=admin:secret",
  1795. "TOKEN_SECRET=jwt-secret",
  1796. "WHITELIST_PATHS=/health",
  1797. "LIGHTRAG_KV_STORAGE=OpenSearchKVStorage",
  1798. "LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage",
  1799. "LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage",
  1800. "LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage",
  1801. "OPENSEARCH_HOSTS=localhost:9200",
  1802. "OPENSEARCH_USER=admin",
  1803. "OPENSEARCH_PASSWORD=LightRAG2026_!@",
  1804. ],
  1805. )
  1806. result = subprocess.run(
  1807. [
  1808. "bash",
  1809. "--norc",
  1810. "--noprofile",
  1811. "-c",
  1812. f"""
  1813. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1814. REPO_ROOT="{tmp_path}"
  1815. security_check_env_file
  1816. """,
  1817. ],
  1818. cwd=REPO_ROOT,
  1819. capture_output=True,
  1820. text=True,
  1821. check=False,
  1822. )
  1823. assert result.returncode == 1
  1824. assert "OPENSEARCH_PASSWORD uses a well-known default value." in result.stdout
  1825. def test_show_summary_masks_auth_accounts() -> None:
  1826. """Configuration summaries should not print auth account passwords."""
  1827. output = run_bash(f"""
  1828. set -euo pipefail
  1829. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1830. reset_state
  1831. ENV_VALUES[AUTH_ACCOUNTS]="admin:secret,reader:hunter2"
  1832. ENV_VALUES[TOKEN_SECRET]="jwt-secret"
  1833. ENV_VALUES[HOST]="0.0.0.0"
  1834. show_summary
  1835. """)
  1836. assert "AUTH_ACCOUNTS=***" in output
  1837. assert "TOKEN_SECRET=***" in output
  1838. assert "admin:secret" not in output
  1839. assert "reader:hunter2" not in output
  1840. def test_opensearch_index_validators_accept_zero_padded_values() -> None:
  1841. """OpenSearch shard and replica validators should accept zero-padded decimals."""
  1842. values = run_bash_lines(f"""
  1843. set -euo pipefail
  1844. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1845. if validate_positive_integer "08"; then
  1846. printf 'SHARDS=valid\\n'
  1847. else
  1848. printf 'SHARDS=invalid\\n'
  1849. fi
  1850. if validate_non_negative_integer "09"; then
  1851. printf 'REPLICAS=valid\\n'
  1852. else
  1853. printf 'REPLICAS=invalid\\n'
  1854. fi
  1855. """)
  1856. assert values["SHARDS"] == "valid"
  1857. assert values["REPLICAS"] == "valid"
  1858. def test_backup_only_backs_up_env_and_generated_compose(tmp_path: Path) -> None:
  1859. """backup_only should back up both .env and the active generated compose file."""
  1860. compose_content = (
  1861. "\n".join(["services:", " lightrag:", " image: example/lightrag:test"])
  1862. + "\n"
  1863. )
  1864. write_text_lines(tmp_path / ".env", ["HOST=0.0.0.0"])
  1865. (tmp_path / "docker-compose.final.yml").write_text(
  1866. compose_content, encoding="utf-8"
  1867. )
  1868. output = run_bash(f"""
  1869. set -euo pipefail
  1870. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1871. REPO_ROOT="{tmp_path}"
  1872. backup_only
  1873. """)
  1874. env_backups = sorted(tmp_path.glob(".env.backup.*"))
  1875. assert len(env_backups) == 1
  1876. assert env_backups[0].read_text(encoding="utf-8") == "HOST=0.0.0.0\n"
  1877. assert "Backed up .env to" in output
  1878. assert "Backed up compose file to" in output
  1879. assert_single_compose_backup(tmp_path, compose_content)
  1880. def test_backup_only_skips_compose_backup_when_no_generated_compose_exists(
  1881. tmp_path: Path,
  1882. ) -> None:
  1883. """backup_only should still succeed when only .env exists."""
  1884. write_text_lines(tmp_path / ".env", ["HOST=0.0.0.0"])
  1885. output = run_bash(f"""
  1886. set -euo pipefail
  1887. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1888. REPO_ROOT="{tmp_path}"
  1889. backup_only
  1890. """)
  1891. env_backups = sorted(tmp_path.glob(".env.backup.*"))
  1892. assert len(env_backups) == 1
  1893. assert "Backed up .env to" in output
  1894. assert "Backed up compose file to" not in output
  1895. assert list(tmp_path.glob("docker-compose.backup*.yml")) == []