test_generate.py 60 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860
  1. # Regression tests for interactive setup wizard.
  2. # Classification: keep tests here when they cover generate_* helpers that render or rewrite .env and docker-compose file contents.
  3. from __future__ import annotations
  4. from pathlib import Path
  5. import pytest
  6. from tests.setup._helpers import (
  7. PRESERVED_HEADER,
  8. PRESERVED_NOTICE,
  9. REPO_ROOT,
  10. parse_lines,
  11. run_bash,
  12. write_text_lines,
  13. )
  14. pytestmark = pytest.mark.offline
  15. def test_generate_files_keep_host_env_values_and_inject_compose_overrides(
  16. tmp_path: Path,
  17. ) -> None:
  18. """This generation path keeps host-style values in `.env` and injects compose-only overrides separately."""
  19. env_example = tmp_path / "env.example"
  20. env_example.write_text(
  21. "\n".join(
  22. [
  23. "SSL_CERTFILE=/placeholder/cert.pem",
  24. "SSL_KEYFILE=/placeholder/key.pem",
  25. "LLM_BINDING_HOST=https://api.example.com/v1",
  26. "EMBEDDING_BINDING_HOST=https://api.example.com/v1",
  27. "RERANK_BINDING_HOST=https://api.example.com/v1",
  28. ]
  29. )
  30. + "\n",
  31. encoding="utf-8",
  32. )
  33. compose_file = tmp_path / "docker-compose.yml"
  34. compose_file.write_text(
  35. "\n".join(
  36. [
  37. "services:",
  38. " lightrag:",
  39. " image: example/lightrag:test",
  40. " env_file:",
  41. " - .env",
  42. " volumes:",
  43. " - ./.env:/app/.env",
  44. ]
  45. )
  46. + "\n",
  47. encoding="utf-8",
  48. )
  49. cert_path = tmp_path / "cert.pem"
  50. cert_path.write_text("cert", encoding="utf-8")
  51. key_path = tmp_path / "key.pem"
  52. key_path.write_text("key", encoding="utf-8")
  53. run_bash(f"""
  54. set -euo pipefail
  55. source "{REPO_ROOT}/scripts/setup/setup.sh"
  56. REPO_ROOT="{tmp_path}"
  57. reset_state
  58. ENV_VALUES[SSL_CERTFILE]="{cert_path}"
  59. ENV_VALUES[SSL_KEYFILE]="{key_path}"
  60. ENV_VALUES[LLM_BINDING_HOST]="http://localhost:11434"
  61. ENV_VALUES[EMBEDDING_BINDING_HOST]="http://127.0.0.1:11434"
  62. ENV_VALUES[RERANK_BINDING_HOST]="http://localhost:8000/rerank"
  63. SSL_CERT_SOURCE_PATH="{cert_path}"
  64. SSL_KEY_SOURCE_PATH="{key_path}"
  65. prepare_compose_env_overrides
  66. stage_ssl_assets "$SSL_CERT_SOURCE_PATH" "$SSL_KEY_SOURCE_PATH"
  67. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env"
  68. generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml\"
  69. """)
  70. generated_env = (tmp_path / ".env").read_text(encoding="utf-8")
  71. generated_compose = (tmp_path / "docker-compose.generated.yml").read_text(
  72. encoding="utf-8"
  73. )
  74. assert f"SSL_CERTFILE={cert_path}" in generated_env
  75. assert f"SSL_KEYFILE={key_path}" in generated_env
  76. assert "LLM_BINDING_HOST=http://localhost:11434" in generated_env
  77. assert "EMBEDDING_BINDING_HOST=http://127.0.0.1:11434" in generated_env
  78. assert "RERANK_BINDING_HOST=http://localhost:8000/rerank" in generated_env
  79. assert 'SSL_CERTFILE: "/app/data/certs/cert.pem"' in generated_compose
  80. assert 'SSL_KEYFILE: "/app/data/certs/key.pem"' in generated_compose
  81. assert 'LLM_BINDING_HOST: "http://host.docker.internal:11434"' in generated_compose
  82. assert (
  83. 'EMBEDDING_BINDING_HOST: "http://host.docker.internal:11434"'
  84. in generated_compose
  85. )
  86. assert (
  87. 'RERANK_BINDING_HOST: "http://host.docker.internal:8000/rerank"'
  88. in generated_compose
  89. )
  90. assert "./data/certs/cert.pem:/app/data/certs/cert.pem:ro" in generated_compose
  91. assert "./data/certs/key.pem:/app/data/certs/key.pem:ro" in generated_compose
  92. assert "env_file:" not in generated_compose
  93. def test_generate_docker_compose_removes_lightrag_env_file_to_preserve_dollar_values(
  94. tmp_path: Path,
  95. ) -> None:
  96. """Generated compose should remove `env_file` and skip empty environment blocks."""
  97. write_text_lines(
  98. tmp_path / "docker-compose.yml",
  99. [
  100. "services:",
  101. " lightrag:",
  102. " container_name: lightrag",
  103. " image: example/lightrag:test",
  104. " env_file:",
  105. " - .env",
  106. " volumes:",
  107. " - ./.env:/app/.env",
  108. ],
  109. )
  110. run_bash(f"""
  111. set -euo pipefail
  112. source "{REPO_ROOT}/scripts/setup/setup.sh"
  113. REPO_ROOT="{tmp_path}"
  114. reset_state
  115. generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml\"
  116. """)
  117. generated_compose = (tmp_path / "docker-compose.generated.yml").read_text(
  118. encoding="utf-8"
  119. )
  120. assert "env_file:" not in generated_compose
  121. assert "environment:" not in generated_compose
  122. assert "container_name:" not in generated_compose
  123. assert "- ./.env:/app/.env" in generated_compose
  124. def test_generate_docker_compose_removes_lightrag_container_name_from_existing_output(
  125. tmp_path: Path,
  126. ) -> None:
  127. """Compose regeneration should strip fixed lightrag container names from prior output."""
  128. write_text_lines(
  129. tmp_path / "docker-compose.final.yml",
  130. [
  131. "services:",
  132. " lightrag:",
  133. " container_name: lightrag",
  134. " image: example/lightrag:test",
  135. ],
  136. )
  137. run_bash(f"""
  138. set -euo pipefail
  139. source "{REPO_ROOT}/scripts/setup/setup.sh"
  140. REPO_ROOT="{tmp_path}"
  141. reset_state
  142. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  143. """)
  144. generated_compose = (tmp_path / "docker-compose.final.yml").read_text(
  145. encoding="utf-8"
  146. )
  147. assert "container_name:" not in generated_compose
  148. def test_generate_docker_compose_preserves_list_style_lightrag_environment(
  149. tmp_path: Path,
  150. ) -> None:
  151. """Compose regeneration should not mix mapping entries into list-style environments."""
  152. write_text_lines(
  153. tmp_path / "docker-compose.final.yml",
  154. [
  155. "services:",
  156. " lightrag:",
  157. " image: example/lightrag:test",
  158. " environment:",
  159. " - PORT=9621",
  160. " - FOO=bar",
  161. ],
  162. )
  163. write_text_lines(
  164. tmp_path / "env.example",
  165. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  166. )
  167. run_bash(f"""
  168. set -euo pipefail
  169. source "{REPO_ROOT}/scripts/setup/setup.sh"
  170. REPO_ROOT="{tmp_path}"
  171. reset_state
  172. set_compose_override "PORT" "1234"
  173. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  174. """)
  175. generated_compose = (tmp_path / "docker-compose.final.yml").read_text(
  176. encoding="utf-8"
  177. )
  178. assert ' - "PORT=1234"' in generated_compose
  179. assert " - FOO=bar" in generated_compose
  180. assert " PORT:" not in generated_compose
  181. def test_generate_docker_compose_injects_healthchecks_and_lightrag_depends_on(
  182. tmp_path: Path,
  183. ) -> None:
  184. """Generated compose should gate LightRAG on all managed dependencies becoming healthy."""
  185. write_text_lines(
  186. tmp_path / "docker-compose.yml",
  187. ["services:", " lightrag:", " image: example/lightrag:test"],
  188. )
  189. write_text_lines(
  190. tmp_path / "env.example",
  191. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  192. )
  193. run_bash(f"""
  194. set -euo pipefail
  195. source "{REPO_ROOT}/scripts/setup/setup.sh"
  196. REPO_ROOT="{tmp_path}"
  197. reset_state
  198. add_docker_service postgres
  199. add_docker_service neo4j
  200. add_docker_service mongodb
  201. add_docker_service redis
  202. add_docker_service milvus
  203. add_docker_service qdrant
  204. add_docker_service memgraph
  205. add_docker_service vllm-embed
  206. add_docker_service vllm-rerank
  207. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  208. """)
  209. generated_compose = (tmp_path / "docker-compose.final.yml").read_text(
  210. encoding="utf-8"
  211. )
  212. lightrag_start = generated_compose.index(" lightrag:\n")
  213. embed_start = generated_compose.index("\n vllm-embed:\n")
  214. lightrag_block = generated_compose[lightrag_start:embed_start]
  215. assert " depends_on:" in generated_compose
  216. assert " depends_on:" in lightrag_block
  217. for service_name in (
  218. "postgres",
  219. "neo4j",
  220. "mongodb",
  221. "redis",
  222. "milvus",
  223. "qdrant",
  224. "memgraph",
  225. "vllm-embed",
  226. "vllm-rerank",
  227. ):
  228. assert (
  229. f""" {service_name}:
  230. condition: service_healthy"""
  231. in lightrag_block
  232. )
  233. assert generated_compose.count(" healthcheck:") == 10
  234. assert " milvus-etcd:" in generated_compose
  235. assert " milvus-minio:" in generated_compose
  236. assert (
  237. """ milvus-etcd:
  238. condition: service_healthy"""
  239. in generated_compose
  240. )
  241. assert (
  242. """ milvus-minio:
  243. condition: service_healthy"""
  244. in generated_compose
  245. )
  246. def test_generate_docker_compose_preserves_user_depends_on_and_removes_stale_managed_entries(
  247. tmp_path: Path,
  248. ) -> None:
  249. """Compose regeneration should preserve user dependencies while refreshing wizard-managed ones."""
  250. write_text_lines(
  251. tmp_path / "docker-compose.final.yml",
  252. [
  253. "services:",
  254. " lightrag:",
  255. " image: example/lightrag:test",
  256. " depends_on:",
  257. " sidecar:",
  258. " condition: service_started",
  259. " postgres:",
  260. " condition: service_started",
  261. " vllm-embed:",
  262. " condition: service_healthy",
  263. " sidecar:",
  264. " image: busybox",
  265. ],
  266. )
  267. write_text_lines(
  268. tmp_path / "env.example",
  269. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  270. )
  271. run_bash(f"""
  272. set -euo pipefail
  273. source "{REPO_ROOT}/scripts/setup/setup.sh"
  274. REPO_ROOT="{tmp_path}"
  275. reset_state
  276. add_docker_service postgres
  277. add_docker_service redis
  278. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  279. """)
  280. generated_compose = (tmp_path / "docker-compose.final.yml").read_text(
  281. encoding="utf-8"
  282. )
  283. assert (
  284. """ sidecar:
  285. condition: service_started"""
  286. in generated_compose
  287. )
  288. assert (
  289. """ postgres:
  290. condition: service_healthy"""
  291. in generated_compose
  292. )
  293. assert (
  294. """ redis:
  295. condition: service_healthy"""
  296. in generated_compose
  297. )
  298. assert (
  299. """ vllm-embed:
  300. condition: service_healthy"""
  301. not in generated_compose
  302. )
  303. def test_generate_docker_compose_repairs_misplaced_lightrag_depends_on_from_existing_output(
  304. tmp_path: Path,
  305. ) -> None:
  306. """Regeneration should move stale lightrag depends_on content back onto the lightrag service."""
  307. write_text_lines(
  308. tmp_path / "docker-compose.final.yml",
  309. [
  310. "services:",
  311. " lightrag:",
  312. " image: example/lightrag:test",
  313. " environment:",
  314. " vllm-rerank:",
  315. " image: example/vllm:test",
  316. " restart: unless-stopped",
  317. " depends_on:",
  318. " my-service:",
  319. " condition: service_healthy",
  320. "volumes:",
  321. " vllm_rerank_cache:",
  322. ],
  323. )
  324. write_text_lines(
  325. tmp_path / "env.example",
  326. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  327. )
  328. run_bash(f"""
  329. set -euo pipefail
  330. source "{REPO_ROOT}/scripts/setup/setup.sh"
  331. REPO_ROOT="{tmp_path}"
  332. reset_state
  333. add_docker_service vllm-rerank
  334. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  335. """)
  336. generated_compose = (tmp_path / "docker-compose.final.yml").read_text(
  337. encoding="utf-8"
  338. )
  339. lightrag_start = generated_compose.index(" lightrag:\n")
  340. rerank_start = generated_compose.index("\n vllm-rerank:\n")
  341. lightrag_block = generated_compose[lightrag_start:rerank_start]
  342. rerank_block = generated_compose[rerank_start:]
  343. assert " depends_on:" in lightrag_block
  344. assert (
  345. """ my-service:
  346. condition: service_healthy"""
  347. in lightrag_block
  348. )
  349. assert (
  350. """ vllm-rerank:
  351. condition: service_healthy"""
  352. in lightrag_block
  353. )
  354. assert " depends_on:" not in rerank_block
  355. assert generated_compose.count("\n vllm-rerank:\n") == 1
  356. def test_generate_docker_compose_normalizes_lightrag_restart_policy_from_existing_output(
  357. tmp_path: Path,
  358. ) -> None:
  359. """Regeneration should replace legacy lightrag restart with deploy.restart_policy."""
  360. write_text_lines(
  361. tmp_path / "docker-compose.final.yml",
  362. [
  363. "services:",
  364. " lightrag:",
  365. " image: example/lightrag:test",
  366. " restart: unless-stopped",
  367. " extra_hosts:",
  368. ' - "host.docker.internal:host-gateway"',
  369. ],
  370. )
  371. write_text_lines(
  372. tmp_path / "env.example",
  373. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  374. )
  375. run_bash(f"""
  376. set -euo pipefail
  377. source "{REPO_ROOT}/scripts/setup/setup.sh"
  378. REPO_ROOT="{tmp_path}"
  379. reset_state
  380. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  381. """)
  382. generated_compose = (tmp_path / "docker-compose.final.yml").read_text(
  383. encoding="utf-8"
  384. )
  385. lightrag_start = generated_compose.index(" lightrag:\n")
  386. lightrag_block = generated_compose[lightrag_start:]
  387. assert " restart: unless-stopped" not in lightrag_block
  388. assert " deploy:\n" in lightrag_block
  389. assert " restart_policy:\n" in lightrag_block
  390. assert " condition: on-failure\n" in lightrag_block
  391. assert " max_attempts: 10\n" in lightrag_block
  392. def test_generate_docker_compose_normalizes_lightrag_restart_policy_without_blank_line_before_deploy(
  393. tmp_path: Path,
  394. ) -> None:
  395. """Regeneration should move the separator blank line after deploy, not before it."""
  396. write_text_lines(
  397. tmp_path / "docker-compose.final.yml",
  398. [
  399. "services:",
  400. " lightrag:",
  401. " image: example/lightrag:test",
  402. " restart: unless-stopped",
  403. "",
  404. " sidecar:",
  405. " image: busybox",
  406. ],
  407. )
  408. write_text_lines(
  409. tmp_path / "env.example",
  410. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  411. )
  412. run_bash(f"""
  413. set -euo pipefail
  414. source "{REPO_ROOT}/scripts/setup/setup.sh"
  415. REPO_ROOT="{tmp_path}"
  416. reset_state
  417. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  418. """)
  419. generated_compose = (tmp_path / "docker-compose.final.yml").read_text(
  420. encoding="utf-8"
  421. )
  422. assert (
  423. """ image: example/lightrag:test
  424. deploy:
  425. """
  426. not in generated_compose
  427. )
  428. assert (
  429. """ image: example/lightrag:test
  430. deploy:
  431. """
  432. in generated_compose
  433. )
  434. assert " max_attempts: 10\n\n volumes:\n" in generated_compose
  435. assert (
  436. " - ./data/prompts:/app/data/prompts\n\n sidecar:\n" in generated_compose
  437. )
  438. def test_generate_docker_compose_preserves_non_managed_named_volumes(
  439. tmp_path: Path,
  440. ) -> None:
  441. """Retained services should keep their referenced top-level named volumes."""
  442. write_text_lines(
  443. tmp_path / "docker-compose.final.yml",
  444. [
  445. "services:",
  446. " lightrag:",
  447. " image: example/lightrag:test",
  448. " volumes:",
  449. " - my_cache:/app/cache",
  450. " sidecar:",
  451. " image: busybox",
  452. ' command: ["sleep", "infinity"]',
  453. " volumes:",
  454. " - sidecar_data:/data",
  455. " postgres:",
  456. " image: old/postgres:image",
  457. " volumes:",
  458. " - postgres_data:/var/lib/postgresql/data",
  459. "volumes:",
  460. " my_cache:",
  461. " driver: local",
  462. " sidecar_data:",
  463. " driver: local",
  464. " postgres_data:",
  465. ],
  466. )
  467. write_text_lines(
  468. tmp_path / "env.example",
  469. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  470. )
  471. run_bash(f"""
  472. set -euo pipefail
  473. source "{REPO_ROOT}/scripts/setup/setup.sh"
  474. REPO_ROOT="{tmp_path}"
  475. reset_state
  476. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  477. """)
  478. result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8")
  479. assert " sidecar:" in result
  480. assert "my_cache:/app/cache" in result
  481. assert "sidecar_data:/data" in result
  482. assert " my_cache:" in result
  483. assert " driver: local" in result
  484. assert " sidecar_data:" in result
  485. assert "postgres_data:" not in result
  486. def test_generate_docker_compose_inserts_managed_services_before_top_level_sections(
  487. tmp_path: Path,
  488. ) -> None:
  489. """Managed services should stay inside services: even when custom top-level sections exist."""
  490. write_text_lines(
  491. tmp_path / "docker-compose.final.yml",
  492. [
  493. "services:",
  494. " lightrag:",
  495. " image: example/lightrag:test",
  496. " volumes:",
  497. " - ./.env:/app/.env",
  498. " worker:",
  499. " image: example/worker:test",
  500. " networks:",
  501. " - appnet",
  502. "networks:",
  503. " appnet:",
  504. " driver: bridge",
  505. ],
  506. )
  507. write_text_lines(
  508. tmp_path / "env.example",
  509. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  510. )
  511. run_bash(f"""
  512. set -euo pipefail
  513. source "{REPO_ROOT}/scripts/setup/setup.sh"
  514. REPO_ROOT="{tmp_path}"
  515. reset_state
  516. ENV_VALUES[POSTGRES_USER]="lightrag"
  517. ENV_VALUES[POSTGRES_PASSWORD]="secret"
  518. ENV_VALUES[POSTGRES_DATABASE]="lightrag"
  519. add_docker_service "postgres"
  520. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  521. """)
  522. result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8")
  523. assert " postgres:" in result
  524. assert "\n\nnetworks:\n" in result
  525. assert result.index("\n postgres:") < result.index("\nnetworks:\n")
  526. assert " appnet:" in result
  527. def test_generate_docker_compose_cleans_marker_and_blank_lines_when_only_lightrag_remains(
  528. tmp_path: Path,
  529. ) -> None:
  530. """Regeneration should not leave a managed-services marker or stacked blank lines behind."""
  531. write_text_lines(
  532. tmp_path / "docker-compose.final.yml",
  533. [
  534. "services:",
  535. " lightrag:",
  536. " image: example/lightrag:test",
  537. " depends_on:",
  538. " vllm-embed:",
  539. " condition: service_healthy",
  540. " vllm-rerank:",
  541. " condition: service_healthy",
  542. "",
  543. " vllm-embed:",
  544. " image: example/vllm:embed",
  545. "",
  546. " vllm-rerank:",
  547. " image: example/vllm:rerank",
  548. "",
  549. "",
  550. "",
  551. "# __WIZARD_MANAGED_SERVICES__",
  552. "networks:",
  553. " appnet:",
  554. " driver: bridge",
  555. ],
  556. )
  557. write_text_lines(
  558. tmp_path / "env.example",
  559. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  560. )
  561. run_bash(f"""
  562. set -euo pipefail
  563. source "{REPO_ROOT}/scripts/setup/setup.sh"
  564. REPO_ROOT="{tmp_path}"
  565. reset_state
  566. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  567. """)
  568. result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8")
  569. assert " vllm-embed:" not in result
  570. assert " vllm-rerank:" not in result
  571. assert "__WIZARD_MANAGED_SERVICES__" not in result
  572. assert "depends_on:" not in result
  573. assert " max_attempts: 10\n\nnetworks:\n" in result
  574. def test_generate_docker_compose_keeps_blank_line_between_managed_service_and_top_level_sections(
  575. tmp_path: Path,
  576. ) -> None:
  577. """Managed service blocks should stay visually separated from following top-level sections."""
  578. write_text_lines(
  579. tmp_path / "docker-compose.final.yml",
  580. [
  581. "services:",
  582. " lightrag:",
  583. " image: example/lightrag:test",
  584. "networks:",
  585. " web_network:",
  586. " driver: bridge",
  587. ],
  588. )
  589. write_text_lines(
  590. tmp_path / "env.example",
  591. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  592. )
  593. run_bash(f"""
  594. set -euo pipefail
  595. source "{REPO_ROOT}/scripts/setup/setup.sh"
  596. REPO_ROOT="{tmp_path}"
  597. reset_state
  598. add_docker_service "vllm-embed"
  599. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  600. """)
  601. result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8")
  602. assert " vllm-embed:" in result
  603. assert (
  604. """ max_attempts: 10
  605. depends_on:
  606. """
  607. in result
  608. )
  609. assert " restart: unless-stopped\n\nnetworks:\n" in result
  610. def test_generate_docker_compose_keeps_single_blank_line_before_generated_volumes(
  611. tmp_path: Path,
  612. ) -> None:
  613. """Generated top-level volumes should be separated from prior sections by one blank line."""
  614. write_text_lines(
  615. tmp_path / "docker-compose.final.yml",
  616. [
  617. "services:",
  618. " lightrag:",
  619. " image: example/lightrag:test",
  620. "networks:",
  621. " web_network:",
  622. " driver: bridge",
  623. "",
  624. ],
  625. )
  626. write_text_lines(
  627. tmp_path / "env.example",
  628. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  629. )
  630. run_bash(f"""
  631. set -euo pipefail
  632. source "{REPO_ROOT}/scripts/setup/setup.sh"
  633. REPO_ROOT="{tmp_path}"
  634. reset_state
  635. add_docker_service "vllm-embed"
  636. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  637. """)
  638. result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8")
  639. assert "\n\nvolumes:\n" in result
  640. assert "\n\n\nvolumes:\n" not in result
  641. def test_generate_env_file_comments_out_later_duplicate_active_keys(
  642. tmp_path: Path,
  643. ) -> None:
  644. """Commented example keys should not be overridden by later active defaults."""
  645. run_bash(f"""
  646. set -euo pipefail
  647. source "{REPO_ROOT}/scripts/setup/setup.sh"
  648. REPO_ROOT="{tmp_path}"
  649. reset_state
  650. ENV_VALUES[EMBEDDING_BINDING]="ollama"
  651. ENV_VALUES[EMBEDDING_MODEL]="bge-m3:latest"
  652. ENV_VALUES[EMBEDDING_DIM]="1024"
  653. ENV_VALUES[EMBEDDING_BINDING_HOST]="http://localhost:11434"
  654. generate_env_file "{REPO_ROOT}/env.example" "$REPO_ROOT/.env\"
  655. """)
  656. generated_env = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  657. active_embedding_lines = [
  658. line for line in generated_env if line.startswith("EMBEDDING_BINDING=")
  659. ]
  660. active_model_lines = [
  661. line for line in generated_env if line.startswith("EMBEDDING_MODEL=")
  662. ]
  663. active_host_lines = [
  664. line for line in generated_env if line.startswith("EMBEDDING_BINDING_HOST=")
  665. ]
  666. assert active_embedding_lines == ["EMBEDDING_BINDING=ollama"]
  667. assert active_model_lines == ["EMBEDDING_MODEL=bge-m3:latest"]
  668. assert active_host_lines == ["EMBEDDING_BINDING_HOST=http://localhost:11434"]
  669. assert "# EMBEDDING_BINDING=openai" in generated_env
  670. def test_generate_env_file_preserves_custom_variables_not_declared_in_template(
  671. tmp_path: Path,
  672. ) -> None:
  673. """Reruns should keep custom `.env` variables that are not declared in env.example."""
  674. write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"])
  675. write_text_lines(
  676. tmp_path / ".env",
  677. [
  678. "HOST=127.0.0.1",
  679. "",
  680. "# Custom integration settings",
  681. "EXTRA_API_BASE='https://example.com/api'",
  682. "# EXTRA_API_TOKEN=secret",
  683. ],
  684. )
  685. run_bash(f"""
  686. set -euo pipefail
  687. source "{REPO_ROOT}/scripts/setup/setup.sh"
  688. REPO_ROOT="{tmp_path}"
  689. reset_state
  690. load_env_file "$REPO_ROOT/.env"
  691. ENV_VALUES[HOST]="0.0.0.0"
  692. ENV_VALUES[PORT]="9621"
  693. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  694. """)
  695. generated_env = (tmp_path / ".env").read_text(encoding="utf-8")
  696. assert "HOST=0.0.0.0" in generated_env
  697. assert "PORT=9621" in generated_env
  698. assert PRESERVED_HEADER in generated_env
  699. assert "# Custom integration settings" not in generated_env
  700. assert "EXTRA_API_BASE='https://example.com/api'" in generated_env
  701. assert "# EXTRA_API_TOKEN=secret" in generated_env
  702. def test_generate_env_file_keeps_preserved_section_idempotent_across_reruns(
  703. tmp_path: Path,
  704. ) -> None:
  705. """Repeated reruns should keep a single preserved marker and its leading blank line."""
  706. write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"])
  707. write_text_lines(
  708. tmp_path / ".env",
  709. [
  710. "HOST=127.0.0.1",
  711. "",
  712. PRESERVED_HEADER,
  713. "",
  714. "# Custom integration settings",
  715. "EXTRA_API_BASE='https://example.com/api'",
  716. "# EXTRA_API_TOKEN=secret",
  717. ],
  718. )
  719. run_bash(f"""
  720. set -euo pipefail
  721. source "{REPO_ROOT}/scripts/setup/setup.sh"
  722. REPO_ROOT="{tmp_path}"
  723. reset_state
  724. load_env_file "$REPO_ROOT/.env"
  725. ENV_VALUES[HOST]="0.0.0.0"
  726. ENV_VALUES[PORT]="9621"
  727. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env"
  728. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  729. """)
  730. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  731. marker = PRESERVED_HEADER
  732. notice = PRESERVED_NOTICE
  733. marker_indexes = [idx for idx, line in enumerate(generated_lines) if line == marker]
  734. assert marker_indexes == [3]
  735. assert generated_lines[2] == ""
  736. assert generated_lines[4] == notice
  737. assert generated_lines[5] == ""
  738. assert generated_lines[6] == "# Custom integration settings"
  739. assert generated_lines[7] == "EXTRA_API_BASE='https://example.com/api'"
  740. assert generated_lines[8] == "# EXTRA_API_TOKEN=secret"
  741. def test_generate_env_file_preserves_multi_line_comments_inside_preserved_section(
  742. tmp_path: Path,
  743. ) -> None:
  744. """Only comments already inside the preserved section should survive reruns."""
  745. write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"])
  746. write_text_lines(
  747. tmp_path / ".env",
  748. [
  749. "HOST=127.0.0.1",
  750. "",
  751. "# External note that should not migrate",
  752. PRESERVED_HEADER,
  753. "",
  754. "# Group A",
  755. "# Shared settings",
  756. "EXTRA_API_BASE='https://example.com/api'",
  757. "",
  758. "# Group B",
  759. "EXTRA_API_TOKEN=secret",
  760. ],
  761. )
  762. run_bash(f"""
  763. set -euo pipefail
  764. source "{REPO_ROOT}/scripts/setup/setup.sh"
  765. REPO_ROOT="{tmp_path}"
  766. reset_state
  767. load_env_file "$REPO_ROOT/.env"
  768. ENV_VALUES[HOST]="0.0.0.0"
  769. ENV_VALUES[PORT]="9621"
  770. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env"
  771. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  772. """)
  773. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  774. marker = PRESERVED_HEADER
  775. notice = PRESERVED_NOTICE
  776. marker_index = generated_lines.index(marker)
  777. assert generated_lines.count(marker) == 1
  778. assert generated_lines.count(notice) == 1
  779. assert "# External note that should not migrate" not in generated_lines
  780. assert generated_lines[marker_index + 1] == notice
  781. assert generated_lines[marker_index + 2] == ""
  782. assert generated_lines[marker_index + 3] == "# Group A"
  783. assert generated_lines[marker_index + 4] == "# Shared settings"
  784. assert (
  785. generated_lines[marker_index + 5] == "EXTRA_API_BASE='https://example.com/api'"
  786. )
  787. assert generated_lines[marker_index + 6] == ""
  788. assert generated_lines[marker_index + 7] == "# Group B"
  789. assert generated_lines[marker_index + 8] == "EXTRA_API_TOKEN=secret"
  790. def test_generate_env_file_preserves_trailing_comments_at_end_of_preserved_section(
  791. tmp_path: Path,
  792. ) -> None:
  793. """Free-form comments after the last preserved variable should survive reruns."""
  794. write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"])
  795. write_text_lines(
  796. tmp_path / ".env",
  797. [
  798. "HOST=127.0.0.1",
  799. "",
  800. PRESERVED_HEADER,
  801. PRESERVED_NOTICE,
  802. "",
  803. "EXTRA_API_BASE='https://example.com/api'",
  804. "# Free-form note",
  805. "# This should stay at EOF",
  806. ],
  807. )
  808. run_bash(f"""
  809. set -euo pipefail
  810. source "{REPO_ROOT}/scripts/setup/setup.sh"
  811. REPO_ROOT="{tmp_path}"
  812. reset_state
  813. load_env_file "$REPO_ROOT/.env"
  814. ENV_VALUES[HOST]="0.0.0.0"
  815. ENV_VALUES[PORT]="9621"
  816. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env"
  817. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  818. """)
  819. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  820. assert generated_lines[-3] == "EXTRA_API_BASE='https://example.com/api'"
  821. assert generated_lines[-2] == "# Free-form note"
  822. assert generated_lines[-1] == "# This should stay at EOF"
  823. def test_generate_env_file_appends_new_external_entries_after_existing_preserved_block(
  824. tmp_path: Path,
  825. ) -> None:
  826. """New template-external entries should be appended after the existing preserved payload."""
  827. write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"])
  828. write_text_lines(
  829. tmp_path / ".env",
  830. [
  831. "HOST=127.0.0.1",
  832. "EXTRA_EARLY=alpha",
  833. PRESERVED_HEADER,
  834. PRESERVED_NOTICE,
  835. "",
  836. "# Existing note",
  837. "EXTRA_EXISTING=omega",
  838. ],
  839. )
  840. run_bash(f"""
  841. set -euo pipefail
  842. source "{REPO_ROOT}/scripts/setup/setup.sh"
  843. REPO_ROOT="{tmp_path}"
  844. reset_state
  845. load_env_file "$REPO_ROOT/.env"
  846. ENV_VALUES[HOST]="0.0.0.0"
  847. ENV_VALUES[PORT]="9621"
  848. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  849. """)
  850. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  851. marker_index = generated_lines.index(PRESERVED_HEADER)
  852. assert generated_lines[marker_index + 1] == PRESERVED_NOTICE
  853. assert generated_lines[marker_index + 2] == ""
  854. assert generated_lines[marker_index + 3] == "# Existing note"
  855. assert generated_lines[marker_index + 4] == "EXTRA_EXISTING=omega"
  856. assert generated_lines[marker_index + 5] == "EXTRA_EARLY=alpha"
  857. def test_generate_env_file_appends_multiple_new_external_entries_in_discovery_order(
  858. tmp_path: Path,
  859. ) -> None:
  860. """Multiple new external entries should append after preserved payload in source order."""
  861. write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"])
  862. write_text_lines(
  863. tmp_path / ".env",
  864. [
  865. "HOST=127.0.0.1",
  866. "EXTRA_FIRST=one",
  867. "# Outside comment should not migrate",
  868. "EXTRA_SECOND=two",
  869. PRESERVED_HEADER,
  870. PRESERVED_NOTICE,
  871. "",
  872. "EXTRA_EXISTING=existing",
  873. ],
  874. )
  875. run_bash(f"""
  876. set -euo pipefail
  877. source "{REPO_ROOT}/scripts/setup/setup.sh"
  878. REPO_ROOT="{tmp_path}"
  879. reset_state
  880. load_env_file "$REPO_ROOT/.env"
  881. ENV_VALUES[HOST]="0.0.0.0"
  882. ENV_VALUES[PORT]="9621"
  883. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  884. """)
  885. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  886. marker_index = generated_lines.index(PRESERVED_HEADER)
  887. assert "# Outside comment should not migrate" not in generated_lines
  888. assert generated_lines[marker_index + 3] == "EXTRA_EXISTING=existing"
  889. assert generated_lines[marker_index + 4] == "EXTRA_FIRST=one"
  890. assert generated_lines[marker_index + 5] == "EXTRA_SECOND=two"
  891. def test_generate_env_file_keeps_commented_template_keys_inside_preserved_section(
  892. tmp_path: Path,
  893. ) -> None:
  894. """Commented env vars already placed in preserved should survive even if the template declares them."""
  895. write_text_lines(
  896. tmp_path / "env.example",
  897. ["HOST=0.0.0.0", "# PORT=9621", "# ENTITY_EXTRACTION_USE_JSON=true"],
  898. )
  899. write_text_lines(
  900. tmp_path / ".env",
  901. [
  902. "HOST=127.0.0.1",
  903. PRESERVED_HEADER,
  904. PRESERVED_NOTICE,
  905. "",
  906. "# ENTITY_EXTRACTION_USE_JSON=true",
  907. ],
  908. )
  909. run_bash(f"""
  910. set -euo pipefail
  911. source "{REPO_ROOT}/scripts/setup/setup.sh"
  912. REPO_ROOT="{tmp_path}"
  913. reset_state
  914. load_env_file "$REPO_ROOT/.env"
  915. ENV_VALUES[HOST]="0.0.0.0"
  916. ENV_VALUES[PORT]="9621"
  917. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env"
  918. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  919. """)
  920. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  921. marker_index = generated_lines.index(PRESERVED_HEADER)
  922. assert generated_lines.count("# ENTITY_EXTRACTION_USE_JSON=true") == 2
  923. assert generated_lines[marker_index + 3] == "# ENTITY_EXTRACTION_USE_JSON=true"
  924. def test_generate_env_file_recognizes_lowercase_extra_variables(tmp_path: Path) -> None:
  925. """Lowercase template-external variables should be preserved like uppercase ones."""
  926. write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"])
  927. write_text_lines(tmp_path / ".env", ["HOST=127.0.0.1", "workspace_name=demo"])
  928. run_bash(f"""
  929. set -euo pipefail
  930. source "{REPO_ROOT}/scripts/setup/setup.sh"
  931. REPO_ROOT="{tmp_path}"
  932. reset_state
  933. load_env_file "$REPO_ROOT/.env"
  934. ENV_VALUES[HOST]="0.0.0.0"
  935. ENV_VALUES[PORT]="9621"
  936. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  937. """)
  938. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  939. assert PRESERVED_HEADER in generated_lines
  940. assert "workspace_name=demo" in generated_lines
  941. def test_generate_env_file_recognizes_lowercase_commented_extra_variables(
  942. tmp_path: Path,
  943. ) -> None:
  944. """Lowercase commented env vars should create and survive in the preserved section."""
  945. write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"])
  946. write_text_lines(tmp_path / ".env", ["HOST=127.0.0.1", "# workspace_name=demo"])
  947. run_bash(f"""
  948. set -euo pipefail
  949. source "{REPO_ROOT}/scripts/setup/setup.sh"
  950. REPO_ROOT="{tmp_path}"
  951. reset_state
  952. load_env_file "$REPO_ROOT/.env"
  953. ENV_VALUES[HOST]="0.0.0.0"
  954. ENV_VALUES[PORT]="9621"
  955. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  956. """)
  957. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  958. assert PRESERVED_HEADER in generated_lines
  959. assert "# workspace_name=demo" in generated_lines
  960. def test_generate_env_file_uses_template_preserved_block_when_env_missing(
  961. tmp_path: Path,
  962. ) -> None:
  963. """Missing `.env` should still produce the preserved block from env.example."""
  964. write_text_lines(
  965. tmp_path / "env.example",
  966. [
  967. "HOST=0.0.0.0",
  968. PRESERVED_HEADER,
  969. PRESERVED_NOTICE,
  970. "### Template preserved comment",
  971. "# template_example=true",
  972. ],
  973. )
  974. run_bash(f"""
  975. set -euo pipefail
  976. source "{REPO_ROOT}/scripts/setup/setup.sh"
  977. REPO_ROOT="{tmp_path}"
  978. reset_state
  979. ENV_VALUES[HOST]="0.0.0.0"
  980. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  981. """)
  982. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  983. assert PRESERVED_HEADER in generated_lines
  984. assert PRESERVED_NOTICE in generated_lines
  985. assert generated_lines.count(PRESERVED_HEADER) == 1
  986. assert generated_lines.count(PRESERVED_NOTICE) == 1
  987. assert "### Template preserved comment" in generated_lines
  988. assert "# template_example=true" in generated_lines
  989. def test_generate_env_file_keeps_template_separator_adjacent_to_preserved_header(
  990. tmp_path: Path,
  991. ) -> None:
  992. """Injected template preserved blocks should not add a blank line after the copied separator."""
  993. write_text_lines(
  994. tmp_path / "env.example",
  995. [
  996. "HOST=0.0.0.0",
  997. "##########################################################################",
  998. PRESERVED_HEADER,
  999. PRESERVED_NOTICE,
  1000. "### Template preserved comment",
  1001. ],
  1002. )
  1003. run_bash(f"""
  1004. set -euo pipefail
  1005. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1006. REPO_ROOT="{tmp_path}"
  1007. reset_state
  1008. ENV_VALUES[HOST]="0.0.0.0"
  1009. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  1010. """)
  1011. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  1012. header_index = generated_lines.index(PRESERVED_HEADER)
  1013. assert (
  1014. generated_lines[header_index - 1]
  1015. == "##########################################################################"
  1016. )
  1017. def test_generate_env_file_does_not_inject_template_payload_when_old_preserved_exists(
  1018. tmp_path: Path,
  1019. ) -> None:
  1020. """Existing preserved blocks should stay authoritative over template preserved payload."""
  1021. write_text_lines(
  1022. tmp_path / "env.example",
  1023. [
  1024. "HOST=0.0.0.0",
  1025. PRESERVED_HEADER,
  1026. PRESERVED_NOTICE,
  1027. "### Template preserved comment",
  1028. "# template_example=true",
  1029. ],
  1030. )
  1031. write_text_lines(
  1032. tmp_path / ".env",
  1033. [
  1034. "HOST=127.0.0.1",
  1035. "",
  1036. PRESERVED_HEADER,
  1037. "",
  1038. "# Existing preserved comment",
  1039. "EXTRA_OLD=1",
  1040. ],
  1041. )
  1042. run_bash(f"""
  1043. set -euo pipefail
  1044. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1045. REPO_ROOT="{tmp_path}"
  1046. reset_state
  1047. load_env_file "$REPO_ROOT/.env"
  1048. ENV_VALUES[HOST]="0.0.0.0"
  1049. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  1050. """)
  1051. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  1052. assert PRESERVED_HEADER in generated_lines
  1053. assert PRESERVED_NOTICE in generated_lines
  1054. assert "### Template preserved comment" not in generated_lines
  1055. assert "# template_example=true" not in generated_lines
  1056. assert "# Existing preserved comment" in generated_lines
  1057. assert "EXTRA_OLD=1" in generated_lines
  1058. def test_generate_env_file_keeps_old_preserved_lines_even_when_they_match_template(
  1059. tmp_path: Path,
  1060. ) -> None:
  1061. """Old preserved content should not be removed just because it matches env.example."""
  1062. write_text_lines(
  1063. tmp_path / "env.example",
  1064. [
  1065. "HOST=0.0.0.0",
  1066. PRESERVED_HEADER,
  1067. PRESERVED_NOTICE,
  1068. "### Template preserved comment",
  1069. "# template_example=true",
  1070. ],
  1071. )
  1072. write_text_lines(
  1073. tmp_path / ".env",
  1074. [
  1075. "HOST=127.0.0.1",
  1076. "",
  1077. PRESERVED_HEADER,
  1078. "### Template preserved comment",
  1079. "# template_example=true",
  1080. "EXTRA_OLD=1",
  1081. ],
  1082. )
  1083. run_bash(f"""
  1084. set -euo pipefail
  1085. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1086. REPO_ROOT="{tmp_path}"
  1087. reset_state
  1088. load_env_file "$REPO_ROOT/.env"
  1089. ENV_VALUES[HOST]="0.0.0.0"
  1090. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  1091. """)
  1092. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  1093. assert PRESERVED_HEADER in generated_lines
  1094. assert PRESERVED_NOTICE in generated_lines
  1095. assert "### Template preserved comment" in generated_lines
  1096. assert "# template_example=true" in generated_lines
  1097. assert "EXTRA_OLD=1" in generated_lines
  1098. def test_generate_env_file_preserves_comments_before_active_template_keys_in_preserved(
  1099. tmp_path: Path,
  1100. ) -> None:
  1101. """Comments in preserved should survive even when followed by active template-managed keys."""
  1102. write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"])
  1103. write_text_lines(
  1104. tmp_path / ".env",
  1105. [
  1106. "HOST=127.0.0.1",
  1107. PRESERVED_HEADER,
  1108. PRESERVED_NOTICE,
  1109. "",
  1110. "# Preserved note before active template key",
  1111. "# Another note",
  1112. "PORT=9999",
  1113. "EXTRA_AFTER=1",
  1114. ],
  1115. )
  1116. run_bash(f"""
  1117. set -euo pipefail
  1118. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1119. REPO_ROOT="{tmp_path}"
  1120. reset_state
  1121. load_env_file "$REPO_ROOT/.env"
  1122. ENV_VALUES[HOST]="0.0.0.0"
  1123. ENV_VALUES[PORT]="9621"
  1124. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  1125. """)
  1126. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  1127. marker_index = generated_lines.index(PRESERVED_HEADER)
  1128. assert (
  1129. generated_lines[marker_index + 3]
  1130. == "# Preserved note before active template key"
  1131. )
  1132. assert generated_lines[marker_index + 4] == "# Another note"
  1133. assert "PORT=9999" not in generated_lines[marker_index + 1 :]
  1134. assert "EXTRA_AFTER=1" in generated_lines
  1135. def test_generate_env_file_appends_extra_variables_after_template_preserved_block(
  1136. tmp_path: Path,
  1137. ) -> None:
  1138. """Extras from old `.env` should append after the template preserved block when none existed before."""
  1139. write_text_lines(
  1140. tmp_path / "env.example",
  1141. [
  1142. "HOST=0.0.0.0",
  1143. PRESERVED_HEADER,
  1144. PRESERVED_NOTICE,
  1145. "### Template preserved comment",
  1146. "# template_example=true",
  1147. ],
  1148. )
  1149. write_text_lines(tmp_path / ".env", ["HOST=127.0.0.1", "EXTRA_NEW=1"])
  1150. run_bash(f"""
  1151. set -euo pipefail
  1152. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1153. REPO_ROOT="{tmp_path}"
  1154. reset_state
  1155. load_env_file "$REPO_ROOT/.env"
  1156. ENV_VALUES[HOST]="0.0.0.0"
  1157. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  1158. """)
  1159. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  1160. assert generated_lines[-3] == "### Template preserved comment"
  1161. assert generated_lines[-2] == "# template_example=true"
  1162. assert generated_lines[-1] == "EXTRA_NEW=1"
  1163. def test_generate_env_file_appends_commented_env_vars_after_template_preserved_block(
  1164. tmp_path: Path,
  1165. ) -> None:
  1166. """Commented env vars from old `.env` should append after the template preserved block when none existed before."""
  1167. write_text_lines(
  1168. tmp_path / "env.example",
  1169. [
  1170. "HOST=0.0.0.0",
  1171. PRESERVED_HEADER,
  1172. PRESERVED_NOTICE,
  1173. "### Template preserved comment",
  1174. "# template_example=true",
  1175. ],
  1176. )
  1177. write_text_lines(tmp_path / ".env", ["HOST=127.0.0.1", "# EXTRA_COMMENTED=1"])
  1178. run_bash(f"""
  1179. set -euo pipefail
  1180. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1181. REPO_ROOT="{tmp_path}"
  1182. reset_state
  1183. load_env_file "$REPO_ROOT/.env"
  1184. ENV_VALUES[HOST]="0.0.0.0"
  1185. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  1186. """)
  1187. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  1188. assert generated_lines[-3] == "### Template preserved comment"
  1189. assert generated_lines[-2] == "# template_example=true"
  1190. assert generated_lines[-1] == "# EXTRA_COMMENTED=1"
  1191. def test_generate_env_file_round_trips_dollar_signs_in_single_quoted_values(
  1192. tmp_path: Path,
  1193. ) -> None:
  1194. """Quoted values containing `$` should survive generate/load cycles unchanged."""
  1195. env_example = tmp_path / "env.example"
  1196. env_example.write_text(
  1197. "\n".join(
  1198. [
  1199. "TOKEN_SECRET=placeholder",
  1200. "LIGHTRAG_API_KEY=placeholder",
  1201. "WEBUI_DESCRIPTION=placeholder",
  1202. ]
  1203. )
  1204. + "\n",
  1205. encoding="utf-8",
  1206. )
  1207. output = run_bash(f"""
  1208. set -euo pipefail
  1209. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1210. REPO_ROOT="{tmp_path}"
  1211. reset_state
  1212. ENV_VALUES[TOKEN_SECRET]='abc$HOME'
  1213. ENV_VALUES[LIGHTRAG_API_KEY]='plain$token'
  1214. ENV_VALUES[WEBUI_DESCRIPTION]='value with "$PATH" and $HOME'
  1215. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env"
  1216. reset_state
  1217. load_env_file "$REPO_ROOT/.env"
  1218. printf 'TOKEN_SECRET=%s\\n' "${{ENV_VALUES[TOKEN_SECRET]}}"
  1219. printf 'LIGHTRAG_API_KEY=%s\\n' "${{ENV_VALUES[LIGHTRAG_API_KEY]}}"
  1220. printf 'WEBUI_DESCRIPTION=%s\\n' "${{ENV_VALUES[WEBUI_DESCRIPTION]}}\"
  1221. """)
  1222. values = parse_lines(output)
  1223. generated_env = (tmp_path / ".env").read_text(encoding="utf-8")
  1224. assert "TOKEN_SECRET='abc$HOME'" in generated_env
  1225. assert "LIGHTRAG_API_KEY='plain$token'" in generated_env
  1226. assert "WEBUI_DESCRIPTION='value with \"$PATH\" and $HOME'" in generated_env
  1227. assert values["TOKEN_SECRET"] == "abc$HOME"
  1228. assert values["LIGHTRAG_API_KEY"] == "plain$token"
  1229. assert values["WEBUI_DESCRIPTION"] == 'value with "$PATH" and $HOME'
  1230. def test_generate_env_file_avoids_double_quotes_for_compose_sensitive_strings(
  1231. tmp_path: Path,
  1232. ) -> None:
  1233. """Setup output should avoid double quotes for affected string variables."""
  1234. env_example = tmp_path / "env.example"
  1235. env_example.write_text(
  1236. "\n".join(
  1237. [
  1238. "WEBUI_TITLE='My Graph KB'",
  1239. "WEBUI_DESCRIPTION='Simple and Fast Graph Based RAG System'",
  1240. "# AUTH_ACCOUNTS='admin:admin123,user1:{bcrypt}$2b$12$hash'",
  1241. "# LANGFUSE_SECRET_KEY=''",
  1242. "# LANGFUSE_PUBLIC_KEY=''",
  1243. "# LANGFUSE_HOST='https://cloud.langfuse.com'",
  1244. ]
  1245. )
  1246. + "\n",
  1247. encoding="utf-8",
  1248. )
  1249. run_bash(f"""
  1250. set -euo pipefail
  1251. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1252. REPO_ROOT="{tmp_path}"
  1253. reset_state
  1254. ENV_VALUES[WEBUI_TITLE]='My Graph KB'
  1255. ENV_VALUES[WEBUI_DESCRIPTION]='Simple and Fast Graph Based RAG System'
  1256. ENV_VALUES[AUTH_ACCOUNTS]='admin:admin123,user1:pa$$word'
  1257. ENV_VALUES[LANGFUSE_SECRET_KEY]='sk-lf-secret'
  1258. ENV_VALUES[LANGFUSE_PUBLIC_KEY]='pk-lf-public'
  1259. ENV_VALUES[LANGFUSE_HOST]='https://langfuse.example'
  1260. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\"
  1261. """)
  1262. generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
  1263. assert "WEBUI_TITLE='My Graph KB'" in generated_lines
  1264. assert (
  1265. "WEBUI_DESCRIPTION='Simple and Fast Graph Based RAG System'" in generated_lines
  1266. )
  1267. assert "AUTH_ACCOUNTS='admin:admin123,user1:pa$$word'" in generated_lines
  1268. assert "LANGFUSE_SECRET_KEY=sk-lf-secret" in generated_lines
  1269. assert "LANGFUSE_PUBLIC_KEY=pk-lf-public" in generated_lines
  1270. assert "LANGFUSE_HOST=https://langfuse.example" in generated_lines
  1271. assert not any(
  1272. line.startswith('WEBUI_TITLE="')
  1273. or line.startswith('WEBUI_DESCRIPTION="')
  1274. or line.startswith('AUTH_ACCOUNTS="')
  1275. or line.startswith('LANGFUSE_SECRET_KEY="')
  1276. or line.startswith('LANGFUSE_PUBLIC_KEY="')
  1277. or line.startswith('LANGFUSE_HOST="')
  1278. for line in generated_lines
  1279. )
  1280. def test_generate_docker_compose_escapes_dollar_signs_in_overrides_and_service_secrets(
  1281. tmp_path: Path,
  1282. ) -> None:
  1283. """Compose generation should keep `$` literals in runtime overrides and bundled secrets."""
  1284. write_text_lines(
  1285. tmp_path / "docker-compose.yml",
  1286. [
  1287. "services:",
  1288. " lightrag:",
  1289. " image: example/lightrag:test",
  1290. " env_file:",
  1291. " - .env",
  1292. ],
  1293. )
  1294. run_bash(f"""
  1295. set -euo pipefail
  1296. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1297. REPO_ROOT="{tmp_path}"
  1298. reset_state
  1299. ENV_VALUES[MONGO_URI]='mongodb://user:p$HOME@localhost:27017/'
  1300. ENV_VALUES[POSTGRES_USER]='user$ID'
  1301. ENV_VALUES[POSTGRES_PASSWORD]='pass$HOME'
  1302. ENV_VALUES[POSTGRES_DATABASE]='db$NAME'
  1303. ENV_VALUES[NEO4J_PASSWORD]='neo$PASS'
  1304. ENV_VALUES[NEO4J_DATABASE]='graph$DB'
  1305. ENV_VALUES[MINIO_ACCESS_KEY_ID]='minio$USER'
  1306. ENV_VALUES[MINIO_SECRET_ACCESS_KEY]='minio$SECRET'
  1307. prepare_compose_runtime_overrides
  1308. add_docker_service postgres
  1309. add_docker_service neo4j
  1310. add_docker_service milvus
  1311. generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml\"
  1312. """)
  1313. generated_compose = (tmp_path / "docker-compose.generated.yml").read_text(
  1314. encoding="utf-8"
  1315. )
  1316. assert (
  1317. 'MONGO_URI: "mongodb://user:p$$HOME@host.docker.internal:27017/"'
  1318. in generated_compose
  1319. )
  1320. assert 'POSTGRES_USER: "user$$ID"' in generated_compose
  1321. assert 'POSTGRES_PASSWORD: "pass$$HOME"' in generated_compose
  1322. assert 'POSTGRES_DB: "db$$NAME"' in generated_compose
  1323. assert (
  1324. "NEO4J_AUTH: ${NEO4J_USERNAME:?missing}/${NEO4J_PASSWORD:?missing}"
  1325. in generated_compose
  1326. )
  1327. assert 'NEO4J_dbms_default__database: "graph$$DB"' in generated_compose
  1328. assert 'MINIO_ACCESS_KEY_ID: "${MINIO_ACCESS_KEY_ID:?missing}"' in generated_compose
  1329. assert (
  1330. 'MINIO_SECRET_ACCESS_KEY: "${MINIO_SECRET_ACCESS_KEY:?missing}"'
  1331. in generated_compose
  1332. )
  1333. assert 'MINIO_ROOT_USER: "${MINIO_ACCESS_KEY_ID:?missing}"' in generated_compose
  1334. assert (
  1335. 'MINIO_ROOT_PASSWORD: "${MINIO_SECRET_ACCESS_KEY:?missing}"'
  1336. in generated_compose
  1337. )
  1338. assert "milvus-etcd" in generated_compose
  1339. assert "milvus-minio" in generated_compose
  1340. def test_generate_docker_compose_uses_template_images_even_with_old_env_overrides(
  1341. tmp_path: Path,
  1342. ) -> None:
  1343. """Managed services should be regenerated from templates instead of legacy image overrides."""
  1344. write_text_lines(
  1345. tmp_path / ".env",
  1346. [
  1347. "POSTGRES_IMAGE=registry.example.com/postgres-for-rag:patched",
  1348. "VLLM_EMBED_IMAGE_TAG=patched",
  1349. ],
  1350. )
  1351. write_text_lines(
  1352. tmp_path / "env.example",
  1353. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  1354. )
  1355. run_bash(f"""
  1356. set -euo pipefail
  1357. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1358. REPO_ROOT="{tmp_path}"
  1359. reset_state
  1360. load_existing_env_if_present
  1361. add_docker_service postgres
  1362. add_docker_service vllm-embed
  1363. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  1364. """)
  1365. result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8")
  1366. assert "image: gzdaniel/postgres-for-rag:pg18-age-pgvector" in result
  1367. assert "image: vllm/vllm-openai-cpu:latest" in result
  1368. assert "registry.example.com/postgres-for-rag:patched" not in result
  1369. assert "vllm/vllm-openai-cpu:patched" not in result
  1370. def test_generate_docker_compose_preserves_long_form_named_sidecar_volumes(
  1371. tmp_path: Path,
  1372. ) -> None:
  1373. """Managed-service regeneration must not misparse preserved long-form named volumes."""
  1374. write_text_lines(
  1375. tmp_path / "env.example",
  1376. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  1377. )
  1378. write_text_lines(
  1379. tmp_path / ".env", ["LLM_BINDING=openai", "EMBEDDING_BINDING=openai"]
  1380. )
  1381. write_text_lines(
  1382. tmp_path / "docker-compose.final.yml",
  1383. [
  1384. "services:",
  1385. " lightrag:",
  1386. " image: example/lightrag:test",
  1387. " sidecar:",
  1388. " image: busybox",
  1389. ' command: ["sleep", "infinity"]',
  1390. " volumes:",
  1391. " - source: sidecar_data",
  1392. " target: /data",
  1393. " type: volume",
  1394. "volumes:",
  1395. " sidecar_data:",
  1396. ],
  1397. )
  1398. run_bash(f"""
  1399. set -euo pipefail
  1400. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1401. REPO_ROOT="{tmp_path}"
  1402. reset_state
  1403. load_existing_env_if_present
  1404. add_docker_service postgres
  1405. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  1406. """)
  1407. result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8")
  1408. assert " sidecar_data:" in result
  1409. assert "\n source:\n" not in result
  1410. def test_generate_docker_compose_includes_all_atlas_local_mongodb_volumes(
  1411. tmp_path: Path,
  1412. ) -> None:
  1413. """MongoDB Atlas Local should emit data, config, and mongot named volumes."""
  1414. write_text_lines(
  1415. tmp_path / "env.example",
  1416. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  1417. )
  1418. run_bash(f"""
  1419. set -euo pipefail
  1420. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1421. REPO_ROOT="{tmp_path}"
  1422. reset_state
  1423. add_docker_service mongodb
  1424. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  1425. """)
  1426. result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8")
  1427. assert "hostname: mongodb" in result
  1428. assert "image: mongodb/mongodb-atlas-local:" in result
  1429. assert "mongo_data:/data/db" in result
  1430. assert "mongo_config_data:/data/configdb" in result
  1431. assert "mongo_mongot_data:/data/mongot" in result
  1432. assert "healthcheck:" not in result
  1433. assert (
  1434. "\nvolumes:\n mongo_data:\n mongo_config_data:\n mongo_mongot_data:\n"
  1435. in result
  1436. )
  1437. def test_generate_docker_compose_injects_server_host_and_port_overrides(
  1438. tmp_path: Path,
  1439. ) -> None:
  1440. """Generated compose should preserve variable-based host publishing and fix container bind values."""
  1441. compose_file = tmp_path / "docker-compose.yml"
  1442. compose_file.write_text(
  1443. "\n".join(
  1444. [
  1445. "services:",
  1446. " lightrag:",
  1447. " image: example/lightrag:test",
  1448. " env_file:",
  1449. " - .env",
  1450. " ports:",
  1451. ' - "${PORT:-9621}:9621"',
  1452. ]
  1453. )
  1454. + "\n",
  1455. encoding="utf-8",
  1456. )
  1457. run_bash(f"""
  1458. set -euo pipefail
  1459. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1460. REPO_ROOT="{tmp_path}"
  1461. reset_state
  1462. ENV_VALUES[HOST]="localhost"
  1463. ENV_VALUES[PORT]="8080"
  1464. prepare_compose_runtime_overrides
  1465. generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml\"
  1466. """)
  1467. generated_compose = (tmp_path / "docker-compose.generated.yml").read_text(
  1468. encoding="utf-8"
  1469. )
  1470. assert 'HOST: "0.0.0.0"' in generated_compose
  1471. assert 'PORT: "9621"' in generated_compose
  1472. assert ' - "${HOST:-0.0.0.0}:${PORT:-9621}:9621"' in generated_compose
  1473. def test_generate_docker_compose_injects_env_overrides_into_lightrag_not_after_managed_services(
  1474. tmp_path: Path,
  1475. ) -> None:
  1476. """Env overrides must appear inside the lightrag environment block, not after managed services.
  1477. When the base compose has a top-level volumes: section, the strip pass inserts a
  1478. __WIZARD_MANAGED_SERVICES__ marker at the point where volumes: begins. Before the
  1479. fix the environment injector would miss that marker (column-0 comment) as an
  1480. end-of-environment boundary and append overrides after it — which placed them outside
  1481. the lightrag service once postgres/neo4j were merged in.
  1482. """
  1483. compose_file = tmp_path / "docker-compose.yml"
  1484. compose_file.write_text(
  1485. "\n".join(
  1486. [
  1487. "services:",
  1488. " lightrag:",
  1489. " image: example/lightrag:test",
  1490. " environment:",
  1491. " EXISTING_KEY: existing_value",
  1492. " volumes:",
  1493. " - ./.env:/app/.env",
  1494. "volumes:",
  1495. " some_volume:",
  1496. ]
  1497. )
  1498. + "\n",
  1499. encoding="utf-8",
  1500. )
  1501. run_bash(f"""
  1502. set -euo pipefail
  1503. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1504. REPO_ROOT="{tmp_path}"
  1505. reset_state
  1506. ENV_VALUES[POSTGRES_USER]="lightrag"
  1507. ENV_VALUES[POSTGRES_PASSWORD]="secret"
  1508. ENV_VALUES[POSTGRES_DATABASE]="lightrag"
  1509. add_docker_service "postgres"
  1510. set_compose_override "LLM_BINDING_HOST" "http://host.docker.internal:11434"
  1511. generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml\"
  1512. """)
  1513. result = (tmp_path / "docker-compose.generated.yml").read_text(encoding="utf-8")
  1514. lightrag_pos = result.index(" lightrag:")
  1515. postgres_pos = result.index(" postgres:")
  1516. override_pos = result.index('LLM_BINDING_HOST: "http://host.docker.internal:11434"')
  1517. assert lightrag_pos < override_pos < postgres_pos
  1518. def test_generate_docker_compose_vllm_gpu_honors_documented_gpu_selector(
  1519. tmp_path: Path,
  1520. ) -> None:
  1521. """GPU vLLM compose should honor the documented CUDA selector variables."""
  1522. env_example = tmp_path / "env.example"
  1523. env_example.write_text(
  1524. "\n".join(
  1525. [
  1526. "# VLLM_RERANK_DEVICE=cuda",
  1527. "# CUDA_VISIBLE_DEVICES=-1",
  1528. "# NVIDIA_VISIBLE_DEVICES=all",
  1529. ]
  1530. )
  1531. + "\n",
  1532. encoding="utf-8",
  1533. )
  1534. compose_file = tmp_path / "docker-compose.yml"
  1535. compose_file.write_text(
  1536. "\n".join(
  1537. [
  1538. "services:",
  1539. " lightrag:",
  1540. " image: example/lightrag:test",
  1541. " env_file:",
  1542. " - .env",
  1543. ]
  1544. )
  1545. + "\n",
  1546. encoding="utf-8",
  1547. )
  1548. run_bash(f"""
  1549. set -euo pipefail
  1550. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1551. REPO_ROOT="{tmp_path}"
  1552. reset_state
  1553. ENV_VALUES[VLLM_RERANK_DEVICE]="cuda"
  1554. ENV_VALUES[CUDA_VISIBLE_DEVICES]="0"
  1555. add_docker_service "vllm-rerank"
  1556. generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env"
  1557. generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml\"
  1558. """)
  1559. generated_env = (tmp_path / ".env").read_text(encoding="utf-8")
  1560. generated_compose = (tmp_path / "docker-compose.generated.yml").read_text(
  1561. encoding="utf-8"
  1562. )
  1563. assert "CUDA_VISIBLE_DEVICES=0" in generated_env
  1564. assert "NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all}" in generated_compose
  1565. assert (
  1566. """ vllm-rerank:
  1567. condition: service_healthy"""
  1568. in generated_compose
  1569. )
  1570. assert " healthcheck:" in generated_compose
  1571. assert "VLLM_RERANK_PORT:-8000" in generated_compose
  1572. assert 'grep -q ":$${PORT_HEX} "' in generated_compose
  1573. @pytest.mark.parametrize(
  1574. ("device", "expected_image"),
  1575. [
  1576. ("cpu", "image: milvusdb/milvus:v2.6.11"),
  1577. ("cuda", "image: milvusdb/milvus:v2.6.11-gpu"),
  1578. ],
  1579. )
  1580. def test_generate_docker_compose_selects_milvus_template_from_device(
  1581. tmp_path: Path, device: str, expected_image: str
  1582. ) -> None:
  1583. """Milvus compose generation should switch templates based on MILVUS_DEVICE."""
  1584. write_text_lines(
  1585. tmp_path / "env.example",
  1586. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  1587. )
  1588. write_text_lines(
  1589. tmp_path / "docker-compose.yml",
  1590. ["services:", " lightrag:", " image: example/lightrag:test"],
  1591. )
  1592. run_bash(f"""
  1593. set -euo pipefail
  1594. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1595. REPO_ROOT="{tmp_path}"
  1596. reset_state
  1597. ENV_VALUES[MILVUS_DEVICE]="{device}"
  1598. add_docker_service milvus
  1599. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  1600. """)
  1601. generated_compose = (tmp_path / "docker-compose.final.yml").read_text(
  1602. encoding="utf-8"
  1603. )
  1604. assert expected_image in generated_compose
  1605. def test_generate_docker_compose_pairs_dashboards_with_opensearch(
  1606. tmp_path: Path,
  1607. ) -> None:
  1608. """Generating the opensearch service block must always emit a paired dashboards service so it remains under wizard management."""
  1609. write_text_lines(
  1610. tmp_path / "env.example",
  1611. (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(),
  1612. )
  1613. write_text_lines(
  1614. tmp_path / "docker-compose.yml",
  1615. ["services:", " lightrag:", " image: example/lightrag:test"],
  1616. )
  1617. run_bash(f"""
  1618. set -euo pipefail
  1619. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1620. REPO_ROOT="{tmp_path}"
  1621. reset_state
  1622. add_docker_service opensearch
  1623. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\"
  1624. """)
  1625. generated_compose = (tmp_path / "docker-compose.final.yml").read_text(
  1626. encoding="utf-8"
  1627. )
  1628. assert " opensearch:" in generated_compose
  1629. assert " dashboards:" in generated_compose
  1630. assert "opensearchproject/opensearch-dashboards:3" in generated_compose
  1631. assert "OPENSEARCH_HOSTS: '[\"https://opensearch:9200\"]'" in generated_compose
  1632. assert "condition: service_healthy" in generated_compose
  1633. def test_generate_docker_compose_omits_config_ini_mount_from_base_template(
  1634. tmp_path: Path,
  1635. ) -> None:
  1636. compose_file = tmp_path / "docker-compose.yml"
  1637. compose_file.write_text(
  1638. "\n".join(
  1639. [
  1640. "services:",
  1641. " lightrag:",
  1642. " image: example/lightrag:test",
  1643. " volumes:",
  1644. " - ./data/rag_storage:/app/data/rag_storage",
  1645. " - ./data/inputs:/app/data/inputs",
  1646. " - ./.env:/app/.env",
  1647. ]
  1648. )
  1649. + "\n",
  1650. encoding="utf-8",
  1651. )
  1652. run_bash(
  1653. f"""
  1654. set -euo pipefail
  1655. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1656. REPO_ROOT="{tmp_path}"
  1657. reset_state
  1658. generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml"
  1659. """
  1660. )
  1661. generated_compose = (tmp_path / "docker-compose.generated.yml").read_text(
  1662. encoding="utf-8"
  1663. )
  1664. assert "./config.ini:/app/config.ini" not in generated_compose
  1665. assert "./data/rag_storage:/app/data/rag_storage" in generated_compose
  1666. assert "./data/inputs:/app/data/inputs" in generated_compose
  1667. assert "./.env:/app/.env" in generated_compose
  1668. def test_generate_docker_compose_preserves_existing_config_ini_mount(
  1669. tmp_path: Path,
  1670. ) -> None:
  1671. compose_file = tmp_path / "docker-compose.final.yml"
  1672. compose_file.write_text(
  1673. "\n".join(
  1674. [
  1675. "services:",
  1676. " lightrag:",
  1677. " image: example/lightrag:test",
  1678. " volumes:",
  1679. " - ./data/rag_storage:/app/data/rag_storage",
  1680. " - ./data/inputs:/app/data/inputs",
  1681. " - ./config.ini:/app/config.ini",
  1682. " - ./.env:/app/.env",
  1683. ]
  1684. )
  1685. + "\n",
  1686. encoding="utf-8",
  1687. )
  1688. run_bash(
  1689. f"""
  1690. set -euo pipefail
  1691. source "{REPO_ROOT}/scripts/setup/setup.sh"
  1692. REPO_ROOT="{tmp_path}"
  1693. reset_state
  1694. generate_docker_compose "$REPO_ROOT/docker-compose.final.yml"
  1695. """
  1696. )
  1697. generated_compose = compose_file.read_text(encoding="utf-8")
  1698. assert "./config.ini:/app/config.ini" in generated_compose
  1699. assert "./data/rag_storage:/app/data/rag_storage" in generated_compose
  1700. assert "./data/inputs:/app/data/inputs" in generated_compose
  1701. assert "./.env:/app/.env" in generated_compose