# Regression tests for interactive setup wizard. # Classification: keep tests here when they cover generate_* helpers that render or rewrite .env and docker-compose file contents. from __future__ import annotations from pathlib import Path import pytest from tests.setup._helpers import ( PRESERVED_HEADER, PRESERVED_NOTICE, REPO_ROOT, parse_lines, run_bash, write_text_lines, ) pytestmark = pytest.mark.offline def test_generate_files_keep_host_env_values_and_inject_compose_overrides( tmp_path: Path, ) -> None: """This generation path keeps host-style values in `.env` and injects compose-only overrides separately.""" env_example = tmp_path / "env.example" env_example.write_text( "\n".join( [ "SSL_CERTFILE=/placeholder/cert.pem", "SSL_KEYFILE=/placeholder/key.pem", "LLM_BINDING_HOST=https://api.example.com/v1", "EMBEDDING_BINDING_HOST=https://api.example.com/v1", "RERANK_BINDING_HOST=https://api.example.com/v1", ] ) + "\n", encoding="utf-8", ) compose_file = tmp_path / "docker-compose.yml" compose_file.write_text( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " env_file:", " - .env", " volumes:", " - ./.env:/app/.env", ] ) + "\n", encoding="utf-8", ) cert_path = tmp_path / "cert.pem" cert_path.write_text("cert", encoding="utf-8") key_path = tmp_path / "key.pem" key_path.write_text("key", encoding="utf-8") run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state ENV_VALUES[SSL_CERTFILE]="{cert_path}" ENV_VALUES[SSL_KEYFILE]="{key_path}" ENV_VALUES[LLM_BINDING_HOST]="http://localhost:11434" ENV_VALUES[EMBEDDING_BINDING_HOST]="http://127.0.0.1:11434" ENV_VALUES[RERANK_BINDING_HOST]="http://localhost:8000/rerank" SSL_CERT_SOURCE_PATH="{cert_path}" SSL_KEY_SOURCE_PATH="{key_path}" prepare_compose_env_overrides stage_ssl_assets "$SSL_CERT_SOURCE_PATH" "$SSL_KEY_SOURCE_PATH" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env" generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml\" """) generated_env = (tmp_path / ".env").read_text(encoding="utf-8") generated_compose = (tmp_path / "docker-compose.generated.yml").read_text( encoding="utf-8" ) assert f"SSL_CERTFILE={cert_path}" in generated_env assert f"SSL_KEYFILE={key_path}" in generated_env assert "LLM_BINDING_HOST=http://localhost:11434" in generated_env assert "EMBEDDING_BINDING_HOST=http://127.0.0.1:11434" in generated_env assert "RERANK_BINDING_HOST=http://localhost:8000/rerank" in generated_env assert 'SSL_CERTFILE: "/app/data/certs/cert.pem"' in generated_compose assert 'SSL_KEYFILE: "/app/data/certs/key.pem"' in generated_compose assert 'LLM_BINDING_HOST: "http://host.docker.internal:11434"' in generated_compose assert ( 'EMBEDDING_BINDING_HOST: "http://host.docker.internal:11434"' in generated_compose ) assert ( 'RERANK_BINDING_HOST: "http://host.docker.internal:8000/rerank"' in generated_compose ) assert "./data/certs/cert.pem:/app/data/certs/cert.pem:ro" in generated_compose assert "./data/certs/key.pem:/app/data/certs/key.pem:ro" in generated_compose assert "env_file:" not in generated_compose def test_generate_docker_compose_removes_lightrag_env_file_to_preserve_dollar_values( tmp_path: Path, ) -> None: """Generated compose should remove `env_file` and skip empty environment blocks.""" write_text_lines( tmp_path / "docker-compose.yml", [ "services:", " lightrag:", " container_name: lightrag", " image: example/lightrag:test", " env_file:", " - .env", " volumes:", " - ./.env:/app/.env", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml\" """) generated_compose = (tmp_path / "docker-compose.generated.yml").read_text( encoding="utf-8" ) assert "env_file:" not in generated_compose assert "environment:" not in generated_compose assert "container_name:" not in generated_compose assert "- ./.env:/app/.env" in generated_compose def test_generate_docker_compose_removes_lightrag_container_name_from_existing_output( tmp_path: Path, ) -> None: """Compose regeneration should strip fixed lightrag container names from prior output.""" write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " container_name: lightrag", " image: example/lightrag:test", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) generated_compose = (tmp_path / "docker-compose.final.yml").read_text( encoding="utf-8" ) assert "container_name:" not in generated_compose def test_generate_docker_compose_preserves_list_style_lightrag_environment( tmp_path: Path, ) -> None: """Compose regeneration should not mix mapping entries into list-style environments.""" write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " environment:", " - PORT=9621", " - FOO=bar", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state set_compose_override "PORT" "1234" generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) generated_compose = (tmp_path / "docker-compose.final.yml").read_text( encoding="utf-8" ) assert ' - "PORT=1234"' in generated_compose assert " - FOO=bar" in generated_compose assert " PORT:" not in generated_compose def test_generate_docker_compose_injects_healthchecks_and_lightrag_depends_on( tmp_path: Path, ) -> None: """Generated compose should gate LightRAG on all managed dependencies becoming healthy.""" write_text_lines( tmp_path / "docker-compose.yml", ["services:", " lightrag:", " image: example/lightrag:test"], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state add_docker_service postgres add_docker_service neo4j add_docker_service mongodb add_docker_service redis add_docker_service milvus add_docker_service qdrant add_docker_service memgraph add_docker_service vllm-embed add_docker_service vllm-rerank generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) generated_compose = (tmp_path / "docker-compose.final.yml").read_text( encoding="utf-8" ) lightrag_start = generated_compose.index(" lightrag:\n") embed_start = generated_compose.index("\n vllm-embed:\n") lightrag_block = generated_compose[lightrag_start:embed_start] assert " depends_on:" in generated_compose assert " depends_on:" in lightrag_block for service_name in ( "postgres", "neo4j", "mongodb", "redis", "milvus", "qdrant", "memgraph", "vllm-embed", "vllm-rerank", ): assert ( f""" {service_name}: condition: service_healthy""" in lightrag_block ) assert generated_compose.count(" healthcheck:") == 10 assert " milvus-etcd:" in generated_compose assert " milvus-minio:" in generated_compose assert ( """ milvus-etcd: condition: service_healthy""" in generated_compose ) assert ( """ milvus-minio: condition: service_healthy""" in generated_compose ) def test_generate_docker_compose_preserves_user_depends_on_and_removes_stale_managed_entries( tmp_path: Path, ) -> None: """Compose regeneration should preserve user dependencies while refreshing wizard-managed ones.""" write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " depends_on:", " sidecar:", " condition: service_started", " postgres:", " condition: service_started", " vllm-embed:", " condition: service_healthy", " sidecar:", " image: busybox", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state add_docker_service postgres add_docker_service redis generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) generated_compose = (tmp_path / "docker-compose.final.yml").read_text( encoding="utf-8" ) assert ( """ sidecar: condition: service_started""" in generated_compose ) assert ( """ postgres: condition: service_healthy""" in generated_compose ) assert ( """ redis: condition: service_healthy""" in generated_compose ) assert ( """ vllm-embed: condition: service_healthy""" not in generated_compose ) def test_generate_docker_compose_repairs_misplaced_lightrag_depends_on_from_existing_output( tmp_path: Path, ) -> None: """Regeneration should move stale lightrag depends_on content back onto the lightrag service.""" write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " environment:", " vllm-rerank:", " image: example/vllm:test", " restart: unless-stopped", " depends_on:", " my-service:", " condition: service_healthy", "volumes:", " vllm_rerank_cache:", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state add_docker_service vllm-rerank generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) generated_compose = (tmp_path / "docker-compose.final.yml").read_text( encoding="utf-8" ) lightrag_start = generated_compose.index(" lightrag:\n") rerank_start = generated_compose.index("\n vllm-rerank:\n") lightrag_block = generated_compose[lightrag_start:rerank_start] rerank_block = generated_compose[rerank_start:] assert " depends_on:" in lightrag_block assert ( """ my-service: condition: service_healthy""" in lightrag_block ) assert ( """ vllm-rerank: condition: service_healthy""" in lightrag_block ) assert " depends_on:" not in rerank_block assert generated_compose.count("\n vllm-rerank:\n") == 1 def test_generate_docker_compose_normalizes_lightrag_restart_policy_from_existing_output( tmp_path: Path, ) -> None: """Regeneration should replace legacy lightrag restart with deploy.restart_policy.""" write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " restart: unless-stopped", " extra_hosts:", ' - "host.docker.internal:host-gateway"', ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) generated_compose = (tmp_path / "docker-compose.final.yml").read_text( encoding="utf-8" ) lightrag_start = generated_compose.index(" lightrag:\n") lightrag_block = generated_compose[lightrag_start:] assert " restart: unless-stopped" not in lightrag_block assert " deploy:\n" in lightrag_block assert " restart_policy:\n" in lightrag_block assert " condition: on-failure\n" in lightrag_block assert " max_attempts: 10\n" in lightrag_block def test_generate_docker_compose_normalizes_lightrag_restart_policy_without_blank_line_before_deploy( tmp_path: Path, ) -> None: """Regeneration should move the separator blank line after deploy, not before it.""" write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " restart: unless-stopped", "", " sidecar:", " image: busybox", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) generated_compose = (tmp_path / "docker-compose.final.yml").read_text( encoding="utf-8" ) assert ( """ image: example/lightrag:test deploy: """ not in generated_compose ) assert ( """ image: example/lightrag:test deploy: """ in generated_compose ) assert " max_attempts: 10\n\n volumes:\n" in generated_compose assert ( " - ./data/prompts:/app/data/prompts\n\n sidecar:\n" in generated_compose ) def test_generate_docker_compose_preserves_non_managed_named_volumes( tmp_path: Path, ) -> None: """Retained services should keep their referenced top-level named volumes.""" write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " volumes:", " - my_cache:/app/cache", " sidecar:", " image: busybox", ' command: ["sleep", "infinity"]', " volumes:", " - sidecar_data:/data", " postgres:", " image: old/postgres:image", " volumes:", " - postgres_data:/var/lib/postgresql/data", "volumes:", " my_cache:", " driver: local", " sidecar_data:", " driver: local", " postgres_data:", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") assert " sidecar:" in result assert "my_cache:/app/cache" in result assert "sidecar_data:/data" in result assert " my_cache:" in result assert " driver: local" in result assert " sidecar_data:" in result assert "postgres_data:" not in result def test_generate_docker_compose_inserts_managed_services_before_top_level_sections( tmp_path: Path, ) -> None: """Managed services should stay inside services: even when custom top-level sections exist.""" write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " volumes:", " - ./.env:/app/.env", " worker:", " image: example/worker:test", " networks:", " - appnet", "networks:", " appnet:", " driver: bridge", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state ENV_VALUES[POSTGRES_USER]="lightrag" ENV_VALUES[POSTGRES_PASSWORD]="secret" ENV_VALUES[POSTGRES_DATABASE]="lightrag" add_docker_service "postgres" generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") assert " postgres:" in result assert "\n\nnetworks:\n" in result assert result.index("\n postgres:") < result.index("\nnetworks:\n") assert " appnet:" in result def test_generate_docker_compose_cleans_marker_and_blank_lines_when_only_lightrag_remains( tmp_path: Path, ) -> None: """Regeneration should not leave a managed-services marker or stacked blank lines behind.""" write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " depends_on:", " vllm-embed:", " condition: service_healthy", " vllm-rerank:", " condition: service_healthy", "", " vllm-embed:", " image: example/vllm:embed", "", " vllm-rerank:", " image: example/vllm:rerank", "", "", "", "# __WIZARD_MANAGED_SERVICES__", "networks:", " appnet:", " driver: bridge", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") assert " vllm-embed:" not in result assert " vllm-rerank:" not in result assert "__WIZARD_MANAGED_SERVICES__" not in result assert "depends_on:" not in result assert " max_attempts: 10\n\nnetworks:\n" in result def test_generate_docker_compose_keeps_blank_line_between_managed_service_and_top_level_sections( tmp_path: Path, ) -> None: """Managed service blocks should stay visually separated from following top-level sections.""" write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", "networks:", " web_network:", " driver: bridge", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state add_docker_service "vllm-embed" generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") assert " vllm-embed:" in result assert ( """ max_attempts: 10 depends_on: """ in result ) assert " restart: unless-stopped\n\nnetworks:\n" in result def test_generate_docker_compose_keeps_single_blank_line_before_generated_volumes( tmp_path: Path, ) -> None: """Generated top-level volumes should be separated from prior sections by one blank line.""" write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", "networks:", " web_network:", " driver: bridge", "", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state add_docker_service "vllm-embed" generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") assert "\n\nvolumes:\n" in result assert "\n\n\nvolumes:\n" not in result def test_generate_env_file_comments_out_later_duplicate_active_keys( tmp_path: Path, ) -> None: """Commented example keys should not be overridden by later active defaults.""" run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state ENV_VALUES[EMBEDDING_BINDING]="ollama" ENV_VALUES[EMBEDDING_MODEL]="bge-m3:latest" ENV_VALUES[EMBEDDING_DIM]="1024" ENV_VALUES[EMBEDDING_BINDING_HOST]="http://localhost:11434" generate_env_file "{REPO_ROOT}/env.example" "$REPO_ROOT/.env\" """) generated_env = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() active_embedding_lines = [ line for line in generated_env if line.startswith("EMBEDDING_BINDING=") ] active_model_lines = [ line for line in generated_env if line.startswith("EMBEDDING_MODEL=") ] active_host_lines = [ line for line in generated_env if line.startswith("EMBEDDING_BINDING_HOST=") ] assert active_embedding_lines == ["EMBEDDING_BINDING=ollama"] assert active_model_lines == ["EMBEDDING_MODEL=bge-m3:latest"] assert active_host_lines == ["EMBEDDING_BINDING_HOST=http://localhost:11434"] assert "# EMBEDDING_BINDING=openai" in generated_env def test_generate_env_file_preserves_custom_variables_not_declared_in_template( tmp_path: Path, ) -> None: """Reruns should keep custom `.env` variables that are not declared in env.example.""" write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"]) write_text_lines( tmp_path / ".env", [ "HOST=127.0.0.1", "", "# Custom integration settings", "EXTRA_API_BASE='https://example.com/api'", "# EXTRA_API_TOKEN=secret", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_env_file "$REPO_ROOT/.env" ENV_VALUES[HOST]="0.0.0.0" ENV_VALUES[PORT]="9621" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert "HOST=0.0.0.0" in generated_env assert "PORT=9621" in generated_env assert PRESERVED_HEADER in generated_env assert "# Custom integration settings" not in generated_env assert "EXTRA_API_BASE='https://example.com/api'" in generated_env assert "# EXTRA_API_TOKEN=secret" in generated_env def test_generate_env_file_keeps_preserved_section_idempotent_across_reruns( tmp_path: Path, ) -> None: """Repeated reruns should keep a single preserved marker and its leading blank line.""" write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"]) write_text_lines( tmp_path / ".env", [ "HOST=127.0.0.1", "", PRESERVED_HEADER, "", "# Custom integration settings", "EXTRA_API_BASE='https://example.com/api'", "# EXTRA_API_TOKEN=secret", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_env_file "$REPO_ROOT/.env" ENV_VALUES[HOST]="0.0.0.0" ENV_VALUES[PORT]="9621" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() marker = PRESERVED_HEADER notice = PRESERVED_NOTICE marker_indexes = [idx for idx, line in enumerate(generated_lines) if line == marker] assert marker_indexes == [3] assert generated_lines[2] == "" assert generated_lines[4] == notice assert generated_lines[5] == "" assert generated_lines[6] == "# Custom integration settings" assert generated_lines[7] == "EXTRA_API_BASE='https://example.com/api'" assert generated_lines[8] == "# EXTRA_API_TOKEN=secret" def test_generate_env_file_preserves_multi_line_comments_inside_preserved_section( tmp_path: Path, ) -> None: """Only comments already inside the preserved section should survive reruns.""" write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"]) write_text_lines( tmp_path / ".env", [ "HOST=127.0.0.1", "", "# External note that should not migrate", PRESERVED_HEADER, "", "# Group A", "# Shared settings", "EXTRA_API_BASE='https://example.com/api'", "", "# Group B", "EXTRA_API_TOKEN=secret", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_env_file "$REPO_ROOT/.env" ENV_VALUES[HOST]="0.0.0.0" ENV_VALUES[PORT]="9621" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() marker = PRESERVED_HEADER notice = PRESERVED_NOTICE marker_index = generated_lines.index(marker) assert generated_lines.count(marker) == 1 assert generated_lines.count(notice) == 1 assert "# External note that should not migrate" not in generated_lines assert generated_lines[marker_index + 1] == notice assert generated_lines[marker_index + 2] == "" assert generated_lines[marker_index + 3] == "# Group A" assert generated_lines[marker_index + 4] == "# Shared settings" assert ( generated_lines[marker_index + 5] == "EXTRA_API_BASE='https://example.com/api'" ) assert generated_lines[marker_index + 6] == "" assert generated_lines[marker_index + 7] == "# Group B" assert generated_lines[marker_index + 8] == "EXTRA_API_TOKEN=secret" def test_generate_env_file_preserves_trailing_comments_at_end_of_preserved_section( tmp_path: Path, ) -> None: """Free-form comments after the last preserved variable should survive reruns.""" write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"]) write_text_lines( tmp_path / ".env", [ "HOST=127.0.0.1", "", PRESERVED_HEADER, PRESERVED_NOTICE, "", "EXTRA_API_BASE='https://example.com/api'", "# Free-form note", "# This should stay at EOF", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_env_file "$REPO_ROOT/.env" ENV_VALUES[HOST]="0.0.0.0" ENV_VALUES[PORT]="9621" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() assert generated_lines[-3] == "EXTRA_API_BASE='https://example.com/api'" assert generated_lines[-2] == "# Free-form note" assert generated_lines[-1] == "# This should stay at EOF" def test_generate_env_file_appends_new_external_entries_after_existing_preserved_block( tmp_path: Path, ) -> None: """New template-external entries should be appended after the existing preserved payload.""" write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"]) write_text_lines( tmp_path / ".env", [ "HOST=127.0.0.1", "EXTRA_EARLY=alpha", PRESERVED_HEADER, PRESERVED_NOTICE, "", "# Existing note", "EXTRA_EXISTING=omega", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_env_file "$REPO_ROOT/.env" ENV_VALUES[HOST]="0.0.0.0" ENV_VALUES[PORT]="9621" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() marker_index = generated_lines.index(PRESERVED_HEADER) assert generated_lines[marker_index + 1] == PRESERVED_NOTICE assert generated_lines[marker_index + 2] == "" assert generated_lines[marker_index + 3] == "# Existing note" assert generated_lines[marker_index + 4] == "EXTRA_EXISTING=omega" assert generated_lines[marker_index + 5] == "EXTRA_EARLY=alpha" def test_generate_env_file_appends_multiple_new_external_entries_in_discovery_order( tmp_path: Path, ) -> None: """Multiple new external entries should append after preserved payload in source order.""" write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"]) write_text_lines( tmp_path / ".env", [ "HOST=127.0.0.1", "EXTRA_FIRST=one", "# Outside comment should not migrate", "EXTRA_SECOND=two", PRESERVED_HEADER, PRESERVED_NOTICE, "", "EXTRA_EXISTING=existing", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_env_file "$REPO_ROOT/.env" ENV_VALUES[HOST]="0.0.0.0" ENV_VALUES[PORT]="9621" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() marker_index = generated_lines.index(PRESERVED_HEADER) assert "# Outside comment should not migrate" not in generated_lines assert generated_lines[marker_index + 3] == "EXTRA_EXISTING=existing" assert generated_lines[marker_index + 4] == "EXTRA_FIRST=one" assert generated_lines[marker_index + 5] == "EXTRA_SECOND=two" def test_generate_env_file_keeps_commented_template_keys_inside_preserved_section( tmp_path: Path, ) -> None: """Commented env vars already placed in preserved should survive even if the template declares them.""" write_text_lines( tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621", "# ENTITY_EXTRACTION_USE_JSON=true"], ) write_text_lines( tmp_path / ".env", [ "HOST=127.0.0.1", PRESERVED_HEADER, PRESERVED_NOTICE, "", "# ENTITY_EXTRACTION_USE_JSON=true", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_env_file "$REPO_ROOT/.env" ENV_VALUES[HOST]="0.0.0.0" ENV_VALUES[PORT]="9621" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() marker_index = generated_lines.index(PRESERVED_HEADER) assert generated_lines.count("# ENTITY_EXTRACTION_USE_JSON=true") == 2 assert generated_lines[marker_index + 3] == "# ENTITY_EXTRACTION_USE_JSON=true" def test_generate_env_file_recognizes_lowercase_extra_variables(tmp_path: Path) -> None: """Lowercase template-external variables should be preserved like uppercase ones.""" write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"]) write_text_lines(tmp_path / ".env", ["HOST=127.0.0.1", "workspace_name=demo"]) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_env_file "$REPO_ROOT/.env" ENV_VALUES[HOST]="0.0.0.0" ENV_VALUES[PORT]="9621" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() assert PRESERVED_HEADER in generated_lines assert "workspace_name=demo" in generated_lines def test_generate_env_file_recognizes_lowercase_commented_extra_variables( tmp_path: Path, ) -> None: """Lowercase commented env vars should create and survive in the preserved section.""" write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"]) write_text_lines(tmp_path / ".env", ["HOST=127.0.0.1", "# workspace_name=demo"]) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_env_file "$REPO_ROOT/.env" ENV_VALUES[HOST]="0.0.0.0" ENV_VALUES[PORT]="9621" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() assert PRESERVED_HEADER in generated_lines assert "# workspace_name=demo" in generated_lines def test_generate_env_file_uses_template_preserved_block_when_env_missing( tmp_path: Path, ) -> None: """Missing `.env` should still produce the preserved block from env.example.""" write_text_lines( tmp_path / "env.example", [ "HOST=0.0.0.0", PRESERVED_HEADER, PRESERVED_NOTICE, "### Template preserved comment", "# template_example=true", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state ENV_VALUES[HOST]="0.0.0.0" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() assert PRESERVED_HEADER in generated_lines assert PRESERVED_NOTICE in generated_lines assert generated_lines.count(PRESERVED_HEADER) == 1 assert generated_lines.count(PRESERVED_NOTICE) == 1 assert "### Template preserved comment" in generated_lines assert "# template_example=true" in generated_lines def test_generate_env_file_keeps_template_separator_adjacent_to_preserved_header( tmp_path: Path, ) -> None: """Injected template preserved blocks should not add a blank line after the copied separator.""" write_text_lines( tmp_path / "env.example", [ "HOST=0.0.0.0", "##########################################################################", PRESERVED_HEADER, PRESERVED_NOTICE, "### Template preserved comment", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state ENV_VALUES[HOST]="0.0.0.0" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() header_index = generated_lines.index(PRESERVED_HEADER) assert ( generated_lines[header_index - 1] == "##########################################################################" ) def test_generate_env_file_does_not_inject_template_payload_when_old_preserved_exists( tmp_path: Path, ) -> None: """Existing preserved blocks should stay authoritative over template preserved payload.""" write_text_lines( tmp_path / "env.example", [ "HOST=0.0.0.0", PRESERVED_HEADER, PRESERVED_NOTICE, "### Template preserved comment", "# template_example=true", ], ) write_text_lines( tmp_path / ".env", [ "HOST=127.0.0.1", "", PRESERVED_HEADER, "", "# Existing preserved comment", "EXTRA_OLD=1", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_env_file "$REPO_ROOT/.env" ENV_VALUES[HOST]="0.0.0.0" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() assert PRESERVED_HEADER in generated_lines assert PRESERVED_NOTICE in generated_lines assert "### Template preserved comment" not in generated_lines assert "# template_example=true" not in generated_lines assert "# Existing preserved comment" in generated_lines assert "EXTRA_OLD=1" in generated_lines def test_generate_env_file_keeps_old_preserved_lines_even_when_they_match_template( tmp_path: Path, ) -> None: """Old preserved content should not be removed just because it matches env.example.""" write_text_lines( tmp_path / "env.example", [ "HOST=0.0.0.0", PRESERVED_HEADER, PRESERVED_NOTICE, "### Template preserved comment", "# template_example=true", ], ) write_text_lines( tmp_path / ".env", [ "HOST=127.0.0.1", "", PRESERVED_HEADER, "### Template preserved comment", "# template_example=true", "EXTRA_OLD=1", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_env_file "$REPO_ROOT/.env" ENV_VALUES[HOST]="0.0.0.0" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() assert PRESERVED_HEADER in generated_lines assert PRESERVED_NOTICE in generated_lines assert "### Template preserved comment" in generated_lines assert "# template_example=true" in generated_lines assert "EXTRA_OLD=1" in generated_lines def test_generate_env_file_preserves_comments_before_active_template_keys_in_preserved( tmp_path: Path, ) -> None: """Comments in preserved should survive even when followed by active template-managed keys.""" write_text_lines(tmp_path / "env.example", ["HOST=0.0.0.0", "# PORT=9621"]) write_text_lines( tmp_path / ".env", [ "HOST=127.0.0.1", PRESERVED_HEADER, PRESERVED_NOTICE, "", "# Preserved note before active template key", "# Another note", "PORT=9999", "EXTRA_AFTER=1", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_env_file "$REPO_ROOT/.env" ENV_VALUES[HOST]="0.0.0.0" ENV_VALUES[PORT]="9621" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() marker_index = generated_lines.index(PRESERVED_HEADER) assert ( generated_lines[marker_index + 3] == "# Preserved note before active template key" ) assert generated_lines[marker_index + 4] == "# Another note" assert "PORT=9999" not in generated_lines[marker_index + 1 :] assert "EXTRA_AFTER=1" in generated_lines def test_generate_env_file_appends_extra_variables_after_template_preserved_block( tmp_path: Path, ) -> None: """Extras from old `.env` should append after the template preserved block when none existed before.""" write_text_lines( tmp_path / "env.example", [ "HOST=0.0.0.0", PRESERVED_HEADER, PRESERVED_NOTICE, "### Template preserved comment", "# template_example=true", ], ) write_text_lines(tmp_path / ".env", ["HOST=127.0.0.1", "EXTRA_NEW=1"]) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_env_file "$REPO_ROOT/.env" ENV_VALUES[HOST]="0.0.0.0" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() assert generated_lines[-3] == "### Template preserved comment" assert generated_lines[-2] == "# template_example=true" assert generated_lines[-1] == "EXTRA_NEW=1" def test_generate_env_file_appends_commented_env_vars_after_template_preserved_block( tmp_path: Path, ) -> None: """Commented env vars from old `.env` should append after the template preserved block when none existed before.""" write_text_lines( tmp_path / "env.example", [ "HOST=0.0.0.0", PRESERVED_HEADER, PRESERVED_NOTICE, "### Template preserved comment", "# template_example=true", ], ) write_text_lines(tmp_path / ".env", ["HOST=127.0.0.1", "# EXTRA_COMMENTED=1"]) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_env_file "$REPO_ROOT/.env" ENV_VALUES[HOST]="0.0.0.0" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() assert generated_lines[-3] == "### Template preserved comment" assert generated_lines[-2] == "# template_example=true" assert generated_lines[-1] == "# EXTRA_COMMENTED=1" def test_generate_env_file_round_trips_dollar_signs_in_single_quoted_values( tmp_path: Path, ) -> None: """Quoted values containing `$` should survive generate/load cycles unchanged.""" env_example = tmp_path / "env.example" env_example.write_text( "\n".join( [ "TOKEN_SECRET=placeholder", "LIGHTRAG_API_KEY=placeholder", "WEBUI_DESCRIPTION=placeholder", ] ) + "\n", encoding="utf-8", ) output = run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state ENV_VALUES[TOKEN_SECRET]='abc$HOME' ENV_VALUES[LIGHTRAG_API_KEY]='plain$token' ENV_VALUES[WEBUI_DESCRIPTION]='value with "$PATH" and $HOME' generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env" reset_state load_env_file "$REPO_ROOT/.env" printf 'TOKEN_SECRET=%s\\n' "${{ENV_VALUES[TOKEN_SECRET]}}" printf 'LIGHTRAG_API_KEY=%s\\n' "${{ENV_VALUES[LIGHTRAG_API_KEY]}}" printf 'WEBUI_DESCRIPTION=%s\\n' "${{ENV_VALUES[WEBUI_DESCRIPTION]}}\" """) values = parse_lines(output) generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert "TOKEN_SECRET='abc$HOME'" in generated_env assert "LIGHTRAG_API_KEY='plain$token'" in generated_env assert "WEBUI_DESCRIPTION='value with \"$PATH\" and $HOME'" in generated_env assert values["TOKEN_SECRET"] == "abc$HOME" assert values["LIGHTRAG_API_KEY"] == "plain$token" assert values["WEBUI_DESCRIPTION"] == 'value with "$PATH" and $HOME' def test_generate_env_file_avoids_double_quotes_for_compose_sensitive_strings( tmp_path: Path, ) -> None: """Setup output should avoid double quotes for affected string variables.""" env_example = tmp_path / "env.example" env_example.write_text( "\n".join( [ "WEBUI_TITLE='My Graph KB'", "WEBUI_DESCRIPTION='Simple and Fast Graph Based RAG System'", "# AUTH_ACCOUNTS='admin:admin123,user1:{bcrypt}$2b$12$hash'", "# LANGFUSE_SECRET_KEY=''", "# LANGFUSE_PUBLIC_KEY=''", "# LANGFUSE_HOST='https://cloud.langfuse.com'", ] ) + "\n", encoding="utf-8", ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state ENV_VALUES[WEBUI_TITLE]='My Graph KB' ENV_VALUES[WEBUI_DESCRIPTION]='Simple and Fast Graph Based RAG System' ENV_VALUES[AUTH_ACCOUNTS]='admin:admin123,user1:pa$$word' ENV_VALUES[LANGFUSE_SECRET_KEY]='sk-lf-secret' ENV_VALUES[LANGFUSE_PUBLIC_KEY]='pk-lf-public' ENV_VALUES[LANGFUSE_HOST]='https://langfuse.example' generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env\" """) generated_lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines() assert "WEBUI_TITLE='My Graph KB'" in generated_lines assert ( "WEBUI_DESCRIPTION='Simple and Fast Graph Based RAG System'" in generated_lines ) assert "AUTH_ACCOUNTS='admin:admin123,user1:pa$$word'" in generated_lines assert "LANGFUSE_SECRET_KEY=sk-lf-secret" in generated_lines assert "LANGFUSE_PUBLIC_KEY=pk-lf-public" in generated_lines assert "LANGFUSE_HOST=https://langfuse.example" in generated_lines assert not any( line.startswith('WEBUI_TITLE="') or line.startswith('WEBUI_DESCRIPTION="') or line.startswith('AUTH_ACCOUNTS="') or line.startswith('LANGFUSE_SECRET_KEY="') or line.startswith('LANGFUSE_PUBLIC_KEY="') or line.startswith('LANGFUSE_HOST="') for line in generated_lines ) def test_generate_docker_compose_escapes_dollar_signs_in_overrides_and_service_secrets( tmp_path: Path, ) -> None: """Compose generation should keep `$` literals in runtime overrides and bundled secrets.""" write_text_lines( tmp_path / "docker-compose.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " env_file:", " - .env", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state ENV_VALUES[MONGO_URI]='mongodb://user:p$HOME@localhost:27017/' ENV_VALUES[POSTGRES_USER]='user$ID' ENV_VALUES[POSTGRES_PASSWORD]='pass$HOME' ENV_VALUES[POSTGRES_DATABASE]='db$NAME' ENV_VALUES[NEO4J_PASSWORD]='neo$PASS' ENV_VALUES[NEO4J_DATABASE]='graph$DB' ENV_VALUES[MINIO_ACCESS_KEY_ID]='minio$USER' ENV_VALUES[MINIO_SECRET_ACCESS_KEY]='minio$SECRET' prepare_compose_runtime_overrides add_docker_service postgres add_docker_service neo4j add_docker_service milvus generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml\" """) generated_compose = (tmp_path / "docker-compose.generated.yml").read_text( encoding="utf-8" ) assert ( 'MONGO_URI: "mongodb://user:p$$HOME@host.docker.internal:27017/"' in generated_compose ) assert 'POSTGRES_USER: "user$$ID"' in generated_compose assert 'POSTGRES_PASSWORD: "pass$$HOME"' in generated_compose assert 'POSTGRES_DB: "db$$NAME"' in generated_compose assert ( "NEO4J_AUTH: ${NEO4J_USERNAME:?missing}/${NEO4J_PASSWORD:?missing}" in generated_compose ) assert 'NEO4J_dbms_default__database: "graph$$DB"' in generated_compose assert 'MINIO_ACCESS_KEY_ID: "${MINIO_ACCESS_KEY_ID:?missing}"' in generated_compose assert ( 'MINIO_SECRET_ACCESS_KEY: "${MINIO_SECRET_ACCESS_KEY:?missing}"' in generated_compose ) assert 'MINIO_ROOT_USER: "${MINIO_ACCESS_KEY_ID:?missing}"' in generated_compose assert ( 'MINIO_ROOT_PASSWORD: "${MINIO_SECRET_ACCESS_KEY:?missing}"' in generated_compose ) assert "milvus-etcd" in generated_compose assert "milvus-minio" in generated_compose def test_generate_docker_compose_uses_template_images_even_with_old_env_overrides( tmp_path: Path, ) -> None: """Managed services should be regenerated from templates instead of legacy image overrides.""" write_text_lines( tmp_path / ".env", [ "POSTGRES_IMAGE=registry.example.com/postgres-for-rag:patched", "VLLM_EMBED_IMAGE_TAG=patched", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present add_docker_service postgres add_docker_service vllm-embed generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") assert "image: gzdaniel/postgres-for-rag:pg18-age-pgvector" in result assert "image: vllm/vllm-openai-cpu:latest" in result assert "registry.example.com/postgres-for-rag:patched" not in result assert "vllm/vllm-openai-cpu:patched" not in result def test_generate_docker_compose_preserves_long_form_named_sidecar_volumes( tmp_path: Path, ) -> None: """Managed-service regeneration must not misparse preserved long-form named volumes.""" write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) write_text_lines( tmp_path / ".env", ["LLM_BINDING=openai", "EMBEDDING_BINDING=openai"] ) write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " sidecar:", " image: busybox", ' command: ["sleep", "infinity"]', " volumes:", " - source: sidecar_data", " target: /data", " type: volume", "volumes:", " sidecar_data:", ], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present add_docker_service postgres generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") assert " sidecar_data:" in result assert "\n source:\n" not in result def test_generate_docker_compose_includes_all_atlas_local_mongodb_volumes( tmp_path: Path, ) -> None: """MongoDB Atlas Local should emit data, config, and mongot named volumes.""" write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state add_docker_service mongodb generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") assert "hostname: mongodb" in result assert "image: mongodb/mongodb-atlas-local:" in result assert "mongo_data:/data/db" in result assert "mongo_config_data:/data/configdb" in result assert "mongo_mongot_data:/data/mongot" in result assert "healthcheck:" not in result assert ( "\nvolumes:\n mongo_data:\n mongo_config_data:\n mongo_mongot_data:\n" in result ) def test_generate_docker_compose_injects_server_host_and_port_overrides( tmp_path: Path, ) -> None: """Generated compose should preserve variable-based host publishing and fix container bind values.""" compose_file = tmp_path / "docker-compose.yml" compose_file.write_text( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " env_file:", " - .env", " ports:", ' - "${PORT:-9621}:9621"', ] ) + "\n", encoding="utf-8", ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state ENV_VALUES[HOST]="localhost" ENV_VALUES[PORT]="8080" prepare_compose_runtime_overrides generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml\" """) generated_compose = (tmp_path / "docker-compose.generated.yml").read_text( encoding="utf-8" ) assert 'HOST: "0.0.0.0"' in generated_compose assert 'PORT: "9621"' in generated_compose assert ' - "${HOST:-0.0.0.0}:${PORT:-9621}:9621"' in generated_compose def test_generate_docker_compose_injects_env_overrides_into_lightrag_not_after_managed_services( tmp_path: Path, ) -> None: """Env overrides must appear inside the lightrag environment block, not after managed services. When the base compose has a top-level volumes: section, the strip pass inserts a __WIZARD_MANAGED_SERVICES__ marker at the point where volumes: begins. Before the fix the environment injector would miss that marker (column-0 comment) as an end-of-environment boundary and append overrides after it — which placed them outside the lightrag service once postgres/neo4j were merged in. """ compose_file = tmp_path / "docker-compose.yml" compose_file.write_text( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " environment:", " EXISTING_KEY: existing_value", " volumes:", " - ./.env:/app/.env", "volumes:", " some_volume:", ] ) + "\n", encoding="utf-8", ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state ENV_VALUES[POSTGRES_USER]="lightrag" ENV_VALUES[POSTGRES_PASSWORD]="secret" ENV_VALUES[POSTGRES_DATABASE]="lightrag" add_docker_service "postgres" set_compose_override "LLM_BINDING_HOST" "http://host.docker.internal:11434" generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml\" """) result = (tmp_path / "docker-compose.generated.yml").read_text(encoding="utf-8") lightrag_pos = result.index(" lightrag:") postgres_pos = result.index(" postgres:") override_pos = result.index('LLM_BINDING_HOST: "http://host.docker.internal:11434"') assert lightrag_pos < override_pos < postgres_pos def test_generate_docker_compose_vllm_gpu_honors_documented_gpu_selector( tmp_path: Path, ) -> None: """GPU vLLM compose should honor the documented CUDA selector variables.""" env_example = tmp_path / "env.example" env_example.write_text( "\n".join( [ "# VLLM_RERANK_DEVICE=cuda", "# CUDA_VISIBLE_DEVICES=-1", "# NVIDIA_VISIBLE_DEVICES=all", ] ) + "\n", encoding="utf-8", ) compose_file = tmp_path / "docker-compose.yml" compose_file.write_text( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " env_file:", " - .env", ] ) + "\n", encoding="utf-8", ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state ENV_VALUES[VLLM_RERANK_DEVICE]="cuda" ENV_VALUES[CUDA_VISIBLE_DEVICES]="0" add_docker_service "vllm-rerank" generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env" generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml\" """) generated_env = (tmp_path / ".env").read_text(encoding="utf-8") generated_compose = (tmp_path / "docker-compose.generated.yml").read_text( encoding="utf-8" ) assert "CUDA_VISIBLE_DEVICES=0" in generated_env assert "NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all}" in generated_compose assert ( """ vllm-rerank: condition: service_healthy""" in generated_compose ) assert " healthcheck:" in generated_compose assert "VLLM_RERANK_PORT:-8000" in generated_compose assert 'grep -q ":$${PORT_HEX} "' in generated_compose @pytest.mark.parametrize( ("device", "expected_image"), [ ("cpu", "image: milvusdb/milvus:v2.6.11"), ("cuda", "image: milvusdb/milvus:v2.6.11-gpu"), ], ) def test_generate_docker_compose_selects_milvus_template_from_device( tmp_path: Path, device: str, expected_image: str ) -> None: """Milvus compose generation should switch templates based on MILVUS_DEVICE.""" write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) write_text_lines( tmp_path / "docker-compose.yml", ["services:", " lightrag:", " image: example/lightrag:test"], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state ENV_VALUES[MILVUS_DEVICE]="{device}" add_docker_service milvus generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) generated_compose = (tmp_path / "docker-compose.final.yml").read_text( encoding="utf-8" ) assert expected_image in generated_compose def test_generate_docker_compose_pairs_dashboards_with_opensearch( tmp_path: Path, ) -> None: """Generating the opensearch service block must always emit a paired dashboards service so it remains under wizard management.""" write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) write_text_lines( tmp_path / "docker-compose.yml", ["services:", " lightrag:", " image: example/lightrag:test"], ) run_bash(f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state add_docker_service opensearch generate_docker_compose "$REPO_ROOT/docker-compose.final.yml\" """) generated_compose = (tmp_path / "docker-compose.final.yml").read_text( encoding="utf-8" ) assert " opensearch:" in generated_compose assert " dashboards:" in generated_compose assert "opensearchproject/opensearch-dashboards:3" in generated_compose assert "OPENSEARCH_HOSTS: '[\"https://opensearch:9200\"]'" in generated_compose assert "condition: service_healthy" in generated_compose def test_generate_docker_compose_omits_config_ini_mount_from_base_template( tmp_path: Path, ) -> None: compose_file = tmp_path / "docker-compose.yml" compose_file.write_text( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " volumes:", " - ./data/rag_storage:/app/data/rag_storage", " - ./data/inputs:/app/data/inputs", " - ./.env:/app/.env", ] ) + "\n", encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml" """ ) generated_compose = (tmp_path / "docker-compose.generated.yml").read_text( encoding="utf-8" ) assert "./config.ini:/app/config.ini" not in generated_compose assert "./data/rag_storage:/app/data/rag_storage" in generated_compose assert "./data/inputs:/app/data/inputs" in generated_compose assert "./.env:/app/.env" in generated_compose def test_generate_docker_compose_preserves_existing_config_ini_mount( tmp_path: Path, ) -> None: compose_file = tmp_path / "docker-compose.final.yml" compose_file.write_text( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " volumes:", " - ./data/rag_storage:/app/data/rag_storage", " - ./data/inputs:/app/data/inputs", " - ./config.ini:/app/config.ini", " - ./.env:/app/.env", ] ) + "\n", encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state generate_docker_compose "$REPO_ROOT/docker-compose.final.yml" """ ) generated_compose = compose_file.read_text(encoding="utf-8") assert "./config.ini:/app/config.ini" in generated_compose assert "./data/rag_storage:/app/data/rag_storage" in generated_compose assert "./data/inputs:/app/data/inputs" in generated_compose assert "./.env:/app/.env" in generated_compose