"""Regression tests for interactive setup host vs. compose configuration.""" from __future__ import annotations import re import subprocess from pathlib import Path import pytest pytestmark = pytest.mark.offline REPO_ROOT = Path(__file__).resolve().parents[1] PRESERVED_HEADER = ( "### ----- Preserved custom environment variables from previous .env -----" ) PRESERVED_NOTICE = ( "### ----- Comments in this session will persist across regenerations -----" ) def run_bash_process( script: str, cwd: Path | None = None, stdin: str | None = "" ) -> subprocess.CompletedProcess[str]: """Run a bash snippet and return the completed process.""" return subprocess.run( ["bash", "--norc", "--noprofile", "-c", script], cwd=cwd or REPO_ROOT, input=stdin, capture_output=True, text=True, check=False, ) def run_bash(script: str, cwd: Path | None = None) -> str: """Run a bash snippet and return stdout.""" result = run_bash_process(script, cwd=cwd) if result.returncode != 0: raise AssertionError( f"bash script failed with code {result.returncode}\n" f"stdout:\n{result.stdout}\n" f"stderr:\n{result.stderr}" ) return result.stdout def parse_lines(output: str) -> dict[str, str]: """Parse KEY=value lines into a dictionary.""" values: dict[str, str] = {} for line in output.splitlines(): if "=" not in line: continue key, value = line.split("=", 1) values[key] = value return values def run_bash_lines(script: str, cwd: Path | None = None) -> dict[str, str]: """Run a bash snippet and parse KEY=value lines from stdout.""" return parse_lines(run_bash(script, cwd=cwd)) def write_text_lines(path: Path, lines: list[str]) -> Path: """Write lines to a fixture file with a trailing newline.""" path.write_text("\n".join(lines) + "\n", encoding="utf-8") return path def assert_single_compose_backup(tmp_path: Path, expected_content: str) -> Path: """Assert that a single compose backup exists with the expected content.""" backups = sorted(tmp_path.glob("docker-compose.backup*.yml")) assert len(backups) == 1 assert re.fullmatch(r"docker-compose\.backup\d{8}_\d{6}\.yml", backups[0].name) assert backups[0].read_text(encoding="utf-8") == expected_content return backups[0] def test_collect_postgres_config_uses_fixed_bundled_port_and_compose_overrides() -> ( None ): """Bundled PostgreSQL should use the fixed service port and compose overrides.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state confirm_default_yes() {{ return 0; }} prompt_with_default() {{ case "$1" in "PostgreSQL host") printf 'localhost' ;; "PostgreSQL user") printf 'lightrag' ;; "PostgreSQL database") printf 'lightrag' ;; *) printf '%s' "$2" ;; esac }} mask_sensitive_input() {{ printf 'supersecret'; }} collect_postgres_config yes printf 'POSTGRES_HOST=%s\\n' "${{ENV_VALUES[POSTGRES_HOST]}}" printf 'POSTGRES_PORT=%s\\n' "${{ENV_VALUES[POSTGRES_PORT]}}" printf 'COMPOSE_POSTGRES_HOST=%s\\n' "${{COMPOSE_ENV_OVERRIDES[POSTGRES_HOST]}}" printf 'COMPOSE_POSTGRES_PORT=%s\\n' "${{COMPOSE_ENV_OVERRIDES[POSTGRES_PORT]}}" printf 'DOCKER_SERVICE=%s\\n' "${{DOCKER_SERVICES[0]}}" """ ) assert values["POSTGRES_HOST"] == "localhost" assert values["POSTGRES_PORT"] == "5432" assert values["COMPOSE_POSTGRES_HOST"] == "postgres" assert values["COMPOSE_POSTGRES_PORT"] == "5432" assert values["DOCKER_SERVICE"] == "postgres" def test_collect_postgres_config_uses_rag_defaults_without_prompt_for_empty_docker_credentials() -> ( None ): """Docker PostgreSQL should auto-fill bundled credentials when old `.env` creds are empty.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state PROMPT_LOG_FILE="$(mktemp)" : > "$PROMPT_LOG_FILE" confirm_default_yes() {{ return 0; }} prompt_with_default() {{ printf '%s\\n' "$1" >> "$PROMPT_LOG_FILE" case "$1" in "PostgreSQL host") printf 'localhost' ;; *) printf '%s' "$2" ;; esac }} prompt_secret_with_default() {{ printf 'secret:%s\\n' "$1" >> "$PROMPT_LOG_FILE" printf '%s' "$2" }} ORIGINAL_ENV_VALUES[POSTGRES_USER]="" ORIGINAL_ENV_VALUES[POSTGRES_PASSWORD]="" ORIGINAL_ENV_VALUES[POSTGRES_DATABASE]="" collect_postgres_config yes printf 'POSTGRES_USER=%s\\n' "${{ENV_VALUES[POSTGRES_USER]}}" printf 'POSTGRES_PASSWORD=%s\\n' "${{ENV_VALUES[POSTGRES_PASSWORD]}}" printf 'POSTGRES_DATABASE=%s\\n' "${{ENV_VALUES[POSTGRES_DATABASE]}}" printf 'PROMPT_LOG=%s\\n' "$(paste -sd '|' "$PROMPT_LOG_FILE")" """ ) assert values["POSTGRES_USER"] == "rag" assert values["POSTGRES_PASSWORD"] == "rag" assert values["POSTGRES_DATABASE"] == "rag" assert values["PROMPT_LOG"] == "PostgreSQL host" def test_collect_postgres_config_prompts_for_existing_docker_credentials() -> None: """Docker PostgreSQL should preserve editability when old `.env` creds already exist.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state PROMPT_LOG_FILE="$(mktemp)" : > "$PROMPT_LOG_FILE" confirm_default_yes() {{ return 0; }} prompt_with_default() {{ printf '%s[%s]\\n' "$1" "$2" >> "$PROMPT_LOG_FILE" case "$1" in "PostgreSQL host") printf 'localhost' ;; "PostgreSQL user") printf 'updated-user' ;; "PostgreSQL database") printf 'updated-db' ;; *) printf '%s' "$2" ;; esac }} prompt_secret_with_default() {{ printf '%s[%s]\\n' "$1" "$2" >> "$PROMPT_LOG_FILE" printf 'updated-password' }} ORIGINAL_ENV_VALUES[POSTGRES_USER]="existing-user" ORIGINAL_ENV_VALUES[POSTGRES_PASSWORD]="existing-password" ORIGINAL_ENV_VALUES[POSTGRES_DATABASE]="existing-db" collect_postgres_config yes printf 'POSTGRES_USER=%s\\n' "${{ENV_VALUES[POSTGRES_USER]}}" printf 'POSTGRES_PASSWORD=%s\\n' "${{ENV_VALUES[POSTGRES_PASSWORD]}}" printf 'POSTGRES_DATABASE=%s\\n' "${{ENV_VALUES[POSTGRES_DATABASE]}}" printf 'PROMPT_LOG=%s\\n' "$(paste -sd '|' "$PROMPT_LOG_FILE")" """ ) assert values["POSTGRES_USER"] == "updated-user" assert values["POSTGRES_PASSWORD"] == "updated-password" assert values["POSTGRES_DATABASE"] == "updated-db" assert ( values["PROMPT_LOG"] == "PostgreSQL host[localhost]|" "PostgreSQL user[existing-user]|PostgreSQL password: [existing-password]|" "PostgreSQL database[existing-db]" ) def test_collect_postgres_config_still_prompts_for_host_credentials() -> None: """Host PostgreSQL should keep prompting even when saved creds are empty.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state PROMPT_LOG_FILE="$(mktemp)" : > "$PROMPT_LOG_FILE" confirm_default_no() {{ return 1; }} prompt_with_default() {{ printf '%s[%s]\\n' "$1" "$2" >> "$PROMPT_LOG_FILE" case "$1" in "PostgreSQL host") printf 'db.internal' ;; "PostgreSQL user") printf 'host-user' ;; "PostgreSQL database") printf 'host-db' ;; *) printf '%s' "$2" ;; esac }} prompt_until_valid() {{ printf '%s[%s]\\n' "$1" "$2" >> "$PROMPT_LOG_FILE" if [[ "$1" == "PostgreSQL port" ]]; then printf '6543' else printf '%s' "$2" fi }} prompt_secret_with_default() {{ printf '%s[%s]\\n' "$1" "$2" >> "$PROMPT_LOG_FILE" printf 'host-password' }} ORIGINAL_ENV_VALUES[POSTGRES_USER]="" ORIGINAL_ENV_VALUES[POSTGRES_PASSWORD]="" collect_postgres_config no printf 'POSTGRES_HOST=%s\\n' "${{ENV_VALUES[POSTGRES_HOST]}}" printf 'POSTGRES_PORT=%s\\n' "${{ENV_VALUES[POSTGRES_PORT]}}" printf 'POSTGRES_USER=%s\\n' "${{ENV_VALUES[POSTGRES_USER]}}" printf 'POSTGRES_PASSWORD=%s\\n' "${{ENV_VALUES[POSTGRES_PASSWORD]}}" printf 'PROMPT_LOG=%s\\n' "$(paste -sd '|' "$PROMPT_LOG_FILE")" """ ) assert values["POSTGRES_HOST"] == "db.internal" assert values["POSTGRES_PORT"] == "6543" assert values["POSTGRES_USER"] == "host-user" assert values["POSTGRES_PASSWORD"] == "host-password" assert ( values["PROMPT_LOG"] == "PostgreSQL host[localhost]|PostgreSQL port[5432]|" "PostgreSQL user[rag]|PostgreSQL password: [rag]|" "PostgreSQL database[lightrag]" ) def test_collect_server_config_includes_summary_language_last() -> None: """Server config should prompt for summary language after the WebUI fields.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state PROMPT_LOG_FILE="$(mktemp)" : > "$PROMPT_LOG_FILE" prompt_with_default() {{ printf '%s\\n' "$1" >> "$PROMPT_LOG_FILE" case "$1" in "Server host") printf '127.0.0.1' ;; "WebUI title") printf 'Custom KB' ;; "WebUI description") printf 'Custom description' ;; "Summary language") printf 'Chinese' ;; *) printf '%s' "$2" ;; esac }} prompt_until_valid() {{ printf '%s\\n' "$1" >> "$PROMPT_LOG_FILE" if [[ "$1" == "Server port" ]]; then printf '9630' else printf '%s' "$2" fi }} collect_server_config printf 'HOST=%s\\n' "${{ENV_VALUES[HOST]}}" printf 'PORT=%s\\n' "${{ENV_VALUES[PORT]}}" printf 'WEBUI_TITLE=%s\\n' "${{ENV_VALUES[WEBUI_TITLE]}}" printf 'WEBUI_DESCRIPTION=%s\\n' "${{ENV_VALUES[WEBUI_DESCRIPTION]}}" printf 'SUMMARY_LANGUAGE=%s\\n' "${{ENV_VALUES[SUMMARY_LANGUAGE]}}" printf 'PROMPT_LOG=%s\\n' "$(paste -sd '|' "$PROMPT_LOG_FILE")" """ ) assert values["HOST"] == "127.0.0.1" assert values["PORT"] == "9630" assert values["WEBUI_TITLE"] == "Custom KB" assert values["WEBUI_DESCRIPTION"] == "Custom description" assert values["SUMMARY_LANGUAGE"] == "Chinese" assert ( values["PROMPT_LOG"] == "Server host|Server port|WebUI title|WebUI description|Summary language" ) @pytest.mark.parametrize( ("setup_lines", "collector_call", "env_key", "expected_value"), [ ( [ 'ENV_VALUES[POSTGRES_HOST]="db.example.com"', 'ENV_VALUES[POSTGRES_PORT]="6543"', ], "collect_postgres_config yes", "POSTGRES_HOST", "localhost", ), ( [ 'ENV_VALUES[POSTGRES_HOST]="db.example.com"', 'ENV_VALUES[POSTGRES_PORT]="6543"', ], "collect_postgres_config yes", "POSTGRES_PORT", "5432", ), ( ['ENV_VALUES[NEO4J_URI]="neo4j+s://graph.example.com"'], "collect_neo4j_config yes", "NEO4J_URI", "neo4j://localhost:7687", ), ( ['ENV_VALUES[MONGO_URI]="mongodb://mongo.example.com:27018/"'], "collect_mongodb_config yes", "MONGO_URI", "mongodb://localhost:27017/", ), ( ['ENV_VALUES[REDIS_URI]="redis://cache.example.com:6380/1"'], "collect_redis_config yes", "REDIS_URI", "redis://localhost:6379/", ), ( ['ENV_VALUES[MILVUS_URI]="http://milvus.example.com:19530"'], "collect_milvus_config yes", "MILVUS_URI", "http://localhost:19530", ), ( ['ENV_VALUES[QDRANT_URL]="http://qdrant.example.com:6333"'], "collect_qdrant_config yes", "QDRANT_URL", "http://localhost:6333", ), ( ['ENV_VALUES[MEMGRAPH_URI]="bolt://memgraph.example.com:7687"'], "collect_memgraph_config yes", "MEMGRAPH_URI", "bolt://localhost:7687", ), ( ['ENV_VALUES[NEO4J_URI]="neo4j://localhost:7777"'], "collect_neo4j_config yes", "NEO4J_URI", "neo4j://localhost:7687", ), ( ['ENV_VALUES[MILVUS_URI]="http://localhost:29530"'], "collect_milvus_config yes", "MILVUS_URI", "http://localhost:19530", ), ( ['ENV_VALUES[QDRANT_URL]="http://localhost:16333"'], "collect_qdrant_config yes", "QDRANT_URL", "http://localhost:6333", ), ( ['ENV_VALUES[MEMGRAPH_URI]="bolt://localhost:17687"'], "collect_memgraph_config yes", "MEMGRAPH_URI", "bolt://localhost:7687", ), ], ids=[ "postgres-remote-host", "postgres-port-reset-to-bundled-default", "neo4j-remote-uri", "mongodb-remote-uri", "redis-remote-uri", "milvus-remote-uri", "qdrant-remote-uri", "memgraph-remote-uri", "neo4j-local-port", "milvus-local-port", "qdrant-local-port", "memgraph-local-port", ], ) def test_collect_local_service_configs_normalize_stale_values( setup_lines: list[str], collector_call: str, env_key: str, expected_value: str, ) -> None: """Bundled services should normalize stale remote or localhost endpoints on rerun.""" setup_block = "\n".join(setup_lines) values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state {setup_block} confirm_default_yes() {{ return 0; }} prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ case "$1" in "PostgreSQL user") printf 'lightrag' ;; "PostgreSQL database") printf 'lightrag' ;; "Neo4j database") printf 'neo4j' ;; "MongoDB database") printf 'LightRAG' ;; "Milvus database name") printf 'lightrag' ;; *) printf '%s' "$2" ;; esac }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} {collector_call} printf '{env_key}=%s\\n' "${{ENV_VALUES[{env_key}]}}" """ ) assert values[env_key] == expected_value def test_prepare_compose_runtime_overrides_keeps_env_unchanged() -> None: """Loopback endpoints should be rewritten only for compose overrides.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state 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" prepare_compose_runtime_overrides printf 'ENV_LLM=%s\\n' "${{ENV_VALUES[LLM_BINDING_HOST]}}" printf 'ENV_EMBEDDING=%s\\n' "${{ENV_VALUES[EMBEDDING_BINDING_HOST]}}" printf 'ENV_RERANK=%s\\n' "${{ENV_VALUES[RERANK_BINDING_HOST]}}" printf 'COMPOSE_LLM=%s\\n' "${{COMPOSE_ENV_OVERRIDES[LLM_BINDING_HOST]}}" printf 'COMPOSE_EMBEDDING=%s\\n' "${{COMPOSE_ENV_OVERRIDES[EMBEDDING_BINDING_HOST]}}" printf 'COMPOSE_RERANK=%s\\n' "${{COMPOSE_ENV_OVERRIDES[RERANK_BINDING_HOST]}}" """ ) values = parse_lines(output) assert values["ENV_LLM"] == "http://localhost:11434" assert values["ENV_EMBEDDING"] == "http://127.0.0.1:11434" assert values["ENV_RERANK"] == "http://localhost:8000/rerank" assert values["COMPOSE_LLM"] == "http://host.docker.internal:11434" assert values["COMPOSE_EMBEDDING"] == "http://host.docker.internal:11434" assert values["COMPOSE_RERANK"] == "http://host.docker.internal:8000/rerank" 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}:\n condition: service_healthy" in lightrag_block ) assert generated_compose.count(" healthcheck:") == 11 assert " milvus-etcd:" in generated_compose assert " milvus-minio:" in generated_compose assert " milvus-etcd:\n condition: service_healthy" in generated_compose assert ( " milvus-minio:\n 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:\n condition: service_started" in generated_compose assert " postgres:\n condition: service_healthy" in generated_compose assert " redis:\n condition: service_healthy" in generated_compose assert ( " vllm-embed:\n 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:\n condition: service_healthy" in lightrag_block assert " vllm-rerank:\n 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\n\n deploy:\n" not in generated_compose assert " image: example/lightrag:test\n deploy:\n" in generated_compose assert " max_attempts: 10\n\n sidecar:\n" in generated_compose def test_existing_ssl_env_keeps_compose_mount_overrides(tmp_path: Path) -> None: """Compose regeneration should preserve working SSL mounts without implying `.env` is permanently dual-purpose.""" 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", ) 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") env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "SSL=true", f"SSL_CERTFILE={cert_path}", f"SSL_KEYFILE={key_path}", ] ) + "\n", encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present prepare_compose_env_overrides stage_ssl_assets "$SSL_CERT_SOURCE_PATH" "$SSL_KEY_SOURCE_PATH" generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml" """ ) generated_compose = (tmp_path / "docker-compose.generated.yml").read_text( encoding="utf-8" ) assert 'SSL_CERTFILE: "/app/data/certs/cert.pem"' in generated_compose assert 'SSL_KEYFILE: "/app/data/certs/key.pem"' 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 def test_finalize_base_setup_rewrites_ssl_env_to_preserved_compose_paths( tmp_path: Path, ) -> None: """Compose-target reruns should rewrite broken SSL source paths to preserved staged compose paths.""" staged_dir = tmp_path / "data" / "certs" staged_dir.mkdir(parents=True) (staged_dir / "server.pem").write_text("cert", encoding="utf-8") (staged_dir / "server.key").write_text("key", encoding="utf-8") write_text_lines( tmp_path / ".env", [ "SSL=true", "SSL_CERTFILE=/missing/original-cert.pem", "SSL_KEYFILE=/missing/original-key.pem", "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " volumes:", " - ./.env:/app/.env", " - ./data/certs/server.pem:/app/data/certs/server.pem:ro", " - ./data/certs/server.key:/app/data/certs/server.key:ro", " environment:", " SSL_CERTFILE: /app/data/certs/server.pem", " SSL_KEYFILE: /app/data/certs/server.key", ], ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present initialize_default_storage_backends show_summary() {{ :; }} confirm_default_yes() {{ case "$1" in "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 1 ;; *) return 0 ;; esac }} confirm_required_yes_no() {{ return 0; }} finalize_base_setup if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """ ) values = parse_lines(output) generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert "SSL_CERTFILE=/app/data/certs/server.pem" in generated_env assert "SSL_KEYFILE=/app/data/certs/server.key" in generated_env assert values["VALID"] == "yes" def test_removing_ssl_strips_wizard_bind_mounts_from_compose(tmp_path: Path) -> None: """Re-running setup without SSL must remove only wizard-managed SSL mounts.""" # A previously generated compose file that has SSL mounts. compose_file = tmp_path / "docker-compose.final.yml" compose_file.write_text( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " volumes:", ' - "./data/certs/cert.pem:/app/data/certs/cert.pem:ro"', ' - "./data/certs/key.pem:/app/data/certs/key.pem:ro"', ' - "./data/rag_storage:/app/data/rag_storage"', ' - "./data/inputs:/app/data/inputs"', ' - "./custom-data:/app/data/custom"', " environment:", ' SSL_CERTFILE: "/app/data/certs/cert.pem"', ' SSL_KEYFILE: "/app/data/certs/key.pem"', ] ) + "\n", encoding="utf-8", ) (tmp_path / "env.example").write_text( (REPO_ROOT / "env.example").read_text(encoding="utf-8"), encoding="utf-8", ) # Re-run without SSL: COMPOSE_ENV_OVERRIDES has no SSL_CERTFILE/SSL_KEYFILE. run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state generate_docker_compose "{tmp_path}/docker-compose.final.yml" """ ) result = compose_file.read_text(encoding="utf-8") # SSL bind mounts must be gone. assert "/app/data/certs/cert.pem" not in result assert "/app/data/certs/key.pem" not in result # Default persistent mounts and user-added non-wizard mounts must be preserved. assert "./data/rag_storage:/app/data/rag_storage" in result assert "./data/inputs:/app/data/inputs" in result assert "./custom-data:/app/data/custom" in result 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\n depends_on:\n" 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_find_generated_compose_file_prefers_final_compose_file( tmp_path: Path, ) -> None: """Compose discovery should prefer docker-compose.final.yml over legacy files.""" write_text_lines( tmp_path / ".env", [ "HOST=0.0.0.0", ], ) write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: final/lightrag", ], ) write_text_lines( tmp_path / "docker-compose.development.yml", [ "services:", " lightrag:", " image: dev/lightrag", ], ) write_text_lines( tmp_path / "docker-compose.production.yml", [ "services:", " lightrag:", " image: prod/lightrag", ], ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" printf 'COMPOSE=%s\\n' "$(find_generated_compose_file)" """ ) values = parse_lines(output) assert values["COMPOSE"] == str(tmp_path / "docker-compose.final.yml") def test_find_generated_compose_file_falls_back_to_order_without_profile( tmp_path: Path, ) -> None: """Without legacy profile metadata, compose migration should use the default order.""" write_text_lines(tmp_path / ".env", ["HOST=0.0.0.0"]) write_text_lines( tmp_path / "docker-compose.development.yml", [ "services:", " lightrag:", " image: dev/lightrag", ], ) write_text_lines( tmp_path / "docker-compose.production.yml", [ "services:", " lightrag:", " image: prod/lightrag", ], ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" printf 'COMPOSE=%s\\n' "$(find_generated_compose_file)" """ ) values = parse_lines(output) assert values["COMPOSE"] == str(tmp_path / "docker-compose.development.yml") def test_collect_ssl_config_can_disable_loaded_ssl_values(tmp_path: Path) -> None: """Declining SSL should clear previously loaded cert paths and staged sources.""" 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") env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "SSL=true", f"SSL_CERTFILE={cert_path}", f"SSL_KEYFILE={key_path}", ] ) + "\n", encoding="utf-8", ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present confirm_default_yes() {{ return 1; }} collect_ssl_config printf 'SSL_IS_SET=%s\\n' "${{ENV_VALUES[SSL]+set}}" printf 'SSL_CERTFILE_IS_SET=%s\\n' "${{ENV_VALUES[SSL_CERTFILE]+set}}" printf 'SSL_KEYFILE_IS_SET=%s\\n' "${{ENV_VALUES[SSL_KEYFILE]+set}}" printf 'SSL_CERT_SOURCE_PATH=%s\\n' "$SSL_CERT_SOURCE_PATH" printf 'SSL_KEY_SOURCE_PATH=%s\\n' "$SSL_KEY_SOURCE_PATH" """ ) values = parse_lines(output) assert values["SSL_IS_SET"] == "" assert values["SSL_CERTFILE_IS_SET"] == "" assert values["SSL_KEYFILE_IS_SET"] == "" assert values["SSL_CERT_SOURCE_PATH"] == "" assert values["SSL_KEY_SOURCE_PATH"] == "" def test_validate_env_file_rejects_missing_ssl_files(tmp_path: Path) -> None: """Validation should fail when SSL is enabled with missing cert/key paths.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "SSL=true", "SSL_CERTFILE=/missing/cert.pem", "SSL_KEYFILE=/missing/key.pem", "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", ] ) + "\n", encoding="utf-8", ) result = subprocess.run( [ "bash", "-lc", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state validate_env_file """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) assert result.returncode == 1 assert "Invalid SSL_CERTFILE" in result.stderr assert "Invalid SSL_KEYFILE" in result.stderr def test_validate_env_file_rejects_container_ssl_paths_for_host_target( tmp_path: Path, ) -> None: """host-target .env must not accept /app/data/certs/* even when the staged file exists.""" (tmp_path / "data" / "certs").mkdir(parents=True) (tmp_path / "data" / "certs" / "cert.pem").write_text("cert", encoding="utf-8") (tmp_path / "data" / "certs" / "key.pem").write_text("key", encoding="utf-8") env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "SSL=true", "SSL_CERTFILE=/app/data/certs/cert.pem", "SSL_KEYFILE=/app/data/certs/key.pem", "LIGHTRAG_RUNTIME_TARGET=host", "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", ] ) + "\n", encoding="utf-8", ) result = subprocess.run( [ "bash", "-lc", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state validate_env_file """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) assert result.returncode == 1 assert "Invalid SSL_CERTFILE" in result.stderr assert "Invalid SSL_KEYFILE" in result.stderr def test_validate_env_file_rejects_container_ssl_paths_for_default_host_target( tmp_path: Path, ) -> None: """Omitting LIGHTRAG_RUNTIME_TARGET defaults to host; container paths must still be rejected.""" (tmp_path / "data" / "certs").mkdir(parents=True) (tmp_path / "data" / "certs" / "cert.pem").write_text("cert", encoding="utf-8") (tmp_path / "data" / "certs" / "key.pem").write_text("key", encoding="utf-8") env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "SSL=true", "SSL_CERTFILE=/app/data/certs/cert.pem", "SSL_KEYFILE=/app/data/certs/key.pem", "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", ] ) + "\n", encoding="utf-8", ) result = subprocess.run( [ "bash", "-lc", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state validate_env_file """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) assert result.returncode == 1 assert "Invalid SSL_CERTFILE" in result.stderr assert "Invalid SSL_KEYFILE" in result.stderr def test_validate_env_file_accepts_container_ssl_paths_for_compose_target( tmp_path: Path, ) -> None: """compose-target .env may use /app/data/certs/* when the staged files exist.""" (tmp_path / "data" / "certs").mkdir(parents=True) (tmp_path / "data" / "certs" / "cert.pem").write_text("cert", encoding="utf-8") (tmp_path / "data" / "certs" / "key.pem").write_text("key", encoding="utf-8") env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "SSL=true", "SSL_CERTFILE=/app/data/certs/cert.pem", "SSL_KEYFILE=/app/data/certs/key.pem", "LIGHTRAG_RUNTIME_TARGET=compose", "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", ] ) + "\n", encoding="utf-8", ) result = subprocess.run( [ "bash", "-lc", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state validate_env_file """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) assert result.returncode == 0 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_validate_sensitive_env_literals_rejects_interpolation_syntax() -> None: """Sensitive values should reject `${...}` so default dotenv interpolation stays safe.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state ENV_VALUES[TOKEN_SECRET]='${{JWT_SECRET}}' ENV_VALUES[LIGHTRAG_API_KEY]='plain$token' ENV_VALUES[WEBUI_DESCRIPTION]='${{ALLOWED_MACRO}}' if validate_sensitive_env_literals; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """ ) values = parse_lines(output) assert values["VALID"] == "no" @pytest.mark.parametrize( ("collector_name", "binding_prefix", "env_lines"), [ ( "collect_llm_config", "LLM", [ "LLM_BINDING=openai", "LLM_MODEL=gpt-4o", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=${OPENAI_API_KEY}", ], ), ( "collect_embedding_config", "EMBEDDING", [ "EMBEDDING_BINDING=openai", "EMBEDDING_MODEL=text-embedding-3-large", "EMBEDDING_DIM=3072", "EMBEDDING_BINDING_HOST=https://api.openai.com/v1", "EMBEDDING_BINDING_API_KEY=${OPENAI_API_KEY}", ], ), ], ids=["llm-bedrock-clears-api-key", "embedding-bedrock-clears-api-key"], ) def test_collect_provider_config_clears_stale_api_key_for_bedrock( tmp_path: Path, collector_name: str, binding_prefix: str, env_lines: list[str], ) -> None: """Switching a provider to Bedrock should remove stale API-key settings.""" write_text_lines(tmp_path / ".env", env_lines) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present prompt_choice() {{ printf 'aws_bedrock'; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_required_secret() {{ printf 'dummy-secret'; }} mask_sensitive_input() {{ printf ''; }} confirm_default_yes() {{ return 0; }} {collector_name} printf 'BINDING=%s\\n' "${{ENV_VALUES[{binding_prefix}_BINDING]}}" printf 'API_KEY_SET=%s\\n' "${{ENV_VALUES[{binding_prefix}_BINDING_API_KEY]+set}}" if validate_sensitive_env_literals; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """ ) values = parse_lines(output) assert values["BINDING"] == "aws_bedrock" assert values["API_KEY_SET"] == "" assert values["VALID"] == "yes" @pytest.mark.parametrize( ( "collector_name", "binding_prefix", "provider_choice", "secret_stub", "expected_binding", "expected_model", "expected_host", "expected_dim", "expected_api_key_set", ), [ ( "collect_llm_config", "LLM", "ollama", "", "ollama", "mistral-nemo:latest", "http://localhost:11434", "", "", ), ( "collect_embedding_config", "EMBEDDING", "jina", "prompt_secret_until_valid_with_default() { printf 'jina-secret-key'; }", "jina", "jina-embeddings-v4", "https://api.jina.ai/v1/embeddings", "2048", "set", ), ], ids=["llm-provider-defaults", "embedding-provider-defaults"], ) def test_collect_provider_config_uses_provider_specific_defaults( collector_name: str, binding_prefix: str, provider_choice: str, secret_stub: str, expected_binding: str, expected_model: str, expected_host: str, expected_dim: str, expected_api_key_set: str, ) -> None: """Fresh provider selection should pick provider-specific defaults.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state prompt_choice() {{ printf '{provider_choice}'; }} prompt_with_default() {{ printf '%s' "$2"; }} {secret_stub} {collector_name} printf 'BINDING=%s\\n' "${{ENV_VALUES[{binding_prefix}_BINDING]}}" printf 'MODEL=%s\\n' "${{ENV_VALUES[{binding_prefix}_MODEL]}}" printf 'HOST=%s\\n' "${{ENV_VALUES[{binding_prefix}_BINDING_HOST]}}" printf 'DIM=%s\\n' "${{ENV_VALUES[{binding_prefix}_DIM]:-}}" printf 'API_KEY_SET=%s\\n' "${{ENV_VALUES[{binding_prefix}_BINDING_API_KEY]+set}}" """ ) values = parse_lines(output) assert values["BINDING"] == expected_binding assert values["MODEL"] == expected_model assert values["HOST"] == expected_host assert values["DIM"] == expected_dim assert values["API_KEY_SET"] == expected_api_key_set @pytest.mark.parametrize( ( "collector_name", "binding_prefix", "env_lines", "prompt_stubs", "expected_binding", "expected_model", "expected_host", "expected_dim", "expected_api_key", ), [ ( "collect_llm_config", "LLM", [ "LLM_BINDING=openai-ollama", "LLM_MODEL=llama3.1:8b", "LLM_BINDING_HOST=http://localhost:11434/v1", "LLM_BINDING_API_KEY=sk-local-test-key", ], """ prompt_with_default() { printf '%s' "$2"; } prompt_secret_until_valid_with_default() { printf '%s' "$2"; } """, "openai-ollama", "llama3.1:8b", "http://localhost:11434/v1", "", "sk-local-test-key", ), ( "collect_embedding_config", "EMBEDDING", [ "EMBEDDING_BINDING=lollms", "EMBEDDING_MODEL=lollms_embedding_model", "EMBEDDING_DIM=1024", "EMBEDDING_BINDING_HOST=http://localhost:9600", ], """prompt_with_default() { printf '%s' "$2"; }""", "lollms", "lollms_embedding_model", "http://localhost:9600", "1024", "", ), ], ids=["llm-rerun-preserves-openai-ollama", "embedding-rerun-preserves-lollms"], ) def test_collect_provider_config_preserves_supported_binding_on_rerun( tmp_path: Path, collector_name: str, binding_prefix: str, env_lines: list[str], prompt_stubs: str, expected_binding: str, expected_model: str, expected_host: str, expected_dim: str, expected_api_key: str, ) -> None: """Reruns should preserve supported provider bindings and their saved settings.""" write_text_lines(tmp_path / ".env", env_lines) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present {prompt_stubs} {collector_name} printf 'BINDING=%s\\n' "${{ENV_VALUES[{binding_prefix}_BINDING]}}" printf 'MODEL=%s\\n' "${{ENV_VALUES[{binding_prefix}_MODEL]}}" printf 'HOST=%s\\n' "${{ENV_VALUES[{binding_prefix}_BINDING_HOST]}}" printf 'DIM=%s\\n' "${{ENV_VALUES[{binding_prefix}_DIM]:-}}" printf 'API_KEY=%s\\n' "${{ENV_VALUES[{binding_prefix}_BINDING_API_KEY]:-}}" """ ) values = parse_lines(output) assert values["BINDING"] == expected_binding assert values["MODEL"] == expected_model assert values["HOST"] == expected_host assert values["DIM"] == expected_dim assert values["API_KEY"] == expected_api_key def test_collect_embedding_config_forces_ollama_for_openai_ollama_llm( tmp_path: Path, ) -> None: """`openai-ollama` should not preserve a conflicting embedding provider.""" write_text_lines( tmp_path / ".env", [ "LLM_BINDING=openai-ollama", "EMBEDDING_BINDING=openai", "EMBEDDING_MODEL=text-embedding-3-large", "EMBEDDING_DIM=3072", "EMBEDDING_BINDING_HOST=https://api.openai.com/v1", "EMBEDDING_BINDING_API_KEY=local-key", ], ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} collect_embedding_config printf 'EMBEDDING_BINDING=%s\\n' "${{ENV_VALUES[EMBEDDING_BINDING]}}" printf 'EMBEDDING_MODEL=%s\\n' "${{ENV_VALUES[EMBEDDING_MODEL]}}" printf 'EMBEDDING_DIM=%s\\n' "${{ENV_VALUES[EMBEDDING_DIM]}}" printf 'EMBEDDING_BINDING_HOST=%s\\n' "${{ENV_VALUES[EMBEDDING_BINDING_HOST]}}" printf 'EMBEDDING_BINDING_API_KEY_SET=%s\\n' "${{ENV_VALUES[EMBEDDING_BINDING_API_KEY]+set}}" """ ) values = parse_lines(output) assert values["EMBEDDING_BINDING"] == "ollama" assert values["EMBEDDING_MODEL"] == "bge-m3:latest" assert values["EMBEDDING_DIM"] == "1024" assert values["EMBEDDING_BINDING_HOST"] == "http://localhost:11434" assert values["EMBEDDING_BINDING_API_KEY_SET"] == "" def test_collect_llm_config_allows_bedrock_ambient_credential_chain() -> None: """Bedrock setup should allow IAM roles, AWS profiles, or SSO without saved keys.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state prompt_choice() {{ printf 'aws_bedrock'; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_clearable_with_default() {{ printf ''; }} prompt_required_secret() {{ return 1; }} confirm_default_yes() {{ return 1; }} collect_llm_config printf 'LLM_BINDING=%s\\n' "${{ENV_VALUES[LLM_BINDING]}}" printf 'AWS_ACCESS_KEY_ID_SET=%s\\n' "${{ENV_VALUES[AWS_ACCESS_KEY_ID]+set}}" printf 'AWS_SECRET_ACCESS_KEY_SET=%s\\n' "${{ENV_VALUES[AWS_SECRET_ACCESS_KEY]+set}}" printf 'AWS_SESSION_TOKEN_SET=%s\\n' "${{ENV_VALUES[AWS_SESSION_TOKEN]+set}}" printf 'AWS_REGION_SET=%s\\n' "${{ENV_VALUES[AWS_REGION]+set}}" """ ) values = parse_lines(output) assert values["LLM_BINDING"] == "aws_bedrock" assert values["AWS_ACCESS_KEY_ID_SET"] == "" assert values["AWS_SECRET_ACCESS_KEY_SET"] == "" assert values["AWS_SESSION_TOKEN_SET"] == "" assert values["AWS_REGION_SET"] == "" def test_switching_both_providers_off_bedrock_clears_saved_aws_credentials( tmp_path: Path, ) -> None: """Reruns should not keep stale AWS Bedrock secrets in regenerated `.env` files.""" write_text_lines( tmp_path / ".env", [ "LLM_BINDING=aws_bedrock", "LLM_MODEL=anthropic.claude-3-5-sonnet-20241022-v2:0", "LLM_BINDING_HOST=https://bedrock.amazonaws.com", "EMBEDDING_BINDING=aws_bedrock", "EMBEDDING_MODEL=amazon.titan-embed-text-v2:0", "EMBEDDING_DIM=1024", "EMBEDDING_BINDING_HOST=https://bedrock.amazonaws.com", "AWS_ACCESS_KEY_ID=AKIAOLDKEY", "AWS_SECRET_ACCESS_KEY=oldsecretvalue", "AWS_SESSION_TOKEN=oldsess", "AWS_REGION=us-east-1", ], ) write_text_lines( tmp_path / "env.example", [ "# AWS_ACCESS_KEY_ID=your_aws_access_key_id", "# AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key", "# AWS_SESSION_TOKEN=your_optional_aws_session_token", "# AWS_REGION=us-east-1", "LLM_BINDING=openai", "LLM_MODEL=gpt-4o", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=your_api_key", "EMBEDDING_BINDING=openai", "EMBEDDING_MODEL=text-embedding-3-large", "EMBEDDING_DIM=3072", "EMBEDDING_BINDING_HOST=https://api.openai.com/v1", "EMBEDDING_BINDING_API_KEY=your_api_key", ], ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present prompt_choice() {{ printf 'openai'; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf 'fresh-key'; }} collect_llm_config collect_embedding_config generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env.generated" printf 'AWS_ACCESS_KEY_ID_SET=%s\\n' "${{ENV_VALUES[AWS_ACCESS_KEY_ID]+set}}" printf 'AWS_SECRET_ACCESS_KEY_SET=%s\\n' "${{ENV_VALUES[AWS_SECRET_ACCESS_KEY]+set}}" printf 'AWS_SESSION_TOKEN_SET=%s\\n' "${{ENV_VALUES[AWS_SESSION_TOKEN]+set}}" printf 'AWS_REGION_SET=%s\\n' "${{ENV_VALUES[AWS_REGION]+set}}" """ ) values = parse_lines(output) generated_lines = ( (tmp_path / ".env.generated").read_text(encoding="utf-8").splitlines() ) assert values["AWS_ACCESS_KEY_ID_SET"] == "" assert values["AWS_SECRET_ACCESS_KEY_SET"] == "" assert values["AWS_SESSION_TOKEN_SET"] == "" assert values["AWS_REGION_SET"] == "" assert not any(line.startswith("AWS_ACCESS_KEY_ID=") for line in generated_lines) assert not any( line.startswith("AWS_SECRET_ACCESS_KEY=") for line in generated_lines ) assert not any(line.startswith("AWS_SESSION_TOKEN=") for line in generated_lines) assert not any(line.startswith("AWS_REGION=") for line in generated_lines) def test_collect_rerank_config_preserves_api_key_when_disabled( tmp_path: Path, ) -> None: """Disabling reranking should preserve credentials so they survive re-enable.""" write_text_lines( tmp_path / ".env", [ "RERANK_BINDING=cohere", "RERANK_MODEL=rerank-v3.5", "RERANK_BINDING_HOST=https://api.cohere.com/v1/rerank", "RERANK_BINDING_API_KEY=test-api-key-literal", ], ) values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present confirm_default_yes() {{ return 1; }} collect_rerank_config printf 'RERANK_BINDING=%s\\n' "${{ENV_VALUES[RERANK_BINDING]}}" printf 'RERANK_BINDING_API_KEY_SET=%s\\n' "${{ENV_VALUES[RERANK_BINDING_API_KEY]+set}}" if validate_sensitive_env_literals; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """ ) assert values["RERANK_BINDING"] == "null" assert values["RERANK_BINDING_API_KEY_SET"] == "set" assert values["VALID"] == "yes" def test_load_existing_env_forces_cohere_binding_for_vllm_rerank( tmp_path: Path, ) -> None: """Loading a Docker-managed vLLM rerank config should normalize the binding to cohere.""" write_text_lines( tmp_path / ".env", [ "RERANK_BINDING=jina", "LIGHTRAG_SETUP_RERANK_PROVIDER=vllm", "RERANK_BINDING_HOST=http://localhost:8000/rerank", ], ) values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present printf 'RERANK_BINDING=%s\\n' "${{ENV_VALUES[RERANK_BINDING]}}" printf 'LIGHTRAG_SETUP_RERANK_PROVIDER=%s\\n' "${{ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]}}" """ ) assert values["RERANK_BINDING"] == "cohere" assert values["LIGHTRAG_SETUP_RERANK_PROVIDER"] == "vllm" def test_collect_rerank_config_does_not_offer_vllm_provider_option() -> None: """The generic rerank provider prompt should only expose valid RERANK_BINDING values.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state ENV_VALUES[RERANK_BINDING]="cohere" confirm_default_no() {{ return 0; }} prompt_choice() {{ case "$1" in "Rerank provider") shift 2 for option in "$@"; do if [[ "$option" == "vllm" ]]; then echo "unexpected vllm option" >&2 return 91 fi done printf 'cohere' ;; *) printf '%s' "$2" ;; esac }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf 'cohere-secret-123'; }} collect_rerank_config printf 'RERANK_BINDING=%s\\n' "${{ENV_VALUES[RERANK_BINDING]}}" """ ) values = parse_lines(output) assert values["RERANK_BINDING"] == "cohere" def test_collect_rerank_config_switching_from_vllm_clears_local_defaults() -> None: """Switching from local vLLM to hosted rerank should replace stale vLLM values with provider defaults.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]="vllm" ENV_VALUES[RERANK_BINDING]="cohere" ENV_VALUES[RERANK_MODEL]="BAAI/bge-reranker-v2-m3" ENV_VALUES[RERANK_BINDING_HOST]="http://localhost:8000/rerank" confirm_default_no() {{ return 0; }} prompt_choice() {{ case "$1" in "Rerank provider") printf 'cohere' ;; *) printf '%s' "$2" ;; esac }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf 'cohere-secret-123'; }} collect_rerank_config printf 'RERANK_BINDING=%s\\n' "${{ENV_VALUES[RERANK_BINDING]}}" printf 'LIGHTRAG_SETUP_RERANK_PROVIDER=%s\\n' "${{ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]:-}}" printf 'RERANK_MODEL=%s\\n' "${{ENV_VALUES[RERANK_MODEL]:-}}" printf 'RERANK_BINDING_HOST=%s\\n' "${{ENV_VALUES[RERANK_BINDING_HOST]:-}}" """ ) values = parse_lines(output) assert values["RERANK_BINDING"] == "cohere" assert values["LIGHTRAG_SETUP_RERANK_PROVIDER"] == "" # Stale vLLM model should be replaced by the cohere provider default assert values["RERANK_MODEL"] != "BAAI/bge-reranker-v2-m3" assert values["RERANK_MODEL"] == "rerank-v3.5" # Stale vLLM localhost endpoint should be replaced by the cohere provider default assert "localhost:8000" not in values["RERANK_BINDING_HOST"] assert "cohere" in values["RERANK_BINDING_HOST"] def test_collect_rerank_config_ignores_vllm_marker_when_docker_is_predeclined() -> None: """A predeclined Docker path should default the provider prompt to the binding, not the setup marker.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]="vllm" ENV_VALUES[RERANK_BINDING]="cohere" prompt_choice() {{ case "$1" in "Rerank provider") if [[ "$2" != "cohere" ]]; then echo "unexpected rerank provider default: $2" >&2 return 91 fi printf 'cohere' ;; *) printf '%s' "$2" ;; esac }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf 'cohere-secret-123'; }} collect_rerank_config "yes" "no" printf 'RERANK_BINDING=%s\\n' "${{ENV_VALUES[RERANK_BINDING]}}" """ ) values = parse_lines(output) assert values["RERANK_BINDING"] == "cohere" 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_env_base_flow_preserves_non_inference_env_values( tmp_path: Path, ) -> None: """env-base wizard should leave server, security, and observability values untouched.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "HOST=127.0.0.1", "PORT=9999", "WEBUI_TITLE=Existing Title", "WEBUI_DESCRIPTION=Existing Description", "SSL=true", "SSL_CERTFILE=/some/cert.pem", "SSL_KEYFILE=/some/key.pem", "AUTH_ACCOUNTS=admin:secret", "TOKEN_SECRET=jwt-secret", "LIGHTRAG_API_KEY=api-key", "LANGFUSE_ENABLE_TRACE=true", "LANGFUSE_SECRET_KEY=langfuse-secret", "LLM_BINDING_API_KEY=sk-existing", "EMBEDDING_BINDING_API_KEY=sk-existing", ] ) + "\n", encoding="utf-8", ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ return 1; }} confirm_default_yes() {{ return 1; }} finalize_base_setup() {{ printf 'HOST=%s\\n' "${{ENV_VALUES[HOST]}}" printf 'PORT=%s\\n' "${{ENV_VALUES[PORT]}}" printf 'WEBUI_TITLE=%s\\n' "${{ENV_VALUES[WEBUI_TITLE]}}" printf 'WEBUI_DESCRIPTION=%s\\n' "${{ENV_VALUES[WEBUI_DESCRIPTION]}}" printf 'LLM_BINDING=%s\\n' "${{ENV_VALUES[LLM_BINDING]}}" printf 'LLM_BINDING_API_KEY=%s\\n' "${{ENV_VALUES[LLM_BINDING_API_KEY]}}" printf 'EMBEDDING_BINDING_API_KEY=%s\\n' "${{ENV_VALUES[EMBEDDING_BINDING_API_KEY]}}" printf 'SSL_SET=%s\\n' "${{ENV_VALUES[SSL]+set}}" printf 'AUTH_ACCOUNTS_SET=%s\\n' "${{ENV_VALUES[AUTH_ACCOUNTS]+set}}" printf 'TOKEN_SECRET_SET=%s\\n' "${{ENV_VALUES[TOKEN_SECRET]+set}}" printf 'LIGHTRAG_API_KEY_SET=%s\\n' "${{ENV_VALUES[LIGHTRAG_API_KEY]+set}}" printf 'LANGFUSE_ENABLE_TRACE_SET=%s\\n' "${{ENV_VALUES[LANGFUSE_ENABLE_TRACE]+set}}" printf 'LANGFUSE_SECRET_KEY_SET=%s\\n' "${{ENV_VALUES[LANGFUSE_SECRET_KEY]+set}}" }} env_base_flow """ ) values = parse_lines(output) assert values["HOST"] == "127.0.0.1" assert values["PORT"] == "9999" assert values["WEBUI_TITLE"] == "Existing Title" assert values["WEBUI_DESCRIPTION"] == "Existing Description" assert values["LLM_BINDING"] == "openai" assert values["LLM_BINDING_API_KEY"] == "sk-existing" assert values["EMBEDDING_BINDING_API_KEY"] == "sk-existing" # env-base does not touch server / security / observability values assert values["SSL_SET"] == "set" assert values["AUTH_ACCOUNTS_SET"] == "set" assert values["TOKEN_SECRET_SET"] == "set" assert values["LIGHTRAG_API_KEY_SET"] == "set" assert values["LANGFUSE_ENABLE_TRACE_SET"] == "set" assert values["LANGFUSE_SECRET_KEY_SET"] == "set" def test_env_base_flow_preserves_existing_provider_bindings_on_rerun( tmp_path: Path, ) -> None: """Rerunning env-base should keep prior LLM and embedding provider settings.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "LLM_BINDING=ollama", "LLM_MODEL=llama3.2:latest", "LLM_BINDING_HOST=http://localhost:11434", "EMBEDDING_BINDING=ollama", "EMBEDDING_MODEL=nomic-embed-text:latest", "EMBEDDING_DIM=768", "EMBEDDING_BINDING_HOST=http://localhost:11434", ] ) + "\n", encoding="utf-8", ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ return 1; }} confirm_default_yes() {{ return 1; }} finalize_base_setup() {{ printf 'LLM_BINDING=%s\\n' "${{ENV_VALUES[LLM_BINDING]}}" printf 'LLM_MODEL=%s\\n' "${{ENV_VALUES[LLM_MODEL]}}" printf 'LLM_BINDING_HOST=%s\\n' "${{ENV_VALUES[LLM_BINDING_HOST]}}" printf 'EMBEDDING_BINDING=%s\\n' "${{ENV_VALUES[EMBEDDING_BINDING]}}" printf 'EMBEDDING_MODEL=%s\\n' "${{ENV_VALUES[EMBEDDING_MODEL]}}" printf 'EMBEDDING_DIM=%s\\n' "${{ENV_VALUES[EMBEDDING_DIM]}}" printf 'EMBEDDING_BINDING_HOST=%s\\n' "${{ENV_VALUES[EMBEDDING_BINDING_HOST]}}" }} env_base_flow """ ) values = parse_lines(output) assert values["LLM_BINDING"] == "ollama" assert values["LLM_MODEL"] == "llama3.2:latest" assert values["LLM_BINDING_HOST"] == "http://localhost:11434" assert values["EMBEDDING_BINDING"] == "ollama" assert values["EMBEDDING_MODEL"] == "nomic-embed-text:latest" assert values["EMBEDDING_DIM"] == "768" assert values["EMBEDDING_BINDING_HOST"] == "http://localhost:11434" def test_env_base_flow_preserves_existing_vllm_embedding_settings_on_rerun( tmp_path: Path, ) -> None: """Rerunning env-base should keep saved local vLLM embedding settings.""" write_text_lines( tmp_path / ".env", [ "LLM_BINDING=openai", "LLM_MODEL=gpt-4o-mini", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=sk-existing", "EMBEDDING_BINDING=openai", "EMBEDDING_MODEL=BAAI/custom-embed", "EMBEDDING_DIM=768", "EMBEDDING_BINDING_HOST=http://localhost:9101/v1", "EMBEDDING_BINDING_API_KEY=embed-key", "LIGHTRAG_SETUP_EMBEDDING_PROVIDER=vllm", "VLLM_EMBED_MODEL=BAAI/custom-embed", "VLLM_EMBED_PORT=9101", "VLLM_EMBED_DEVICE=cpu", ], ) values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ return 1; }} confirm_default_yes() {{ case "$1" in "Run embedding model locally via Docker (vLLM)?") return 0 ;; *) return 1 ;; esac }} finalize_base_setup() {{ printf 'EMBEDDING_MODEL=%s\\n' "${{ENV_VALUES[EMBEDDING_MODEL]}}" printf 'EMBEDDING_DIM=%s\\n' "${{ENV_VALUES[EMBEDDING_DIM]}}" printf 'EMBEDDING_BINDING_HOST=%s\\n' "${{ENV_VALUES[EMBEDDING_BINDING_HOST]}}" printf 'VLLM_EMBED_MODEL=%s\\n' "${{ENV_VALUES[VLLM_EMBED_MODEL]}}" printf 'VLLM_EMBED_PORT=%s\\n' "${{ENV_VALUES[VLLM_EMBED_PORT]}}" }} env_base_flow """ ) assert values["EMBEDDING_MODEL"] == "BAAI/custom-embed" assert values["EMBEDDING_DIM"] == "768" assert values["EMBEDDING_BINDING_HOST"] == "http://localhost:9101/v1" assert values["VLLM_EMBED_MODEL"] == "BAAI/custom-embed" assert values["VLLM_EMBED_PORT"] == "9101" def test_env_base_flow_resets_remote_embedding_host_when_switching_to_vllm( tmp_path: Path, ) -> None: """Switching a remote embedding provider to local vLLM should restore localhost.""" write_text_lines( tmp_path / ".env", [ "LLM_BINDING=openai", "LLM_MODEL=gpt-4o-mini", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=sk-existing", "EMBEDDING_BINDING=jina", "EMBEDDING_MODEL=jina-embeddings-v4", "EMBEDDING_DIM=2048", "EMBEDDING_BINDING_HOST=https://api.jina.ai/v1/embeddings", "EMBEDDING_BINDING_API_KEY=jina-key", "VLLM_EMBED_PORT=9101", ], ) values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ case "$1" in "Run embedding model locally via Docker (vLLM)?") return 0 ;; "Enable reranking?") return 1 ;; *) return 1 ;; esac }} confirm_default_yes() {{ return 1; }} finalize_base_setup() {{ printf 'EMBEDDING_BINDING_HOST=%s\\n' "${{ENV_VALUES[EMBEDDING_BINDING_HOST]}}" printf 'LIGHTRAG_SETUP_EMBEDDING_PROVIDER=%s\\n' "${{ENV_VALUES[LIGHTRAG_SETUP_EMBEDDING_PROVIDER]}}" }} env_base_flow """ ) assert values["EMBEDDING_BINDING_HOST"] == "http://localhost:9101/v1" assert values["LIGHTRAG_SETUP_EMBEDDING_PROVIDER"] == "vllm" def test_env_base_flow_preserves_existing_vllm_embedding_device_on_gpu_host( tmp_path: Path, ) -> None: """Saved vLLM embedding CPU/GPU mode should win over auto-detected GPU defaults.""" write_text_lines( tmp_path / ".env", [ "LLM_BINDING=openai", "LLM_MODEL=gpt-4o-mini", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=sk-existing", "EMBEDDING_BINDING=openai", "EMBEDDING_MODEL=BAAI/custom-embed", "EMBEDDING_DIM=1024", "EMBEDDING_BINDING_HOST=http://localhost:9101/v1", "EMBEDDING_BINDING_API_KEY=embed-key", "LIGHTRAG_SETUP_EMBEDDING_PROVIDER=vllm", "VLLM_EMBED_MODEL=BAAI/custom-embed", "VLLM_EMBED_PORT=9101", "VLLM_EMBED_DEVICE=cpu", ], ) values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" nvidia-smi() {{ return 0; }} prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ return 1; }} confirm_default_yes() {{ case "$1" in "Run embedding model locally via Docker (vLLM)?") return 0 ;; *) return 1 ;; esac }} finalize_base_setup() {{ printf 'VLLM_EMBED_DEVICE=%s\\n' "${{ENV_VALUES[VLLM_EMBED_DEVICE]}}" }} env_base_flow """ ) assert values["VLLM_EMBED_DEVICE"] == "cpu" def test_env_base_flow_preserves_existing_vllm_embedding_cuda_device_on_rerun( tmp_path: Path, ) -> None: """Saved vLLM embedding CUDA mode should survive env-base reruns.""" write_text_lines( tmp_path / ".env", [ "LLM_BINDING=openai", "LLM_MODEL=gpt-4o-mini", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=sk-existing", "EMBEDDING_BINDING=openai", "EMBEDDING_MODEL=BAAI/custom-embed", "EMBEDDING_DIM=1024", "EMBEDDING_BINDING_HOST=http://localhost:9101/v1", "EMBEDDING_BINDING_API_KEY=embed-key", "LIGHTRAG_SETUP_EMBEDDING_PROVIDER=vllm", "VLLM_EMBED_MODEL=BAAI/custom-embed", "VLLM_EMBED_PORT=9101", "VLLM_EMBED_DEVICE=cuda", ], ) values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" nvidia-smi() {{ return 0; }} prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ return 1; }} confirm_default_yes() {{ case "$1" in "Run embedding model locally via Docker (vLLM)?") return 0 ;; *) return 1 ;; esac }} finalize_base_setup() {{ printf 'VLLM_EMBED_DEVICE=%s\\n' "${{ENV_VALUES[VLLM_EMBED_DEVICE]}}" }} env_base_flow """ ) assert values["VLLM_EMBED_DEVICE"] == "cuda" def test_env_base_flow_defaults_new_vllm_embedding_to_cuda_on_gpu_host( tmp_path: Path, ) -> None: """Fresh local vLLM embedding setup should honor GPU auto-detection.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" nvidia-smi() {{ return 0; }} prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ case "$1" in "Run embedding model locally via Docker (vLLM)?") return 0 ;; "Enable reranking?") return 1 ;; *) return 1 ;; esac }} confirm_default_yes() {{ return 1 }} finalize_base_setup() {{ printf 'VLLM_EMBED_DEVICE=%s\\n' "${{ENV_VALUES[VLLM_EMBED_DEVICE]}}" }} env_base_flow """ ) assert values["VLLM_EMBED_DEVICE"] == "cuda" def test_env_base_flow_preserves_ssl_config_on_rerun(tmp_path: Path) -> None: """env-base should preserve SSL config on rerun, even when old paths are stale.""" cases = { "stale-paths": [ "SSL=true", "SSL_CERTFILE=/missing/cert.pem", "SSL_KEYFILE=/missing/key.pem", "LLM_BINDING_API_KEY=sk-existing", "EMBEDDING_BINDING_API_KEY=sk-existing", ], "existing-paths": [ "SSL=true", "SSL_CERTFILE=/some/cert.pem", "SSL_KEYFILE=/some/key.pem", ], } for case_name, env_lines in cases.items(): case_dir = tmp_path / case_name case_dir.mkdir() write_text_lines( case_dir / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) write_text_lines(case_dir / ".env", env_lines) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{case_dir}" prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ return 1; }} confirm_default_yes() {{ case "$1" in *) return 1 ;; esac }} confirm_required_yes_no() {{ return 0; }} env_base_flow """ ) generated_lines = (case_dir / ".env").read_text(encoding="utf-8").splitlines() for line in env_lines: assert line in generated_lines def test_env_base_flow_preserves_existing_compose_ssl_when_env_paths_are_stale( tmp_path: Path, ) -> None: """env-base should keep compose SSL wiring when inherited source paths no longer exist.""" write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) write_text_lines( tmp_path / ".env", [ "SSL=true", "SSL_CERTFILE=/missing/cert.pem", "SSL_KEYFILE=/missing/key.pem", "LLM_BINDING=openai", "LLM_MODEL=gpt-4o-mini", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=sk-existing", "EMBEDDING_BINDING=openai", "EMBEDDING_MODEL=text-embedding-3-small", "EMBEDDING_DIM=1536", "EMBEDDING_BINDING_HOST=https://api.openai.com/v1", "EMBEDDING_BINDING_API_KEY=sk-existing", ], ) write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " environment:", ' SSL_CERTFILE: "/app/data/certs/cert.pem"', ' SSL_KEYFILE: "/app/data/certs/key.pem"', " volumes:", ' - "./data/certs/cert.pem:/app/data/certs/cert.pem:ro"', ' - "./data/certs/key.pem:/app/data/certs/key.pem:ro"', ], ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ return 1; }} confirm_default_yes() {{ case "$1" in "Run LightRAG Server via Docker?") return 0 ;; *) return 1 ;; esac }} confirm_required_yes_no() {{ return 0; }} env_base_flow """ ) generated_compose = (tmp_path / "docker-compose.final.yml").read_text( encoding="utf-8" ) assert 'SSL_CERTFILE: "/app/data/certs/cert.pem"' in generated_compose assert 'SSL_KEYFILE: "/app/data/certs/key.pem"' 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 def test_finalize_base_setup_uses_compose_native_storage_endpoints_on_rerun( tmp_path: Path, ) -> None: """Preserved managed storage services should inject compose-native endpoints on base reruns.""" write_text_lines( tmp_path / ".env", [ "LIGHTRAG_RUNTIME_TARGET=compose", "LIGHTRAG_SETUP_NEO4J_DEPLOYMENT=docker", "LIGHTRAG_SETUP_MILVUS_DEPLOYMENT=docker", "NEO4J_URI=neo4j://localhost:7687", "MILVUS_URI=http://localhost:19530", "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " neo4j:", " image: neo4j:latest", " milvus:", " image: milvusdb/milvus:v2.6.11", " milvus-etcd:", " image: quay.io/coreos/etcd:v3.5.16", " milvus-minio:", " image: minio/minio:latest", "volumes:", " neo4j_data:", " milvus_data:", " milvus-etcd_data:", " milvus-minio_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 show_summary() {{ :; }} confirm_required_yes_no() {{ return 0; }} confirm_default_yes() {{ return 0; }} validate_sensitive_env_literals() {{ return 0; }} finalize_base_setup """ ) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") assert 'NEO4J_URI: "neo4j://neo4j:7687"' in result assert 'MILVUS_URI: "http://milvus:19530"' in result assert 'NEO4J_URI: "neo4j://host.docker.internal:7687"' not in result assert 'MILVUS_URI: "http://host.docker.internal:19530"' not in result assert " milvus:\n condition: service_healthy" in result assert " milvus-etcd:\n condition: service_healthy" not in result assert " milvus-minio:\n condition: service_healthy" not in result def test_finalize_base_setup_drops_stale_storage_services_missing_from_env_markers( tmp_path: Path, ) -> None: """env-base should treat storage Docker state in `.env` as authoritative.""" write_text_lines( tmp_path / ".env", [ "LIGHTRAG_RUNTIME_TARGET=compose", "LLM_BINDING=openai", "LLM_MODEL=gpt-4o-mini", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=sk-existing", "EMBEDDING_BINDING=openai", "EMBEDDING_MODEL=text-embedding-3-small", "EMBEDDING_DIM=1536", "EMBEDDING_BINDING_HOST=https://api.openai.com/v1", "EMBEDDING_BINDING_API_KEY=sk-existing", "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " redis:", " image: redis:latest", " qdrant:", " image: qdrant/qdrant:latest", "volumes:", " redis_data:", " qdrant_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 show_summary() {{ :; }} confirm_required_yes_no() {{ return 0; }} confirm_default_yes() {{ return 1; }} confirm_default_no() {{ return 1; }} validate_sensitive_env_literals() {{ return 0; }} finalize_base_setup """ ) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert " lightrag:" in result assert " redis:" not in result assert " qdrant:" not in result assert "redis_data:" not in result assert "qdrant_data:" not in result assert "LIGHTRAG_RUNTIME_TARGET=compose" in generated_env def test_env_base_flow_backs_up_legacy_generated_compose_before_rewrite( tmp_path: Path, ) -> None: """env-base should back up the active legacy compose file before regenerating final output.""" legacy_compose = ( "\n".join( [ "services:", " lightrag:", " image: prod/lightrag", ] ) + "\n" ) 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", "LLM_MODEL=gpt-4o-mini", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=sk-existing", "EMBEDDING_BINDING=openai", "EMBEDDING_MODEL=text-embedding-3-small", "EMBEDDING_DIM=1536", "EMBEDDING_BINDING_HOST=https://api.openai.com/v1", "EMBEDDING_BINDING_API_KEY=sk-existing", ], ) (tmp_path / "docker-compose.production.yml").write_text( legacy_compose, encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ case "$1" in "LLM API key: "|"Embedding API key: ") printf 'sk-test-key' ;; *) printf '%s' "$2" ;; esac }} confirm_default_no() {{ return 1; }} confirm_default_yes() {{ case "$1" in "Run LightRAG Server via Docker?") return 0 ;; *) return 1 ;; esac }} confirm_required_yes_no() {{ return 0; }} env_base_flow """ ) assert_single_compose_backup(tmp_path, legacy_compose) assert (tmp_path / "docker-compose.final.yml").exists() assert (tmp_path / "docker-compose.production.yml").read_text(encoding="utf-8") == ( legacy_compose ) def test_env_base_flow_deletes_compose_when_switching_lightrag_to_host( tmp_path: Path, ) -> None: """env-base should back up and delete compose when no Docker services remain.""" existing_compose = ( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " redis:", " image: redis:latest", ] ) + "\n" ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) write_text_lines( tmp_path / ".env", [ "LIGHTRAG_RUNTIME_TARGET=compose", "LLM_BINDING=openai", "LLM_MODEL=gpt-4o-mini", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=sk-existing", "EMBEDDING_BINDING=openai", "EMBEDDING_MODEL=text-embedding-3-small", "EMBEDDING_DIM=1536", "EMBEDDING_BINDING_HOST=https://api.openai.com/v1", "EMBEDDING_BINDING_API_KEY=sk-existing", ], ) (tmp_path / "docker-compose.final.yml").write_text( existing_compose, encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ case "$1" in "LLM API key: "|"Embedding API key: ") printf 'sk-test-key' ;; *) printf '%s' "$2" ;; esac }} confirm_default_no() {{ case "$1" in "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 0 ;; *) return 1 ;; esac }} confirm_default_yes() {{ return 1; }} confirm_required_yes_no() {{ return 0; }} env_base_flow """ ) assert_single_compose_backup(tmp_path, existing_compose) assert not (tmp_path / "docker-compose.final.yml").exists() generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert "LIGHTRAG_RUNTIME_TARGET=host" in generated_env def test_env_base_flow_generates_env_and_compose_files(tmp_path: Path) -> None: """env-base should generate `.env` and docker-compose output for hosted and local providers.""" cases = { "openai": { "prompt_choice": "prompt_choice() { printf '%s' \"$2\"; }", "prompt_secret": """ prompt_secret_until_valid_with_default() { case "$1" in "LLM API key: "|"Embedding API key: ") printf 'sk-test-key' ;; *) printf '%s' "$2" ;; esac } """, "env_assertions": [ "LLM_BINDING=openai", "LLM_BINDING_API_KEY=sk-test-key", "EMBEDDING_BINDING_API_KEY=sk-test-key", ], }, "ollama": { "prompt_choice": """ prompt_choice() { case "$1" in "LLM provider") printf 'ollama' ;; "Embedding provider") printf 'ollama' ;; *) printf '%s' "$2" ;; esac } """, "prompt_secret": "prompt_secret_until_valid_with_default() { printf '%s' \"$2\"; }", "env_assertions": [ "LLM_BINDING=ollama", "EMBEDDING_BINDING=ollama", ], }, } for case_name, case in cases.items(): case_dir = tmp_path / case_name case_dir.mkdir() write_text_lines( case_dir / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) write_text_lines( case_dir / "docker-compose.yml", (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8").splitlines(), ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{case_dir}" {case["prompt_choice"]} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} {case["prompt_secret"]} confirm_default_no() {{ case "$1" in "Run embedding model locally via Docker (vLLM)?") return 1 ;; "Enable reranking?") return 1 ;; "Run LightRAG Server via Docker?") return 0 ;; *) return 1 ;; esac }} confirm_default_yes() {{ case "$1" in *) return 1 ;; esac }} confirm_required_yes_no() {{ return 0; }} env_base_flow """ ) generated_env = (case_dir / ".env").read_text(encoding="utf-8") generated_compose = (case_dir / "docker-compose.final.yml").read_text( encoding="utf-8" ) assert "LIGHTRAG_RUNTIME_TARGET=compose" in generated_env assert "LIGHTRAG_KV_STORAGE=JsonKVStorage" in generated_env assert "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage" in generated_env assert "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage" in generated_env assert "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage" in generated_env for expected_line in case["env_assertions"]: assert expected_line in generated_env assert "services:" in generated_compose assert " lightrag:" in generated_compose assert "env_file:" not in generated_compose def test_env_base_flow_generates_validatable_env_on_clean_checkout( tmp_path: Path, ) -> None: """Fresh env-base output should include default storage selections and pass validation.""" 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}" prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ case "$1" in "LLM API key: "|"Embedding API key: ") printf 'sk-test-key' ;; *) printf '%s' "$2" ;; esac }} confirm_default_no() {{ return 1; }} confirm_default_yes() {{ case "$1" in *) return 1 ;; esac }} confirm_required_yes_no() {{ return 0; }} env_base_flow validate_env_file """ ) generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert "LIGHTRAG_KV_STORAGE=JsonKVStorage" in generated_env assert "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage" in generated_env assert "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage" in generated_env assert "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage" in generated_env assert "LIGHTRAG_RUNTIME_TARGET=host" in generated_env assert "LIGHTRAG_SETUP_PROFILE=" not in generated_env def test_env_storage_flow_drops_legacy_setup_profile_on_write(tmp_path: Path) -> None: """Modular flows should not persist LIGHTRAG_SETUP_PROFILE into regenerated .env files.""" write_text_lines( tmp_path / ".env", [ "LIGHTRAG_SETUP_PROFILE=production", "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", ], ) 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}" select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="JsonKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="NanoVectorDBStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="NetworkXStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="JsonDocStatusStorage" }} collect_database_config() {{ :; }} validate_required_variables() {{ return 0; }} confirm_default_yes() {{ return 0; }} confirm_default_no() {{ return 1; }} confirm_required_yes_no() {{ return 0; }} env_storage_flow """ ) generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert "LIGHTRAG_RUNTIME_TARGET=host" in generated_env assert "LIGHTRAG_SETUP_PROFILE=" not in generated_env def test_env_base_flow_registers_vllm_rerank_service_for_docker_deployment( tmp_path: Path, ) -> None: """Choosing docker rerank in env-base should add vllm-rerank to DOCKER_SERVICE_SET.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state collect_llm_config() {{ :; }} collect_embedding_config() {{ :; }} prompt_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ case "$1" in "Run embedding model locally via Docker (vLLM)?") return 1 ;; "Enable reranking?") return 0 ;; "Run rerank service locally via Docker?") return 0 ;; *) return 1 ;; esac }} confirm_default_yes() {{ return 1; }} finalize_base_setup() {{ if [[ -n "${{DOCKER_SERVICE_SET[vllm-rerank]+set}}" ]]; then printf 'HAS_VLLM_SERVICE=yes\\n' else printf 'HAS_VLLM_SERVICE=no\\n' fi }} env_base_flow """ ) values = parse_lines(output) assert values["HAS_VLLM_SERVICE"] == "yes" def test_env_base_flow_preserves_existing_vllm_rerank_settings_on_rerun( tmp_path: Path, ) -> None: """Rerunning env-base should keep saved local vLLM rerank model and port.""" write_text_lines( tmp_path / ".env", [ "LLM_BINDING=openai", "LLM_MODEL=gpt-4o-mini", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=sk-existing", "RERANK_BINDING=cohere", "RERANK_MODEL=BAAI/custom-rerank", "RERANK_BINDING_HOST=http://localhost:9200/rerank", "RERANK_BINDING_API_KEY=rerank-key", "LIGHTRAG_SETUP_RERANK_PROVIDER=vllm", "VLLM_RERANK_MODEL=BAAI/custom-rerank", "VLLM_RERANK_PORT=9200", "VLLM_RERANK_DEVICE=cpu", ], ) values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ case "$1" in "Enable reranking?") return 0 ;; "Run rerank service locally via Docker?") return 0 ;; *) return 1 ;; esac }} confirm_default_yes() {{ return 1; }} collect_embedding_config() {{ :; }} finalize_base_setup() {{ printf 'RERANK_MODEL=%s\\n' "${{ENV_VALUES[RERANK_MODEL]}}" printf 'RERANK_BINDING_HOST=%s\\n' "${{ENV_VALUES[RERANK_BINDING_HOST]}}" printf 'VLLM_RERANK_MODEL=%s\\n' "${{ENV_VALUES[VLLM_RERANK_MODEL]}}" printf 'VLLM_RERANK_PORT=%s\\n' "${{ENV_VALUES[VLLM_RERANK_PORT]}}" }} env_base_flow """ ) assert values["RERANK_MODEL"] == "BAAI/custom-rerank" assert values["RERANK_BINDING_HOST"] == "http://localhost:9200/rerank" assert values["VLLM_RERANK_MODEL"] == "BAAI/custom-rerank" assert values["VLLM_RERANK_PORT"] == "9200" def test_env_base_flow_does_not_repeat_rerank_docker_prompt_when_declined( tmp_path: Path, ) -> None: """Declining rerank Docker at the outer prompt should switch to endpoint-based config.""" write_text_lines( tmp_path / ".env", [ "LLM_BINDING=openai", "LLM_MODEL=gpt-4o-mini", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=sk-existing", "RERANK_BINDING=cohere", "RERANK_MODEL=BAAI/custom-rerank", "RERANK_BINDING_HOST=http://localhost:9200/rerank", "RERANK_BINDING_API_KEY=rerank-key", "LIGHTRAG_SETUP_RERANK_PROVIDER=vllm", "VLLM_RERANK_MODEL=BAAI/custom-rerank", "VLLM_RERANK_PORT=9200", "VLLM_RERANK_DEVICE=cpu", ], ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" DOCKER_PROMPT_COUNT=0 RERANK_MODEL_PROMPT_LOG="$REPO_ROOT/rerank-model-prompts.log" : > "$RERANK_MODEL_PROMPT_LOG" prompt_choice() {{ case "$1" in "vLLM device") echo "unexpected vLLM device prompt" >&2 return 91 ;; *) printf '%s' "$2" ;; esac }} prompt_with_default() {{ case "$1" in "vLLM rerank model") echo "unexpected vLLM rerank model prompt" >&2 return 93 ;; "Rerank model") printf 'hit\n' >> "$RERANK_MODEL_PROMPT_LOG" printf '%s' "$2" return 0 ;; "Rerank endpoint") printf '%s' "https://rerank.example.internal/rerank" return 0 ;; esac printf '%s' "$2" }} prompt_until_valid() {{ case "$1" in "vLLM rerank port") echo "unexpected vLLM rerank port prompt" >&2 return 92 ;; esac printf '%s' "$2" }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ return 1; }} confirm_default_yes() {{ case "$1" in "Enable reranking?") return 0 ;; "Run rerank service locally via Docker?") DOCKER_PROMPT_COUNT=$((DOCKER_PROMPT_COUNT + 1)) return 1 ;; *) return 1 ;; esac }} collect_embedding_config() {{ :; }} finalize_base_setup() {{ local rerank_model_prompt_count rerank_model_prompt_count="$(wc -l < "$RERANK_MODEL_PROMPT_LOG" | tr -d '[:space:]')" printf 'DOCKER_PROMPT_COUNT=%s\\n' "$DOCKER_PROMPT_COUNT" printf 'RERANK_MODEL_PROMPT_COUNT=%s\\n' "$rerank_model_prompt_count" printf 'RERANK_BINDING_HOST=%s\\n' "${{ENV_VALUES[RERANK_BINDING_HOST]}}" printf 'LIGHTRAG_SETUP_RERANK_PROVIDER=%s\\n' "${{ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]:-}}" }} env_base_flow """ ) values = parse_lines(output) assert values["DOCKER_PROMPT_COUNT"] == "1" assert values["RERANK_MODEL_PROMPT_COUNT"] == "1" assert values["RERANK_BINDING_HOST"] == "https://rerank.example.internal/rerank" assert values["LIGHTRAG_SETUP_RERANK_PROVIDER"] == "" assert "vLLM uses the Cohere-compatible rerank API." not in output def test_env_base_flow_comments_rerank_setup_marker_when_switching_off_docker( tmp_path: Path, ) -> None: """Switching rerank from Docker to a non-Docker provider should drop the setup marker.""" 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", "LLM_MODEL=gpt-4o-mini", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=sk-existing", "RERANK_BINDING=cohere", "RERANK_MODEL=BAAI/custom-rerank", "RERANK_BINDING_HOST=http://localhost:9200/rerank", "RERANK_BINDING_API_KEY=rerank-key", "LIGHTRAG_SETUP_RERANK_PROVIDER=vllm", "VLLM_RERANK_MODEL=BAAI/custom-rerank", "VLLM_RERANK_PORT=9200", "VLLM_RERANK_DEVICE=cpu", ], ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" prompt_choice() {{ case "$1" in "Rerank provider") printf 'cohere' ;; *) printf '%s' "$2" ;; esac }} prompt_with_default() {{ case "$1" in "Rerank endpoint") printf '%s' "https://api.cohere.com/v2/rerank" ;; *) printf '%s' "$2" ;; esac }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ case "$1" in "Run embedding model locally via Docker (vLLM)?") return 1 ;; "Run rerank service locally via Docker?") return 1 ;; "Run LightRAG Server via Docker?") return 1 ;; *) return 1 ;; esac }} confirm_default_yes() {{ case "$1" in "Enable reranking?") return 0 ;; *) return 1 ;; esac }} confirm_required_yes_no() {{ return 0; }} env_base_flow """ ) generated_env = (tmp_path / ".env").read_text(encoding="utf-8") active_marker_lines = [ line for line in generated_env.splitlines() if line.startswith("LIGHTRAG_SETUP_RERANK_PROVIDER=") ] assert "RERANK_BINDING=cohere" in generated_env assert active_marker_lines == [] def test_env_base_flow_resets_remote_rerank_host_when_switching_to_vllm( tmp_path: Path, ) -> None: """Switching a remote reranker to local vLLM should restore localhost.""" write_text_lines( tmp_path / ".env", [ "LLM_BINDING=openai", "LLM_MODEL=gpt-4o-mini", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=sk-existing", "RERANK_BINDING=jina", "RERANK_MODEL=jina-reranker-v2-base-multilingual", "RERANK_BINDING_HOST=https://api.jina.ai/v1/rerank", "RERANK_BINDING_API_KEY=jina-key", "VLLM_RERANK_PORT=9200", ], ) values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ case "$1" in "Run rerank service locally via Docker?") return 0 ;; *) return 1 ;; esac }} confirm_default_yes() {{ case "$1" in "Enable reranking?") return 0 ;; *) return 1 ;; esac }} collect_embedding_config() {{ :; }} finalize_base_setup() {{ printf 'RERANK_BINDING=%s\\n' "${{ENV_VALUES[RERANK_BINDING]}}" printf 'RERANK_BINDING_HOST=%s\\n' "${{ENV_VALUES[RERANK_BINDING_HOST]}}" printf 'LIGHTRAG_SETUP_RERANK_PROVIDER=%s\\n' "${{ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]}}" }} env_base_flow """ ) assert values["RERANK_BINDING"] == "cohere" assert values["RERANK_BINDING_HOST"] == "http://localhost:9200/rerank" assert values["LIGHTRAG_SETUP_RERANK_PROVIDER"] == "vllm" def test_env_base_flow_preserves_existing_vllm_rerank_device_on_gpu_host( tmp_path: Path, ) -> None: """Saved vLLM rerank CPU/GPU mode should win over auto-detected GPU defaults.""" write_text_lines( tmp_path / ".env", [ "LLM_BINDING=openai", "LLM_MODEL=gpt-4o-mini", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=sk-existing", "RERANK_BINDING=cohere", "RERANK_MODEL=BAAI/custom-rerank", "RERANK_BINDING_HOST=http://localhost:9200/rerank", "RERANK_BINDING_API_KEY=rerank-key", "LIGHTRAG_SETUP_RERANK_PROVIDER=vllm", "VLLM_RERANK_MODEL=BAAI/custom-rerank", "VLLM_RERANK_PORT=9200", "VLLM_RERANK_DEVICE=cpu", ], ) values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" nvidia-smi() {{ return 0; }} prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ case "$1" in "Enable reranking?") return 0 ;; *) return 1 ;; esac }} confirm_default_yes() {{ case "$1" in "Run rerank service locally via Docker?") return 0 ;; *) return 1 ;; esac }} collect_embedding_config() {{ :; }} finalize_base_setup() {{ printf 'VLLM_RERANK_DEVICE=%s\\n' "${{ENV_VALUES[VLLM_RERANK_DEVICE]}}" }} env_base_flow """ ) assert values["VLLM_RERANK_DEVICE"] == "cpu" def test_env_base_flow_preserves_existing_vllm_rerank_cuda_device_on_rerun( tmp_path: Path, ) -> None: """Saved vLLM rerank CUDA mode should survive env-base reruns.""" write_text_lines( tmp_path / ".env", [ "LLM_BINDING=openai", "LLM_MODEL=gpt-4o-mini", "LLM_BINDING_HOST=https://api.openai.com/v1", "LLM_BINDING_API_KEY=sk-existing", "RERANK_BINDING=cohere", "RERANK_MODEL=BAAI/custom-rerank", "RERANK_BINDING_HOST=http://localhost:9200/rerank", "RERANK_BINDING_API_KEY=rerank-key", "LIGHTRAG_SETUP_RERANK_PROVIDER=vllm", "VLLM_RERANK_MODEL=BAAI/custom-rerank", "VLLM_RERANK_PORT=9200", "VLLM_RERANK_DEVICE=cuda", ], ) values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" nvidia-smi() {{ return 0; }} prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_no() {{ case "$1" in "Enable reranking?") return 0 ;; *) return 1 ;; esac }} confirm_default_yes() {{ case "$1" in "Run rerank service locally via Docker?") return 0 ;; *) return 1 ;; esac }} collect_embedding_config() {{ :; }} finalize_base_setup() {{ printf 'VLLM_RERANK_DEVICE=%s\\n' "${{ENV_VALUES[VLLM_RERANK_DEVICE]}}" }} env_base_flow """ ) assert values["VLLM_RERANK_DEVICE"] == "cuda" def test_env_storage_flow_applies_selected_storage_backends( tmp_path: Path, ) -> None: """env-storage should honor the selected backends without auto-applying a preset.""" write_text_lines( tmp_path / ".env", [ "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", "LLM_BINDING=ollama", "LLM_MODEL=llama3.2:latest", "LLM_BINDING_HOST=http://localhost:11434", "EMBEDDING_BINDING=ollama", "EMBEDDING_MODEL=nomic-embed-text:latest", "EMBEDDING_DIM=768", "EMBEDDING_BINDING_HOST=http://localhost:11434", ], ) values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="RedisKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="MilvusVectorDBStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="Neo4JStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="RedisDocStatusStorage" }} collect_database_config() {{ :; }} collect_docker_image_tags() {{ :; }} finalize_storage_setup() {{ printf 'LIGHTRAG_KV_STORAGE=%s\\n' "${{ENV_VALUES[LIGHTRAG_KV_STORAGE]}}" printf 'LIGHTRAG_VECTOR_STORAGE=%s\\n' "${{ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]}}" printf 'LIGHTRAG_GRAPH_STORAGE=%s\\n' "${{ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]}}" printf 'LIGHTRAG_DOC_STATUS_STORAGE=%s\\n' "${{ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]}}" printf 'LLM_BINDING=%s\\n' "${{ENV_VALUES[LLM_BINDING]}}" printf 'EMBEDDING_BINDING=%s\\n' "${{ENV_VALUES[EMBEDDING_BINDING]}}" }} env_storage_flow """ ) assert values["LIGHTRAG_KV_STORAGE"] == "RedisKVStorage" assert values["LIGHTRAG_VECTOR_STORAGE"] == "MilvusVectorDBStorage" assert values["LIGHTRAG_GRAPH_STORAGE"] == "Neo4JStorage" assert values["LIGHTRAG_DOC_STATUS_STORAGE"] == "RedisDocStatusStorage" # LLM and embedding settings from existing .env are preserved assert values["LLM_BINDING"] == "ollama" assert values["EMBEDDING_BINDING"] == "ollama" def test_env_storage_flow_reuses_saved_storage_docker_default( tmp_path: Path, ) -> None: """Saved storage deployment metadata should drive the next Docker prompt default.""" write_text_lines( tmp_path / ".env", [ "LIGHTRAG_SETUP_POSTGRES_DEPLOYMENT=docker", "LIGHTRAG_KV_STORAGE=PGKVStorage", "LIGHTRAG_VECTOR_STORAGE=PGVectorStorage", "LIGHTRAG_GRAPH_STORAGE=PGGraphStorage", "LIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage", ], ) values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="PGKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="PGVectorStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="PGGraphStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="PGDocStatusStorage" REQUIRED_DB_TYPES[postgresql]=1 }} collect_postgres_config() {{ printf 'POSTGRES_DEFAULT_DOCKER=%s\\n' "$1" }} finalize_storage_setup() {{ :; }} env_storage_flow """ ) assert values["POSTGRES_DEFAULT_DOCKER"] == "yes" def test_env_storage_flow_writes_storage_docker_marker_for_selected_service( tmp_path: Path, ) -> None: """Choosing a bundled storage service should persist its deployment marker in `.env`.""" write_text_lines( tmp_path / ".env", [ "LLM_BINDING=ollama", "EMBEDDING_BINDING=ollama", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) (tmp_path / "docker-compose.yml").write_text( (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8"), encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="PGKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="PGVectorStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="PGGraphStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="PGDocStatusStorage" REQUIRED_DB_TYPES[postgresql]=1 }} collect_postgres_config() {{ add_docker_service "postgres" ENV_VALUES[POSTGRES_HOST]="localhost" ENV_VALUES[POSTGRES_PORT]="5432" ENV_VALUES[POSTGRES_USER]="lightrag" ENV_VALUES[POSTGRES_PASSWORD]="secret" ENV_VALUES[POSTGRES_DATABASE]="lightrag" }} validate_required_variables() {{ return 0; }} validate_mongo_vector_storage_config() {{ return 0; }} validate_sensitive_env_literals() {{ return 0; }} confirm_default_yes() {{ case "$1" in "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 1 ;; *) return 0 ;; esac }} confirm_default_no() {{ return 1; }} confirm_required_yes_no() {{ return 0; }} env_storage_flow """ ) generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert any( line == "LIGHTRAG_SETUP_POSTGRES_DEPLOYMENT=docker" for line in generated_env.splitlines() ) assert "LIGHTRAG_RUNTIME_TARGET=compose" in generated_env def test_env_storage_flow_writes_opensearch_docker_marker_for_selected_service( tmp_path: Path, ) -> None: """Choosing bundled OpenSearch should persist its deployment marker in `.env`.""" write_text_lines( tmp_path / ".env", [ "LLM_BINDING=ollama", "EMBEDDING_BINDING=ollama", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) (tmp_path / "docker-compose.yml").write_text( (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8"), encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="OpenSearchKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="OpenSearchVectorDBStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="OpenSearchGraphStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="OpenSearchDocStatusStorage" REQUIRED_DB_TYPES[opensearch]=1 }} collect_opensearch_config() {{ add_docker_service "opensearch" ENV_VALUES[OPENSEARCH_HOSTS]="localhost:9200" ENV_VALUES[OPENSEARCH_USER]="admin" ENV_VALUES[OPENSEARCH_PASSWORD]="secret" ENV_VALUES[OPENSEARCH_USE_SSL]="true" ENV_VALUES[OPENSEARCH_VERIFY_CERTS]="false" }} validate_required_variables() {{ return 0; }} validate_mongo_vector_storage_config() {{ return 0; }} validate_sensitive_env_literals() {{ return 0; }} confirm_default_yes() {{ case "$1" in "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 1 ;; *) return 0 ;; esac }} confirm_default_no() {{ return 1; }} confirm_required_yes_no() {{ return 0; }} env_storage_flow """ ) generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert any( line == "LIGHTRAG_SETUP_OPENSEARCH_DEPLOYMENT=docker" for line in generated_env.splitlines() ) assert "LIGHTRAG_RUNTIME_TARGET=compose" in generated_env def test_env_storage_flow_removes_storage_docker_marker_when_switching_to_host( tmp_path: Path, ) -> None: """Choosing a host-managed storage backend should clear a previously saved Docker marker.""" write_text_lines( tmp_path / ".env", [ "LIGHTRAG_SETUP_POSTGRES_DEPLOYMENT=docker", "LIGHTRAG_KV_STORAGE=PGKVStorage", "LIGHTRAG_VECTOR_STORAGE=PGVectorStorage", "LIGHTRAG_GRAPH_STORAGE=PGGraphStorage", "LIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage", ], ) 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}" select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="PGKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="PGVectorStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="PGGraphStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="PGDocStatusStorage" REQUIRED_DB_TYPES[postgresql]=1 }} collect_postgres_config() {{ ENV_VALUES[POSTGRES_HOST]="localhost" ENV_VALUES[POSTGRES_PORT]="5432" ENV_VALUES[POSTGRES_USER]="lightrag" ENV_VALUES[POSTGRES_PASSWORD]="secret" ENV_VALUES[POSTGRES_DATABASE]="lightrag" }} validate_required_variables() {{ return 0; }} validate_mongo_vector_storage_config() {{ return 0; }} validate_sensitive_env_literals() {{ return 0; }} confirm_default_yes() {{ case "$1" in "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 1 ;; *) return 0 ;; esac }} confirm_default_no() {{ return 1; }} confirm_required_yes_no() {{ return 0; }} env_storage_flow """ ) generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert not any( line.startswith("LIGHTRAG_SETUP_POSTGRES_DEPLOYMENT=") for line in generated_env.splitlines() ) assert "LIGHTRAG_RUNTIME_TARGET=host" in generated_env def test_env_storage_flow_clears_unused_storage_docker_markers( tmp_path: Path, ) -> None: """Markers for databases no longer required by the selected backends should be removed.""" write_text_lines( tmp_path / ".env", [ "LIGHTRAG_SETUP_POSTGRES_DEPLOYMENT=docker", "LIGHTRAG_KV_STORAGE=PGKVStorage", "LIGHTRAG_VECTOR_STORAGE=PGVectorStorage", "LIGHTRAG_GRAPH_STORAGE=PGGraphStorage", "LIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage", ], ) 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}" select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="JsonKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="NanoVectorDBStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="NetworkXStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="JsonDocStatusStorage" }} collect_database_config() {{ :; }} validate_required_variables() {{ return 0; }} validate_mongo_vector_storage_config() {{ return 0; }} validate_sensitive_env_literals() {{ return 0; }} confirm_default_yes() {{ case "$1" in "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 1 ;; *) return 0 ;; esac }} confirm_default_no() {{ return 1; }} confirm_required_yes_no() {{ return 0; }} env_storage_flow """ ) generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert not any( line.startswith("LIGHTRAG_SETUP_POSTGRES_DEPLOYMENT=") for line in generated_env.splitlines() ) assert "LIGHTRAG_KV_STORAGE=JsonKVStorage" in generated_env def test_env_storage_flow_generates_env_and_compose_files(tmp_path: Path) -> None: """env-storage should write updated .env and a docker-compose.final.yml.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "LLM_BINDING=ollama", "EMBEDDING_BINDING=ollama", "AUTH_ACCOUNTS=admin:secret", "TOKEN_SECRET=jwt-secret", "WHITELIST_PATHS=/health", ] ) + "\n", encoding="utf-8", ) (tmp_path / "env.example").write_text( (REPO_ROOT / "env.example").read_text(encoding="utf-8"), encoding="utf-8", ) (tmp_path / "docker-compose.yml").write_text( (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8"), encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="PGKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="MilvusVectorDBStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="Neo4JStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="PGDocStatusStorage" add_docker_service "postgres" add_docker_service "neo4j" }} collect_database_config() {{ :; }} collect_docker_image_tags() {{ :; }} validate_required_variables() {{ return 0; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} confirm_default_yes() {{ case "$1" in *) return 1 ;; esac }} confirm_default_no() {{ return 1; }} confirm_required_yes_no() {{ return 0; }} env_storage_flow """ ) generated_env = (tmp_path / ".env").read_text(encoding="utf-8") generated_compose = (tmp_path / "docker-compose.final.yml").read_text( encoding="utf-8" ) assert "LIGHTRAG_KV_STORAGE=PGKVStorage" in generated_env assert "LIGHTRAG_GRAPH_STORAGE=Neo4JStorage" in generated_env assert "LLM_BINDING=ollama" in generated_env assert "services:" in generated_compose assert " lightrag:" in generated_compose assert "env_file:" not in generated_compose def test_env_storage_flow_uses_rag_defaults_for_empty_postgres_docker_credentials( tmp_path: Path, ) -> None: """env-storage should write bundled postgres credentials when old `.env` creds are empty.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "LLM_BINDING=ollama", "EMBEDDING_BINDING=ollama", "AUTH_ACCOUNTS=admin:secret", "TOKEN_SECRET=jwt-secret", "WHITELIST_PATHS=/health", "POSTGRES_USER=", "POSTGRES_PASSWORD=", "POSTGRES_DATABASE=", ] ) + "\n", encoding="utf-8", ) (tmp_path / "env.example").write_text( (REPO_ROOT / "env.example").read_text(encoding="utf-8"), encoding="utf-8", ) (tmp_path / "docker-compose.yml").write_text( (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8"), encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" PROMPT_LOG_FILE="$(mktemp)" : > "$PROMPT_LOG_FILE" select_storage_backends() {{ REQUIRED_DB_TYPES[postgresql]=1 ENV_VALUES[LIGHTRAG_KV_STORAGE]="PGKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="PGVectorStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="PGGraphStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="PGDocStatusStorage" }} confirm_default_no() {{ if [[ "$1" == "Run PostgreSQL locally via Docker?" ]]; then return 0 fi return 1 }} confirm_default_yes() {{ return 0; }} confirm_required_yes_no() {{ return 0; }} prompt_with_default() {{ printf '%s\\n' "$1" >> "$PROMPT_LOG_FILE" case "$1" in "PostgreSQL host") printf 'localhost' ;; *) printf '%s' "$2" ;; esac }} prompt_secret_with_default() {{ printf 'secret:%s\\n' "$1" >> "$PROMPT_LOG_FILE" printf '%s' "$2" }} env_storage_flow printf 'PROMPT_LOG=%s\\n' "$(paste -sd '|' "$PROMPT_LOG_FILE")" """, cwd=tmp_path, ) generated_env = (tmp_path / ".env").read_text(encoding="utf-8") generated_compose = (tmp_path / "docker-compose.final.yml").read_text( encoding="utf-8" ) assert "POSTGRES_USER=rag" in generated_env assert "POSTGRES_PASSWORD=rag" in generated_env assert "POSTGRES_DATABASE=rag" in generated_env assert 'POSTGRES_USER: "rag"' in generated_compose assert 'POSTGRES_PASSWORD: "rag"' in generated_compose assert 'POSTGRES_DB: "rag"' in generated_compose @pytest.mark.parametrize( ("changed_key", "changed_value", "expected_rewrite"), [ ("NEO4J_PASSWORD", "updated-password", "no"), ("NEO4J_DATABASE", "updated-database", "yes"), ], ids=["neo4j-password-does-not-rewrite", "neo4j-database-rewrites"], ) def test_configure_storage_compose_rewrites_only_rewrites_neo4j_on_database_change( changed_key: str, changed_value: str, expected_rewrite: str, ) -> None: """Neo4j service rewrites should be driven by database changes, not credentials.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state EXISTING_MANAGED_ROOT_SERVICE_SET[neo4j]=1 DOCKER_SERVICE_SET[neo4j]=1 ORIGINAL_ENV_VALUES[NEO4J_PASSWORD]="original-password" ORIGINAL_ENV_VALUES[NEO4J_DATABASE]="neo4j" ENV_VALUES[NEO4J_PASSWORD]="original-password" ENV_VALUES[NEO4J_DATABASE]="neo4j" ENV_VALUES[{changed_key}]="{changed_value}" configure_storage_compose_rewrites if [[ -n "${{COMPOSE_REWRITE_SERVICE_SET[neo4j]+set}}" ]]; then printf 'REWRITE=yes\\n' else printf 'REWRITE=no\\n' fi """ ) values = parse_lines(output) assert values["REWRITE"] == expected_rewrite @pytest.mark.parametrize( ("changed_key", "changed_value", "expected_rewrite"), [ ("POSTGRES_HOST", "db.example.com", "no"), ("POSTGRES_PORT", "6543", "no"), ("POSTGRES_USER", "updated-user", "yes"), ("POSTGRES_PASSWORD", "updated-password", "yes"), ("POSTGRES_DATABASE", "updated-database", "yes"), ], ids=[ "postgres-host-does-not-rewrite", "postgres-port-does-not-rewrite", "postgres-user-rewrites", "postgres-password-rewrites", "postgres-database-rewrites", ], ) def test_configure_storage_compose_rewrites_only_rewrites_postgres_for_service_env_changes( changed_key: str, changed_value: str, expected_rewrite: str, ) -> None: """Postgres service rewrites should only follow changes emitted into the postgres block.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state EXISTING_MANAGED_ROOT_SERVICE_SET[postgres]=1 DOCKER_SERVICE_SET[postgres]=1 ORIGINAL_ENV_VALUES[POSTGRES_HOST]="localhost" ORIGINAL_ENV_VALUES[POSTGRES_PORT]="5432" ORIGINAL_ENV_VALUES[POSTGRES_USER]="rag" ORIGINAL_ENV_VALUES[POSTGRES_PASSWORD]="rag" ORIGINAL_ENV_VALUES[POSTGRES_DATABASE]="lightrag" ENV_VALUES[POSTGRES_HOST]="localhost" ENV_VALUES[POSTGRES_PORT]="5432" ENV_VALUES[POSTGRES_USER]="rag" ENV_VALUES[POSTGRES_PASSWORD]="rag" ENV_VALUES[POSTGRES_DATABASE]="lightrag" ENV_VALUES[{changed_key}]="{changed_value}" configure_storage_compose_rewrites if [[ -n "${{COMPOSE_REWRITE_SERVICE_SET[postgres]+set}}" ]]; then printf 'REWRITE=yes\\n' else printf 'REWRITE=no\\n' fi """ ) values = parse_lines(output) assert values["REWRITE"] == expected_rewrite def test_env_storage_flow_backs_up_existing_compose_before_rewrite( tmp_path: Path, ) -> None: """env-storage should back up the current compose file before rewriting it.""" existing_compose = ( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " environment:", ' LEGACY_SETTING: "1"', " postgres:", " image: gzdaniel/postgres-for-rag:16.6", ] ) + "\n" ) write_text_lines( tmp_path / ".env", [ "LLM_BINDING=openai", "EMBEDDING_BINDING=openai", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) (tmp_path / "docker-compose.final.yml").write_text( existing_compose, encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="JsonKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="NanoVectorDBStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="NetworkXStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="JsonDocStatusStorage" }} collect_database_config() {{ :; }} validate_required_variables() {{ return 0; }} validate_mongo_vector_storage_config() {{ return 0; }} validate_sensitive_env_literals() {{ return 0; }} confirm_default_yes() {{ case "$1" in "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 1 ;; *) return 0 ;; esac }} confirm_default_no() {{ return 1; }} confirm_required_yes_no() {{ return 0; }} env_storage_flow """ ) assert_single_compose_backup(tmp_path, existing_compose) assert (tmp_path / "docker-compose.final.yml").exists() def test_env_storage_flow_keeps_compose_mode_for_user_sidecars( tmp_path: Path, ) -> None: """env-storage should keep LightRAG in Docker when user sidecars are present.""" existing_compose = ( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " environment:", ' LEGACY_SETTING: "1"', " sidecar:", " image: busybox", ] ) + "\n" ) write_text_lines( tmp_path / ".env", [ "LLM_BINDING=openai", "EMBEDDING_BINDING=openai", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) (tmp_path / "docker-compose.final.yml").write_text( existing_compose, encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="JsonKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="NanoVectorDBStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="NetworkXStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="JsonDocStatusStorage" }} collect_database_config() {{ :; }} validate_required_variables() {{ return 0; }} validate_mongo_vector_storage_config() {{ return 0; }} validate_sensitive_env_literals() {{ return 0; }} confirm_default_yes() {{ return 0; }} confirm_default_no() {{ return 1; }} confirm_required_yes_no() {{ return 0; }} env_storage_flow """ ) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert_single_compose_backup(tmp_path, existing_compose) assert " lightrag:" in result assert " sidecar:" in result assert "LIGHTRAG_RUNTIME_TARGET=compose" in generated_env def test_env_storage_flow_clears_mongodb_docker_marker_for_atlas_vector_storage( tmp_path: Path, ) -> None: """MongoDB Atlas-only vector storage should not preserve a local Docker deployment marker.""" write_text_lines( tmp_path / ".env", [ "LIGHTRAG_SETUP_MONGODB_DEPLOYMENT=docker", "LIGHTRAG_KV_STORAGE=MongoKVStorage", "LIGHTRAG_VECTOR_STORAGE=MongoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=MongoGraphStorage", "LIGHTRAG_DOC_STATUS_STORAGE=MongoDocStatusStorage", ], ) 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}" select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="MongoKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="MongoVectorDBStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="MongoGraphStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="MongoDocStatusStorage" REQUIRED_DB_TYPES[mongodb]=1 }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} validate_required_variables() {{ return 0; }} validate_mongo_vector_storage_config() {{ return 0; }} validate_sensitive_env_literals() {{ return 0; }} confirm_default_yes() {{ return 0; }} confirm_default_no() {{ return 1; }} confirm_required_yes_no() {{ return 0; }} env_storage_flow """ ) generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert not any( line.startswith("LIGHTRAG_SETUP_MONGODB_DEPLOYMENT=") for line in generated_env.splitlines() ) assert "MONGO_URI=mongodb+srv://cluster.example.mongodb.net/" in generated_env def test_env_storage_flow_preserves_existing_compose_ssl_when_env_paths_are_stale( tmp_path: Path, ) -> None: """env-storage should keep compose SSL wiring when inherited source paths no longer exist.""" write_text_lines( tmp_path / ".env", [ "SSL=true", "SSL_CERTFILE=/missing/cert.pem", "SSL_KEYFILE=/missing/key.pem", "LLM_BINDING=openai", "EMBEDDING_BINDING=openai", "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " environment:", ' SSL_CERTFILE: "/app/data/certs/cert.pem"', ' SSL_KEYFILE: "/app/data/certs/key.pem"', " volumes:", ' - "./data/certs/cert.pem:/app/data/certs/cert.pem:ro"', ' - "./data/certs/key.pem:/app/data/certs/key.pem:ro"', ], ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="JsonKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="NanoVectorDBStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="NetworkXStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="JsonDocStatusStorage" }} collect_database_config() {{ :; }} validate_required_variables() {{ return 0; }} confirm_default_yes() {{ case "$1" in "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 1 ;; *) return 0 ;; esac }} confirm_default_no() {{ return 1; }} confirm_required_yes_no() {{ return 0; }} env_storage_flow """ ) generated_compose = (tmp_path / "docker-compose.final.yml").read_text( encoding="utf-8" ) assert 'SSL_CERTFILE: "/app/data/certs/cert.pem"' in generated_compose assert 'SSL_KEYFILE: "/app/data/certs/key.pem"' 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 def test_env_server_flow_preserves_existing_compose_ssl_when_env_paths_are_stale( tmp_path: Path, ) -> None: """env-server should keep compose SSL wiring and variable-based port publishing.""" write_text_lines( tmp_path / ".env", [ "SSL=true", "SSL_CERTFILE=/missing/cert.pem", "SSL_KEYFILE=/missing/key.pem", "HOST=0.0.0.0", "PORT=9621", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " environment:", ' SSL_CERTFILE: "/app/data/certs/cert.pem"', ' SSL_KEYFILE: "/app/data/certs/key.pem"', " volumes:", ' - "./data/certs/cert.pem:/app/data/certs/cert.pem:ro"', ' - "./data/certs/key.pem:/app/data/certs/key.pem:ro"', ], ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" collect_server_config() {{ ENV_VALUES[HOST]="0.0.0.0" ENV_VALUES[PORT]="8080" }} collect_security_config() {{ :; }} collect_ssl_config() {{ :; }} confirm_default_yes() {{ case "$1" in "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 1 ;; *) return 0 ;; esac }} confirm_required_yes_no() {{ return 0; }} env_server_flow """ ) generated_compose = (tmp_path / "docker-compose.final.yml").read_text( encoding="utf-8" ) assert 'SSL_CERTFILE: "/app/data/certs/cert.pem"' in generated_compose assert 'SSL_KEYFILE: "/app/data/certs/key.pem"' 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 'PORT: "9621"' in generated_compose assert ' - "${HOST:-0.0.0.0}:${PORT:-9621}:9621"' in generated_compose def test_env_server_flow_backs_up_existing_compose_before_rewrite( tmp_path: Path, ) -> None: """env-server should back up the current compose file before rewriting it.""" existing_compose = ( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " environment:", ' PORT: "9621"', ] ) + "\n" ) write_text_lines( tmp_path / ".env", [ "HOST=0.0.0.0", "PORT=9621", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) (tmp_path / "docker-compose.final.yml").write_text( existing_compose, encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" collect_server_config() {{ ENV_VALUES[HOST]="0.0.0.0" ENV_VALUES[PORT]="8080" }} collect_security_config() {{ :; }} collect_ssl_config() {{ :; }} validate_sensitive_env_literals() {{ return 0; }} validate_security_config() {{ return 0; }} confirm_default_yes() {{ case "$1" in "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 1 ;; *) return 0 ;; esac }} confirm_required_yes_no() {{ return 0; }} env_server_flow """ ) assert_single_compose_backup(tmp_path, existing_compose) assert (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") != ( existing_compose ) def test_switching_to_non_docker_storage_removes_stale_services_from_compose( tmp_path: Path, ) -> None: """env-storage must strip managed storage services while preserving user sidecars.""" # Existing compose with postgres and neo4j Docker services. compose_file = tmp_path / "docker-compose.final.yml" compose_file.write_text( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " postgres:", " image: gzdaniel/postgres-for-rag:16.6", " neo4j:", " image: neo4j:5.26.21-community", " sidecar:", " image: busybox", ' command: ["sleep", "infinity"]', " volumes:", " - sidecar_data:/data", "volumes:", " postgres_data:", " neo4j_data:", " sidecar_data:", ] ) + "\n", encoding="utf-8", ) env_file = tmp_path / ".env" env_file.write_text("LLM_BINDING=openai\n", encoding="utf-8") (tmp_path / "env.example").write_text( (REPO_ROOT / "env.example").read_text(encoding="utf-8"), encoding="utf-8", ) (tmp_path / "docker-compose.yml").write_text( (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8"), encoding="utf-8", ) # User switches to non-Docker backends: DOCKER_SERVICES stays empty. run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="JsonKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="NanoVectorDBStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="NetworkXStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="JsonDocStatusStorage" }} collect_database_config() {{ :; }} collect_docker_image_tags() {{ :; }} validate_required_variables() {{ return 0; }} confirm_default_yes() {{ return 0; }} confirm_default_no() {{ return 1; }} confirm_required_yes_no() {{ return 0; }} env_storage_flow """ ) result = compose_file.read_text(encoding="utf-8") # Stale storage services must be gone. assert "postgres:" not in result assert "neo4j:" not in result assert "postgres_data:" not in result assert "neo4j_data:" not in result # lightrag and user services must be preserved. assert " lightrag:" in result assert " sidecar:" in result assert "sidecar_data:" in result def test_env_storage_flow_drops_stale_vllm_services_missing_from_env_markers( tmp_path: Path, ) -> None: """env-storage should remove stale vLLM services unless `.env` still marks them as Docker-managed.""" write_text_lines( tmp_path / ".env", [ "LIGHTRAG_RUNTIME_TARGET=compose", "LLM_BINDING=openai", "EMBEDDING_BINDING=openai", "RERANK_BINDING=cohere", "LIGHTRAG_SETUP_RERANK_PROVIDER=cohere", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) (tmp_path / "docker-compose.final.yml").write_text( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " vllm-embed:", " image: vllm/vllm-openai:latest", " vllm-rerank:", " image: vllm/vllm-openai:latest", "volumes:", " vllm_embed_cache:", " vllm_rerank_cache:", ] ) + "\n", encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="JsonKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="NanoVectorDBStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="NetworkXStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="JsonDocStatusStorage" }} collect_database_config() {{ :; }} validate_required_variables() {{ return 0; }} validate_mongo_vector_storage_config() {{ return 0; }} validate_sensitive_env_literals() {{ return 0; }} confirm_default_yes() {{ return 1; }} confirm_default_no() {{ return 1; }} confirm_required_yes_no() {{ return 0; }} env_storage_flow """ ) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert " vllm-embed:" not in result assert " vllm-rerank:" not in result assert "vllm_embed_cache:" not in result assert "vllm_rerank_cache:" not in result assert "LIGHTRAG_RUNTIME_TARGET=compose" in generated_env def test_env_storage_flow_preserves_vllm_services_marked_in_env( tmp_path: Path, ) -> None: """env-storage should restore vLLM services from `.env` markers even without old compose entries.""" write_text_lines( tmp_path / ".env", [ "LIGHTRAG_RUNTIME_TARGET=compose", "LLM_BINDING=openai", "EMBEDDING_BINDING=openai", "EMBEDDING_BINDING_HOST=http://localhost:8001/v1", "LIGHTRAG_SETUP_EMBEDDING_PROVIDER=vllm", "VLLM_EMBED_MODEL=BAAI/bge-m3", "VLLM_EMBED_PORT=8001", "VLLM_EMBED_DEVICE=cpu", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", ], ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="JsonKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="NanoVectorDBStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="NetworkXStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="JsonDocStatusStorage" }} collect_database_config() {{ :; }} validate_required_variables() {{ return 0; }} validate_mongo_vector_storage_config() {{ return 0; }} validate_sensitive_env_literals() {{ return 0; }} confirm_default_yes() {{ return 1; }} confirm_default_no() {{ return 1; }} confirm_required_yes_no() {{ return 0; }} env_storage_flow """ ) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert " vllm-embed:" in result assert "LIGHTRAG_RUNTIME_TARGET=compose" in generated_env def test_env_storage_flow_deletes_compose_when_switching_lightrag_to_host( tmp_path: Path, ) -> None: """env-storage should back up and delete compose when no Docker services remain.""" existing_compose = ( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " redis:", " image: redis:latest", ] ) + "\n" ) write_text_lines( tmp_path / ".env", [ "LIGHTRAG_RUNTIME_TARGET=compose", "LLM_BINDING=openai", "EMBEDDING_BINDING=openai", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) (tmp_path / "docker-compose.final.yml").write_text( existing_compose, encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" select_storage_backends() {{ ENV_VALUES[LIGHTRAG_KV_STORAGE]="JsonKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="NanoVectorDBStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="NetworkXStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="JsonDocStatusStorage" }} collect_database_config() {{ :; }} validate_required_variables() {{ return 0; }} validate_mongo_vector_storage_config() {{ return 0; }} validate_sensitive_env_literals() {{ return 0; }} confirm_default_yes() {{ return 1; }} confirm_default_no() {{ case "$1" in "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 0 ;; *) return 1 ;; esac }} confirm_required_yes_no() {{ return 0; }} env_storage_flow """ ) assert_single_compose_backup(tmp_path, existing_compose) assert not (tmp_path / "docker-compose.final.yml").exists() generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert "LIGHTRAG_RUNTIME_TARGET=host" in generated_env 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:16.6" 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_collect_milvus_config_defaults_to_existing_database_name() -> None: """Milvus database prompt should preserve the documented default database.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state confirm_default_yes() {{ return 1; }} prompt_with_default() {{ printf '%s' "$2" }} prompt_until_valid() {{ printf '%s' "$2" }} collect_milvus_config no printf 'MILVUS_DB_NAME=%s\\n' "${{ENV_VALUES[MILVUS_DB_NAME]}}" """ ) assert values["MILVUS_DB_NAME"] == "lightrag" def test_collect_milvus_config_initializes_minio_credentials_for_local_docker( tmp_path: Path, ) -> None: """Local Docker Milvus should write default MinIO credentials when none exist yet.""" env_file = tmp_path / ".env" env_example = tmp_path / "env.example" env_example.write_text((REPO_ROOT / "env.example").read_text(encoding="utf-8")) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state confirm_default_yes() {{ return 0; }} prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2" }} prompt_until_valid() {{ printf '%s' "$2" }} collect_milvus_config yes generate_env_file "$REPO_ROOT/env.example" "$REPO_ROOT/.env" """, cwd=tmp_path, ) env_text = env_file.read_text(encoding="utf-8") assert "MINIO_ACCESS_KEY_ID=minioadmin" in env_text assert "MINIO_SECRET_ACCESS_KEY=minioadmin" in env_text @pytest.mark.parametrize( ("setup_lines", "nvidia_impl", "expected_device"), [ ( ['ENV_VALUES[MILVUS_DEVICE]="cpu"'], "nvidia-smi() { return 0; }", "cpu", ), ( ['ENV_VALUES[MILVUS_DEVICE]="cuda"'], "nvidia-smi() { return 1; }", "cuda", ), ( [], "nvidia-smi() { return 0; }", "cuda", ), ], ids=["saved-cpu-wins", "saved-cuda-wins", "gpu-host-defaults-to-cuda"], ) def test_collect_milvus_config_resolves_device_default_for_local_docker( setup_lines: list[str], nvidia_impl: str, expected_device: str, ) -> None: """Milvus device defaults should prefer saved state and otherwise use host CUDA detection.""" setup_block = "\n".join(setup_lines) values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state {setup_block} {nvidia_impl} confirm_default_yes() {{ return 0; }} prompt_choice() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_until_valid() {{ printf '%s' "$2"; }} collect_milvus_config yes printf 'MILVUS_DEVICE=%s\\n' "${{ENV_VALUES[MILVUS_DEVICE]}}" """ ) assert values["MILVUS_DEVICE"] == expected_device @pytest.mark.parametrize( ("env_key", "env_value", "expected_value"), [ ("POSTGRES_HOST", "127.0.0.1", "host.docker.internal"), ("REDIS_URI", "redis://localhost:6379", "redis://host.docker.internal:6379"), ( "MONGO_URI", "mongodb://127.0.0.1:27017/", "mongodb://host.docker.internal:27017/", ), ( "MONGO_URI", "mongodb://root:root@localhost:27017/", "mongodb://root:root@host.docker.internal:27017/", ), ("NEO4J_URI", "neo4j://localhost:7687", "neo4j://host.docker.internal:7687"), ("MILVUS_URI", "http://localhost:19530", "http://host.docker.internal:19530"), ("QDRANT_URL", "http://127.0.0.1:6333", "http://host.docker.internal:6333"), ("MEMGRAPH_URI", "bolt://localhost:7687", "bolt://host.docker.internal:7687"), ("POSTGRES_HOST", "0.0.0.0", "host.docker.internal"), ( "LLM_BINDING_HOST", "http://0.0.0.0:11434", "http://host.docker.internal:11434", ), ( "RERANK_BINDING_HOST", "http://0.0.0.0:8000/rerank", "http://host.docker.internal:8000/rerank", ), ], ids=[ "postgres-loopback-host", "redis-loopback-uri", "mongo-loopback-uri", "mongo-authenticated-loopback-uri", "neo4j-loopback-uri", "milvus-loopback-uri", "qdrant-loopback-uri", "memgraph-loopback-uri", "postgres-zero-host", "llm-zero-host", "rerank-zero-host", ], ) def test_prepare_compose_runtime_overrides_rewrites_container_endpoints( env_key: str, env_value: str, expected_value: str ) -> None: """Loopback and 0.0.0.0 endpoints should be rewritten for container reachability.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state ENV_VALUES[{env_key}]="{env_value}" prepare_compose_runtime_overrides printf '{env_key}=%s\\n' "${{COMPOSE_ENV_OVERRIDES[{env_key}]}}" """ ) assert values[env_key] == expected_value def test_collect_mongodb_config_local_service_strips_stale_credentials_on_rerun() -> ( None ): """Bundled MongoDB should keep host `.env` aligned with the unauthenticated template.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state ENV_VALUES[MONGO_URI]="mongodb://root:secret@localhost:27018/" confirm_default_yes() {{ return 0; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_with_default() {{ if [[ "$1" == "MongoDB database" ]]; then printf 'LightRAG' else printf '%s' "$2" fi }} collect_mongodb_config yes printf 'MONGO_URI=%s\\n' "${{ENV_VALUES[MONGO_URI]}}" printf 'COMPOSE_MONGO_URI=%s\\n' "${{COMPOSE_ENV_OVERRIDES[MONGO_URI]}}" printf 'DOCKER_SERVICE=%s\\n' "${{DOCKER_SERVICES[0]}}" """ ) assert values["MONGO_URI"] == "mongodb://localhost:27017/" assert values["COMPOSE_MONGO_URI"] == "mongodb://mongodb:27017/" assert values["DOCKER_SERVICE"] == "mongodb" def test_collect_redis_config_local_service_normalizes_custom_host_port() -> None: """Bundled Redis should keep host `.env` aligned with the published local port.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state ENV_VALUES[REDIS_URI]="redis://localhost:6380/1" confirm_default_yes() {{ return 0; }} prompt_until_valid() {{ printf '%s' "$2"; }} collect_redis_config yes printf 'REDIS_URI=%s\\n' "${{ENV_VALUES[REDIS_URI]}}" printf 'COMPOSE_REDIS_URI=%s\\n' "${{COMPOSE_ENV_OVERRIDES[REDIS_URI]}}" printf 'DOCKER_SERVICE=%s\\n' "${{DOCKER_SERVICES[0]}}" """ ) assert values["REDIS_URI"] == "redis://localhost:6379/1" assert values["COMPOSE_REDIS_URI"] == "redis://redis:6379" assert values["DOCKER_SERVICE"] == "redis" @pytest.mark.parametrize( ("host_value", "expected_port_mapping"), [ ("127.0.0.1", "${HOST:-0.0.0.0}:${PORT:-9621}:9621"), ("192.168.1.10", "${HOST:-0.0.0.0}:${PORT:-9621}:9621"), ], ids=["loopback-bind", "lan-bind"], ) def test_prepare_compose_runtime_overrides_normalizes_server_binding( host_value: str, expected_port_mapping: str ) -> None: """Compose runtime should keep variable-based publishing while fixing container bind values.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state ENV_VALUES[HOST]="{host_value}" ENV_VALUES[PORT]="8080" prepare_compose_runtime_overrides printf 'HOST=%s\\n' "${{COMPOSE_ENV_OVERRIDES[HOST]}}" printf 'PORT=%s\\n' "${{COMPOSE_ENV_OVERRIDES[PORT]}}" printf 'PORT_MAPPING=%s\\n' "${{LIGHTRAG_COMPOSE_SERVER_PORT_MAPPING}}" """ ) assert values["HOST"] == "0.0.0.0" assert values["PORT"] == "9621" assert values["PORT_MAPPING"] == expected_port_mapping 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"') # Override must appear inside lightrag's block, before the postgres service. assert lightrag_pos < override_pos < postgres_pos def test_finalize_server_setup_skips_embedded_milvus_sub_services( tmp_path: Path, ) -> None: """finalize_server_setup must keep prefixed Milvus child services on rerun.""" compose_file = tmp_path / "docker-compose.final.yml" compose_file.write_text( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " milvus:", " image: milvusdb/milvus:v2.6.11", " milvus-etcd:", " image: quay.io/coreos/etcd:v3.5.16", " milvus-minio:", " image: minio/minio:RELEASE.2024-12-13T22-19-12Z", "volumes:", " milvus_data:", " milvus-etcd_data:", " milvus-minio_data:", ] ) + "\n", encoding="utf-8", ) (tmp_path / "env.example").write_text( (REPO_ROOT / "env.example").read_text(encoding="utf-8"), encoding="utf-8", ) write_text_lines( tmp_path / ".env", [ "LIGHTRAG_SETUP_MILVUS_DEPLOYMENT=docker", ], ) # Should complete without error; Milvus child services are managed via the # Milvus template, not as independent root services. run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present collect_server_config() {{ :; }} collect_security_config() {{ :; }} collect_ssl_config() {{ :; }} confirm_required_yes_no() {{ return 0; }} finalize_server_setup """ ) result = compose_file.read_text(encoding="utf-8") # The Milvus template and its prefixed child services must still be present. assert "milvus" in result assert "milvus-etcd" in result assert "milvus-minio" in result assert " milvus:\n condition: service_healthy" in result assert " milvus-etcd:\n condition: service_healthy" not in result assert " milvus-minio:\n condition: service_healthy" not in result def test_finalize_server_setup_uses_compose_native_neo4j_endpoint_on_rerun( tmp_path: Path, ) -> None: """Preserved managed services should inject compose-native endpoints on server reruns.""" write_text_lines( tmp_path / ".env", [ "LIGHTRAG_SETUP_NEO4J_DEPLOYMENT=docker", "NEO4J_URI=neo4j://localhost:7687", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " neo4j:", " image: neo4j:latest", ], ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present show_summary() {{ :; }} confirm_required_yes_no() {{ return 0; }} validate_sensitive_env_literals() {{ return 0; }} validate_security_config() {{ return 0; }} finalize_server_setup """ ) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") assert 'NEO4J_URI: "neo4j://neo4j:7687"' in result assert 'NEO4J_URI: "neo4j://host.docker.internal:7687"' not in result def test_finalize_server_setup_drops_stale_managed_services_missing_from_env_markers( tmp_path: Path, ) -> None: """env-server should remove stale wizard-managed services not marked in `.env`.""" write_text_lines( tmp_path / ".env", [ "HOST=0.0.0.0", "PORT=9621", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " redis:", " image: redis:latest", " vllm-embed:", " image: vllm/vllm-openai:latest", "volumes:", " redis_data:", " vllm_embed_cache:", ], ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present show_summary() {{ :; }} collect_server_config() {{ :; }} collect_security_config() {{ :; }} collect_ssl_config() {{ :; }} confirm_required_yes_no() {{ return 0; }} confirm_default_yes() {{ case "$1" in "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 1 ;; *) return 0 ;; esac }} validate_sensitive_env_literals() {{ return 0; }} validate_security_config() {{ return 0; }} finalize_server_setup """ ) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert " redis:" not in result assert " vllm-embed:" not in result assert "redis_data:" not in result assert "vllm_embed_cache:" not in result assert " lightrag:" in result assert "LIGHTRAG_RUNTIME_TARGET=compose" in generated_env def test_env_server_flow_deletes_compose_when_switching_lightrag_to_host( tmp_path: Path, ) -> None: """env-server should back up and delete compose when no managed or sidecar services remain.""" existing_compose = ( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " redis:", " image: redis:latest", ] ) + "\n" ) write_text_lines( tmp_path / ".env", [ "LIGHTRAG_RUNTIME_TARGET=compose", "HOST=0.0.0.0", "PORT=9621", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) (tmp_path / "docker-compose.final.yml").write_text( existing_compose, encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" collect_server_config() {{ ENV_VALUES[HOST]="0.0.0.0" ENV_VALUES[PORT]="8080" }} collect_security_config() {{ :; }} collect_ssl_config() {{ :; }} validate_sensitive_env_literals() {{ return 0; }} validate_security_config() {{ return 0; }} confirm_default_yes() {{ return 1; }} confirm_default_no() {{ case "$1" in "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 0 ;; *) return 1 ;; esac }} confirm_required_yes_no() {{ return 0; }} env_server_flow """ ) assert_single_compose_backup(tmp_path, existing_compose) assert not (tmp_path / "docker-compose.final.yml").exists() generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert "LIGHTRAG_RUNTIME_TARGET=host" in generated_env def test_env_server_flow_keeps_compose_mode_for_user_sidecars( tmp_path: Path, ) -> None: """env-server should keep LightRAG in Docker when compose still carries user sidecars.""" existing_compose = ( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " sidecar:", " image: busybox", ] ) + "\n" ) write_text_lines( tmp_path / ".env", [ "LIGHTRAG_RUNTIME_TARGET=compose", "HOST=0.0.0.0", "PORT=9621", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) (tmp_path / "docker-compose.final.yml").write_text( existing_compose, encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" collect_server_config() {{ ENV_VALUES[HOST]="0.0.0.0" ENV_VALUES[PORT]="8080" }} collect_security_config() {{ :; }} collect_ssl_config() {{ :; }} validate_sensitive_env_literals() {{ return 0; }} validate_security_config() {{ return 0; }} confirm_default_yes() {{ return 0; }} confirm_required_yes_no() {{ return 0; }} env_server_flow """ ) result = (tmp_path / "docker-compose.final.yml").read_text(encoding="utf-8") generated_env = (tmp_path / ".env").read_text(encoding="utf-8") assert " sidecar:" in result assert " lightrag:" in result assert "LIGHTRAG_RUNTIME_TARGET=compose" in generated_env def test_env_server_flow_rejects_invalid_ssl_cert_when_switching_to_host( tmp_path: Path, ) -> None: """finalize_server_setup should reject a missing SSL cert even when switching to host mode.""" existing_compose = ( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", " redis:", " image: redis:latest", ] ) + "\n" ) write_text_lines( tmp_path / ".env", [ "LIGHTRAG_RUNTIME_TARGET=compose", "HOST=0.0.0.0", "PORT=9621", "SSL=true", "SSL_CERTFILE=/nonexistent/cert.pem", "SSL_KEYFILE=/nonexistent/key.pem", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) (tmp_path / "docker-compose.final.yml").write_text( existing_compose, encoding="utf-8", ) result = run_bash_process( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" collect_server_config() {{ :; }} collect_security_config() {{ :; }} collect_ssl_config() {{ ENV_VALUES[SSL]="true" SSL_CERT_SOURCE_PATH="/nonexistent/cert.pem" SSL_KEY_SOURCE_PATH="/nonexistent/key.pem" }} validate_sensitive_env_literals() {{ return 0; }} validate_security_config() {{ return 0; }} confirm_default_yes() {{ return 1; }} confirm_default_no() {{ case "$1" in "All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?") return 0 ;; *) return 1 ;; esac }} confirm_required_yes_no() {{ return 0; }} env_server_flow """, ) assert result.returncode != 0 assert ( "Invalid SSL_CERTFILE" in result.stderr or "Invalid SSL_CERTFILE" in result.stdout ) # compose and .env must not have been modified assert (tmp_path / "docker-compose.final.yml").exists() assert "LIGHTRAG_RUNTIME_TARGET=compose" in (tmp_path / ".env").read_text( encoding="utf-8" ) def test_detect_managed_root_services_deduplicates_embedded_milvus_children( tmp_path: Path, ) -> None: """Managed service discovery should collapse Milvus child services to the root service.""" write_text_lines( tmp_path / "docker-compose.final.yml", [ "services:", " lightrag:", " image: example/lightrag:test", " milvus:", " image: milvusdb/milvus:v2.6.11", " milvus-etcd:", " image: quay.io/coreos/etcd:v3.5.16", " milvus-minio:", " image: minio/minio:latest", " neo4j:", " image: neo4j:latest", ], ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" detect_managed_root_services "{tmp_path}/docker-compose.final.yml" """ ) assert output.splitlines() == ["milvus", "neo4j"] def test_finalize_server_setup_allows_risky_security_config_and_security_check_reports_it( tmp_path: Path, ) -> None: """Wizard writes `.env` without blocking, while security-check reports risky settings.""" write_text_lines( tmp_path / ".env", [ "AUTH_ACCOUNTS=admin:secret", "TOKEN_SECRET=jwt-secret", "WHITELIST_PATHS=/health,/api/*", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present show_summary() {{ :; }} confirm_default_yes() {{ return 0; }} confirm_required_yes_no() {{ return 0; }} if finalize_server_setup; then printf 'RESULT=success\\n' else printf 'RESULT=failure\\n' fi """ ) values = parse_lines(output) assert values["RESULT"] == "success" result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" security_check_env_file """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) assert result.returncode == 1 assert "WHITELIST_PATHS exposes /api routes" in result.stdout def test_finalize_server_setup_allows_predictable_auth_passwords_and_security_check_reports_it( tmp_path: Path, ) -> None: """Server setup should not block on weak password prefixes that belong to security audit.""" write_text_lines( tmp_path / ".env", [ "AUTH_ACCOUNTS=admin:Passw0rd!", "TOKEN_SECRET=jwt-secret", "WHITELIST_PATHS=/health", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present show_summary() {{ :; }} confirm_default_yes() {{ return 0; }} confirm_required_yes_no() {{ return 0; }} if finalize_server_setup; then printf 'RESULT=success\\n' else printf 'RESULT=failure\\n' fi """, ) values = parse_lines(output) assert values["RESULT"] == "success" result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" security_check_env_file """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) assert result.returncode == 1 assert "AUTH_ACCOUNTS uses a predictable password prefix." in result.stdout def test_finalize_server_setup_rejects_malformed_auth_accounts(tmp_path: Path) -> None: """Server setup should fail fast instead of persisting invalid AUTH_ACCOUNTS syntax.""" write_text_lines(tmp_path / ".env", ["HOST=0.0.0.0"]) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present collect_server_config() {{ :; }} collect_ssl_config() {{ :; }} ENV_VALUES[AUTH_ACCOUNTS]="admin" ENV_VALUES[TOKEN_SECRET]="jwt-secret" show_summary() {{ :; }} confirm_default_yes() {{ return 0; }} confirm_required_yes_no() {{ return 0; }} if finalize_server_setup; then printf 'RESULT=success\\n' else printf 'RESULT=failure\\n' fi printf 'ENV=%s\\n' "$(cat "$REPO_ROOT/.env")" """, cwd=tmp_path, ) values = parse_lines(output) assert values["RESULT"] == "failure" assert values["ENV"] == "HOST=0.0.0.0" def test_validate_env_file_allows_predictable_auth_passwords_and_leaves_them_to_audit( tmp_path: Path, ) -> None: """validate_env_file should allow risky-but-runnable auth settings.""" write_text_lines( tmp_path / ".env", [ "AUTH_ACCOUNTS=admin:Passw0rd!", "TOKEN_SECRET=jwt-secret", "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", ], ) write_text_lines(tmp_path / "env.example", ["LLM_BINDING=openai"]) result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == "yes" audit_result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" security_check_env_file """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) assert audit_result.returncode == 1 assert "AUTH_ACCOUNTS uses a predictable password prefix." in audit_result.stdout def test_validate_uri_accepts_neo4j_self_signed_tls_scheme() -> None: """Neo4j self-signed TLS URIs should pass validation.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" if validate_uri "neo4j+ssc://db.example.com:7687" neo4j; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """ ) values = parse_lines(output) assert values["VALID"] == "yes" def test_ssl_staging_uses_distinct_names_for_same_basename_inputs( tmp_path: Path, ) -> None: """Cert/key files with the same basename should stage to distinct paths.""" env_example = tmp_path / "env.example" env_example.write_text( "\n".join( [ "SSL_CERTFILE=/placeholder/cert.pem", "SSL_KEYFILE=/placeholder/key.pem", ] ) + "\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", ) cert_dir = tmp_path / "certs" key_dir = tmp_path / "keys" cert_dir.mkdir() key_dir.mkdir() cert_path = cert_dir / "server.pem" cert_path.write_text("cert", encoding="utf-8") key_path = key_dir / "server.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}" 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_docker_compose "$REPO_ROOT/docker-compose.generated.yml" """ ) generated_compose = (tmp_path / "docker-compose.generated.yml").read_text( encoding="utf-8" ) staged_cert = tmp_path / "data" / "certs" / "cert-server.pem" staged_key = tmp_path / "data" / "certs" / "key-server.pem" assert staged_cert.read_text(encoding="utf-8") == "cert" assert staged_key.read_text(encoding="utf-8") == "key" assert 'SSL_CERTFILE: "/app/data/certs/cert-server.pem"' in generated_compose assert 'SSL_KEYFILE: "/app/data/certs/key-server.pem"' in generated_compose assert ( "./data/certs/cert-server.pem:/app/data/certs/cert-server.pem:ro" in generated_compose ) assert ( "./data/certs/key-server.pem:/app/data/certs/key-server.pem:ro" in generated_compose ) def test_ssl_staging_skips_copy_for_already_staged_relative_paths( tmp_path: Path, ) -> None: """Re-running setup with already-staged certs should not fail on identical copies.""" staged_dir = tmp_path / "data" / "certs" staged_dir.mkdir(parents=True) cert_path = staged_dir / "server.pem" key_path = staged_dir / "server.key" cert_path.write_text("cert", encoding="utf-8") key_path.write_text("key", encoding="utf-8") run_bash( f""" set -euo pipefail cd "{tmp_path}" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state stage_ssl_assets "./data/certs/server.pem" "./data/certs/server.key" """ ) assert cert_path.read_text(encoding="utf-8") == "cert" assert key_path.read_text(encoding="utf-8") == "key" @pytest.mark.parametrize( ("name", "env_lines", "setup_snippet", "finalize_call"), [ ( "base", [], "\n".join( [ 'ENV_VALUES[VLLM_EMBED_DEVICE]="cpu"', 'ENV_VALUES[VLLM_EMBED_MODEL]="BAAI/bge-m3"', 'ENV_VALUES[VLLM_EMBED_PORT]="8001"', 'ENV_VALUES[VLLM_EMBED_API_KEY]="local-key"', 'add_docker_service "vllm-embed"', "confirm_default_no() { return 1; }", ] ), "finalize_base_setup", ), ( "storage", [ "LIGHTRAG_KV_STORAGE=PGKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage", "POSTGRES_USER=lightrag", "POSTGRES_PASSWORD=secret", "POSTGRES_DATABASE=lightrag", ], 'add_docker_service "postgres"', "finalize_storage_setup", ), ], ids=["base", "storage"], ) def test_finalize_flows_stage_inherited_ssl_assets_for_compose( tmp_path: Path, name: str, env_lines: list[str], setup_snippet: str, finalize_call: str, ) -> None: """Compose-writing finalize flows should stage inherited SSL assets before mounting them.""" cert_path = tmp_path / f"{name}-source-cert.pem" key_path = tmp_path / f"{name}-source-key.pem" cert_path.write_text("cert", encoding="utf-8") key_path.write_text("key", encoding="utf-8") write_text_lines( tmp_path / ".env", [ *env_lines, "SSL=true", f"SSL_CERTFILE={cert_path}", f"SSL_KEYFILE={key_path}", ], ) write_text_lines( tmp_path / "env.example", (REPO_ROOT / "env.example").read_text(encoding="utf-8").splitlines(), ) (tmp_path / "docker-compose.yml").write_text( (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8"), encoding="utf-8", ) run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present {setup_snippet} show_summary() {{ :; }} confirm_default_yes() {{ return 0; }} confirm_required_yes_no() {{ return 0; }} {finalize_call} """ ) generated_compose = (tmp_path / "docker-compose.final.yml").read_text( encoding="utf-8" ) staged_cert = tmp_path / "data" / "certs" / f"{name}-source-cert.pem" staged_key = tmp_path / "data" / "certs" / f"{name}-source-key.pem" assert staged_cert.read_text(encoding="utf-8") == "cert" assert staged_key.read_text(encoding="utf-8") == "key" assert ( f"./data/certs/{name}-source-cert.pem:/app/data/certs/{name}-source-cert.pem:ro" in generated_compose ) assert ( f"./data/certs/{name}-source-key.pem:/app/data/certs/{name}-source-key.pem:ro" in generated_compose ) 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:\n 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_collect_security_config_can_clear_existing_values_on_rerun( tmp_path: Path, ) -> None: """Rerunning security setup should be able to remove previously saved values.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "AUTH_ACCOUNTS=admin:secret", "TOKEN_SECRET=jwt-secret", "TOKEN_EXPIRE_HOURS=72", "LIGHTRAG_API_KEY=api-key", "WHITELIST_PATHS=/health,/api/*,/docs", ] ) + "\n", encoding="utf-8", ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present confirm_default_no() {{ return 0; }} prompt_clearable_with_default() {{ printf '%s' "$CLEAR_INPUT_SENTINEL"; }} prompt_clearable_secret_with_default() {{ printf '%s' "$CLEAR_INPUT_SENTINEL"; }} collect_security_config yes no generate_env_file "{REPO_ROOT}/env.example" "$REPO_ROOT/.env.generated" printf 'AUTH_ACCOUNTS_SET=%s\\n' "${{ENV_VALUES[AUTH_ACCOUNTS]+set}}" printf 'TOKEN_SECRET_SET=%s\\n' "${{ENV_VALUES[TOKEN_SECRET]+set}}" printf 'TOKEN_EXPIRE_HOURS_SET=%s\\n' "${{ENV_VALUES[TOKEN_EXPIRE_HOURS]+set}}" printf 'LIGHTRAG_API_KEY_SET=%s\\n' "${{ENV_VALUES[LIGHTRAG_API_KEY]+set}}" printf 'WHITELIST_PATHS_SET=%s\\n' "${{ENV_VALUES[WHITELIST_PATHS]+set}}" """ ) values = parse_lines(output) generated_lines = ( (tmp_path / ".env.generated").read_text(encoding="utf-8").splitlines() ) assert values["AUTH_ACCOUNTS_SET"] == "" assert values["TOKEN_SECRET_SET"] == "" assert values["TOKEN_EXPIRE_HOURS_SET"] == "" assert values["LIGHTRAG_API_KEY_SET"] == "" assert values["WHITELIST_PATHS_SET"] == "set" assert not any(line.startswith("AUTH_ACCOUNTS=") for line in generated_lines) assert not any(line.startswith("TOKEN_SECRET=") for line in generated_lines) assert not any(line.startswith("TOKEN_EXPIRE_HOURS=") for line in generated_lines) assert not any(line.startswith("LIGHTRAG_API_KEY=") for line in generated_lines) assert "WHITELIST_PATHS=" in generated_lines def test_collect_security_config_preserves_explicit_empty_whitelist_on_rerun( tmp_path: Path, ) -> None: """Rerunning security setup should keep an explicitly empty whitelist unchanged.""" env_file = tmp_path / ".env" env_file.write_text("WHITELIST_PATHS=\n", encoding="utf-8") output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present prompt_clearable_with_default() {{ printf '%s' "$2"; }} prompt_clearable_secret_with_default() {{ printf '%s' "$2"; }} collect_security_config no no printf 'WHITELIST_PATHS_SET=%s\\n' "${{ENV_VALUES[WHITELIST_PATHS]+set}}" printf 'WHITELIST_PATHS=%s\\n' "${{ENV_VALUES[WHITELIST_PATHS]}}" """ ) values = parse_lines(output) assert values["WHITELIST_PATHS_SET"] == "set" assert values["WHITELIST_PATHS"] == "" def test_collect_observability_config_clears_existing_values_on_rerun( tmp_path: Path, ) -> None: """Rerunning setup should remove saved Langfuse settings when observability is declined.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "LANGFUSE_ENABLE_TRACE=true", "LANGFUSE_SECRET_KEY=old-secret", "LANGFUSE_PUBLIC_KEY=old-public", "LANGFUSE_HOST=https://langfuse.example", ] ) + "\n", encoding="utf-8", ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state load_existing_env_if_present collect_observability_config generate_env_file "{REPO_ROOT}/env.example" "$REPO_ROOT/.env.generated" printf 'LANGFUSE_ENABLE_TRACE_SET=%s\\n' "${{ENV_VALUES[LANGFUSE_ENABLE_TRACE]+set}}" printf 'LANGFUSE_SECRET_KEY_SET=%s\\n' "${{ENV_VALUES[LANGFUSE_SECRET_KEY]+set}}" printf 'LANGFUSE_PUBLIC_KEY_SET=%s\\n' "${{ENV_VALUES[LANGFUSE_PUBLIC_KEY]+set}}" printf 'LANGFUSE_HOST_SET=%s\\n' "${{ENV_VALUES[LANGFUSE_HOST]+set}}" """ ) values = parse_lines(output) generated_lines = ( (tmp_path / ".env.generated").read_text(encoding="utf-8").splitlines() ) assert values["LANGFUSE_ENABLE_TRACE_SET"] == "" assert values["LANGFUSE_SECRET_KEY_SET"] == "" assert values["LANGFUSE_PUBLIC_KEY_SET"] == "" assert values["LANGFUSE_HOST_SET"] == "" assert not any( line.startswith("LANGFUSE_ENABLE_TRACE=") for line in generated_lines ) assert not any(line.startswith("LANGFUSE_SECRET_KEY=") for line in generated_lines) assert not any(line.startswith("LANGFUSE_PUBLIC_KEY=") for line in generated_lines) assert not any(line.startswith("LANGFUSE_HOST=") for line in generated_lines) def test_collect_neo4j_config_bundled_service_keeps_username_editable( tmp_path: Path, ) -> None: """Bundled Neo4j should preserve editable credentials and existing database overrides.""" 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", ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state ENV_VALUES[NEO4J_USERNAME]="custom-user" ENV_VALUES[NEO4J_PASSWORD]="existing-password" ENV_VALUES[NEO4J_DATABASE]="custom-db" confirm_default_yes() {{ return 0; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_log_file="$(mktemp)" trap 'rm -f "$prompt_log_file"' EXIT prompt_with_default() {{ printf '%s\\n' "$1" >> "$prompt_log_file" if [[ "$1" == "Neo4j database" ]]; then printf 'custom-db-2' else printf '%s' "$2" fi }} prompt_secret_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} collect_neo4j_config yes generate_docker_compose "$REPO_ROOT/docker-compose.generated.yml" printf 'NEO4J_USERNAME=%s\\n' "${{ENV_VALUES[NEO4J_USERNAME]}}" printf 'NEO4J_PASSWORD=%s\\n' "${{ENV_VALUES[NEO4J_PASSWORD]}}" printf 'NEO4J_DATABASE=%s\\n' "${{ENV_VALUES[NEO4J_DATABASE]}}" printf 'DOCKER_SERVICE=%s\\n' "${{DOCKER_SERVICES[0]}}" printf 'DATABASE_PROMPTS=%s\\n' "$(grep -c '^Neo4j database$' "$prompt_log_file" || true)" """ ) values = parse_lines(output) generated_compose = (tmp_path / "docker-compose.generated.yml").read_text( encoding="utf-8" ) assert values["NEO4J_USERNAME"] == "custom-user" assert values["NEO4J_PASSWORD"] == "existing-password" assert values["NEO4J_DATABASE"] == "custom-db-2" assert values["DOCKER_SERVICE"] == "neo4j" assert values["DATABASE_PROMPTS"] == "1" assert ( "NEO4J_AUTH: ${NEO4J_USERNAME:?missing}/${NEO4J_PASSWORD:?missing}" in generated_compose ) assert 'NEO4J_dbms_default__database: "custom-db-2"' in generated_compose def test_collect_neo4j_config_bundled_service_defaults_database_when_unset() -> None: """Bundled Neo4j should pin the community default database when no prior value exists.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state prompt_log_file="$(mktemp)" trap 'rm -f "$prompt_log_file"' EXIT confirm_default_yes() {{ return 0; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s\\n' "$1" >> "$prompt_log_file" printf '%s' "$2" }} prompt_secret_until_valid_with_default() {{ printf 'secure-password'; }} collect_neo4j_config yes printf 'DATABASE=%s\\n' "${{ENV_VALUES[NEO4J_DATABASE]}}" printf 'DATABASE_PROMPTS=%s\\n' "$(grep -c '^Neo4j database$' "$prompt_log_file" || true)" """ ) values = parse_lines(output) assert values["DATABASE"] == "neo4j" assert values["DATABASE_PROMPTS"] == "0" def test_collect_neo4j_config_uses_existing_password_as_default_in_docker_mode() -> ( None ): """Bundled Neo4j should preserve the existing password when the default is accepted.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state ENV_VALUES[NEO4J_PASSWORD]="from-env-password" confirm_default_yes() {{ return 0; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} collect_neo4j_config yes printf 'PASSWORD=%s\\n' "${{ENV_VALUES[NEO4J_PASSWORD]}}" """ ) values = parse_lines(output) assert values["PASSWORD"] == "from-env-password" def test_collect_neo4j_config_uses_existing_password_as_default_in_external_mode() -> ( None ): """External Neo4j should preserve the existing password when the default is accepted.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state ENV_VALUES[NEO4J_PASSWORD]="from-env-password" confirm_default_no() {{ return 1; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_with_default() {{ printf '%s' "$2"; }} collect_neo4j_config no printf 'PASSWORD=%s\\n' "${{ENV_VALUES[NEO4J_PASSWORD]}}" """ ) values = parse_lines(output) assert values["PASSWORD"] == "from-env-password" def test_collect_neo4j_config_bundled_service_reprompts_for_empty_credentials() -> None: """Bundled Neo4j should reject empty username and password values.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state prompt_log_file="$(mktemp)" trap 'rm -f "$prompt_log_file"' EXIT confirm_default_yes() {{ return 0; }} prompt_until_valid() {{ local prompt="$1" local default="$2" local validator="$3" shift 3 local value="" while true; do if [[ "$prompt" == "Neo4j URI" ]]; then value="$default" else printf 'username\\n' >> "$prompt_log_file" if [[ "$(grep -c '^username$' "$prompt_log_file")" -eq 1 ]]; then value="" else value="neo4j-user" fi fi if "$validator" "$value" "$@"; then printf '%s' "$value" return 0 fi done }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ local prompt="$1" local default="$2" local validator="$3" shift 3 local value="" while true; do printf 'password\\n' >> "$prompt_log_file" if [[ "$(grep -c '^password$' "$prompt_log_file")" -eq 1 ]]; then value="" else value="secure-password" fi if "$validator" "$value" "$@"; then printf '%s' "$value" return 0 fi done }} collect_neo4j_config yes printf 'USERNAME=%s\\n' "${{ENV_VALUES[NEO4J_USERNAME]}}" printf 'PASSWORD=%s\\n' "${{ENV_VALUES[NEO4J_PASSWORD]}}" printf 'USERNAME_CALLS=%s\\n' "$(grep -c '^username$' "$prompt_log_file")" printf 'PASSWORD_CALLS=%s\\n' "$(grep -c '^password$' "$prompt_log_file")" """ ) values = parse_lines(output) assert values["USERNAME"] == "neo4j-user" assert values["PASSWORD"] == "secure-password" assert values["USERNAME_CALLS"] == "2" assert values["PASSWORD_CALLS"] == "2" def test_collect_neo4j_config_external_service_still_uses_standard_prompts() -> None: """External Neo4j setup should keep the non-Docker prompt behavior unchanged.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state prompt_log_file="$(mktemp)" trap 'rm -f "$prompt_log_file"' EXIT confirm_default_no() {{ return 1; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf 'with_default\\n' >> "$prompt_log_file" if [[ "$1" == "Neo4j username" ]]; then printf 'external-user' elif [[ "$1" == "Neo4j database" ]]; then printf 'external-db' else printf '%s' "$2" fi }} prompt_secret_with_default() {{ printf 'secret_with_default\\n' >> "$prompt_log_file" printf 'external-password' }} collect_neo4j_config no printf 'USERNAME=%s\\n' "${{ENV_VALUES[NEO4J_USERNAME]}}" printf 'PASSWORD=%s\\n' "${{ENV_VALUES[NEO4J_PASSWORD]}}" printf 'DATABASE=%s\\n' "${{ENV_VALUES[NEO4J_DATABASE]}}" printf 'USERNAME_PROMPTS=%s\\n' "$(grep -c '^with_default$' "$prompt_log_file")" printf 'PASSWORD_PROMPTS=%s\\n' "$(grep -c '^secret_with_default$' "$prompt_log_file")" """ ) values = parse_lines(output) assert values["USERNAME"] == "external-user" assert values["PASSWORD"] == "external-password" assert values["DATABASE"] == "external-db" assert values["USERNAME_PROMPTS"] == "2" assert values["PASSWORD_PROMPTS"] == "1" def test_validate_security_config_rejects_malformed_auth_accounts() -> None: """Security validation should reject auth entries the API cannot parse.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state if validate_security_config "admin" "token-secret" "" no "/health"; then printf 'MISSING_COLON=yes\\n' else printf 'MISSING_COLON=no\\n' fi if validate_security_config "admin:secret," "token-secret" "" no "/health"; then printf 'TRAILING_COMMA=yes\\n' else printf 'TRAILING_COMMA=no\\n' fi if validate_security_config "admin:secret,reader:hunter2" "token-secret" "" no "/health"; then printf 'VALID_FORMAT=yes\\n' else printf 'VALID_FORMAT=no\\n' fi if validate_security_config 'admin:{{bcrypt}}$2b$12$abcdefghijklmnopqrstuuuuuuuuuuuuuuuuuuuuuuuuuuuu' "token-secret" "" no "/health"; then printf 'BCRYPT_FORMAT=yes\\n' else printf 'BCRYPT_FORMAT=no\\n' fi if validate_security_config "admin:admin123!" "token-secret" "" no "/health"; then printf 'ADMIN_PREFIX=yes\\n' else printf 'ADMIN_PREFIX=no\\n' fi if validate_security_config "admin:Passw0rd!" "token-secret" "" no "/health"; then printf 'PASS_PREFIX=yes\\n' else printf 'PASS_PREFIX=no\\n' fi """ ) values = parse_lines(output) assert values["MISSING_COLON"] == "no" assert values["TRAILING_COMMA"] == "no" assert values["VALID_FORMAT"] == "yes" assert values["BCRYPT_FORMAT"] == "yes" assert values["ADMIN_PREFIX"] == "no" assert values["PASS_PREFIX"] == "no" def test_security_check_reports_missing_authentication(tmp_path: Path) -> None: """Security audit should flag unauthenticated API exposure.""" write_text_lines(tmp_path / ".env", ["HOST=0.0.0.0"]) result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" security_check_env_file """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) assert result.returncode == 1 assert "No API protection is configured." in result.stdout def test_security_check_passes_for_authenticated_minimal_config(tmp_path: Path) -> None: """Security audit should pass for a minimally hardened config.""" write_text_lines( tmp_path / ".env", [ "AUTH_ACCOUNTS=admin:secret", "TOKEN_SECRET=jwt-secret", "WHITELIST_PATHS=/health", ], ) result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" security_check_env_file """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) assert result.returncode == 0 def test_security_check_reports_predictable_auth_password_prefix( tmp_path: Path, ) -> None: """Security audit should flag AUTH_ACCOUNTS passwords with predictable prefixes.""" write_text_lines( tmp_path / ".env", [ "AUTH_ACCOUNTS=admin:admin123!", "TOKEN_SECRET=jwt-secret", "WHITELIST_PATHS=/health", ], ) result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" security_check_env_file """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) assert result.returncode == 1 assert "AUTH_ACCOUNTS uses a predictable password prefix." in result.stdout def test_security_check_reports_api_key_only_with_default_whitelist( tmp_path: Path, ) -> None: """API-key-only deployment with unset WHITELIST_PATHS inherits /api/* and must be flagged.""" write_text_lines(tmp_path / ".env", ["LIGHTRAG_API_KEY=my-secret-key"]) result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" security_check_env_file """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) assert result.returncode == 1 assert "WHITELIST_PATHS exposes /api routes" in result.stdout def test_security_check_reports_api_key_only_with_explicit_api_wildcard_whitelist( tmp_path: Path, ) -> None: """API-key-only deployment with WHITELIST_PATHS=/health,/api/* must be flagged.""" write_text_lines( tmp_path / ".env", ["LIGHTRAG_API_KEY=my-secret-key", "WHITELIST_PATHS=/health,/api/*"], ) result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" security_check_env_file """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) assert result.returncode == 1 assert "WHITELIST_PATHS exposes /api routes" in result.stdout def test_security_check_passes_for_api_key_only_with_safe_whitelist( tmp_path: Path, ) -> None: """API-key-only deployment with a safe WHITELIST_PATHS should pass the security check.""" write_text_lines( tmp_path / ".env", ["LIGHTRAG_API_KEY=my-secret-key", "WHITELIST_PATHS=/health"], ) result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" security_check_env_file """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) assert result.returncode == 0 assert "No obvious security issues found" in result.stdout def test_security_check_ignores_default_opensearch_password_when_opensearch_unused( tmp_path: Path, ) -> None: """Security audit should ignore OpenSearch defaults when no OpenSearch storage is selected.""" write_text_lines( tmp_path / ".env", [ "AUTH_ACCOUNTS=admin:secret", "TOKEN_SECRET=jwt-secret", "WHITELIST_PATHS=/health", "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", "OPENSEARCH_PASSWORD=LightRAG2026_!@", ], ) result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" security_check_env_file """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) assert result.returncode == 0 assert "OPENSEARCH_PASSWORD uses a well-known default value." not in result.stdout def test_security_check_reports_default_opensearch_password_when_opensearch_selected( tmp_path: Path, ) -> None: """Security audit should flag the default OpenSearch password when OpenSearch is selected.""" write_text_lines( tmp_path / ".env", [ "AUTH_ACCOUNTS=admin:secret", "TOKEN_SECRET=jwt-secret", "WHITELIST_PATHS=/health", "LIGHTRAG_KV_STORAGE=OpenSearchKVStorage", "LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage", "LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage", "OPENSEARCH_HOSTS=localhost:9200", "OPENSEARCH_USER=admin", "OPENSEARCH_PASSWORD=LightRAG2026_!@", ], ) result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" security_check_env_file """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) assert result.returncode == 1 assert "OPENSEARCH_PASSWORD uses a well-known default value." in result.stdout def test_show_summary_masks_auth_accounts() -> None: """Configuration summaries should not print auth account passwords.""" output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state ENV_VALUES[AUTH_ACCOUNTS]="admin:secret,reader:hunter2" ENV_VALUES[TOKEN_SECRET]="jwt-secret" ENV_VALUES[HOST]="0.0.0.0" show_summary """ ) assert "AUTH_ACCOUNTS=***" in output assert "TOKEN_SECRET=***" in output assert "admin:secret" not in output assert "reader:hunter2" not in output def test_validate_env_file_handles_supported_and_unsupported_uri_schemes( tmp_path: Path, ) -> None: """validate_env_file should reject malformed schemes and allow supported TLS variants.""" cases = { "invalid-neo4j-scheme": ( [ "LIGHTRAG_GRAPH_STORAGE=Neo4JStorage", "NEO4J_URI=http://localhost:7687", "NEO4J_USERNAME=neo4j", "NEO4J_PASSWORD=secret", ], "no", "Invalid NEO4J_URI", ), "invalid-redis-scheme": ( [ "LIGHTRAG_KV_STORAGE=RedisKVStorage", "REDIS_URI=tcp://localhost:6379", ], "no", "Invalid REDIS_URI", ), "valid-rediss-scheme": ( [ "LIGHTRAG_KV_STORAGE=RedisKVStorage", "REDIS_URI=rediss://localhost:6380", ], "yes", "", ), } for case_name, (extra_lines, expected_valid, expected_stderr) in cases.items(): case_dir = tmp_path / case_name case_dir.mkdir() write_text_lines( case_dir / ".env", [ "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", *extra_lines, ], ) write_text_lines(case_dir / "env.example", ["LLM_BINDING=openai"]) result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{case_dir}" reset_state if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == expected_valid if expected_stderr: assert expected_stderr in result.stderr def test_validate_env_file_rejects_invalid_runtime_target(tmp_path: Path) -> None: """validate_env_file should reject unsupported LIGHTRAG_RUNTIME_TARGET values.""" write_text_lines( tmp_path / ".env", [ "LIGHTRAG_RUNTIME_TARGET=laptop", "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", ], ) write_text_lines(tmp_path / "env.example", ["LLM_BINDING=openai"]) result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == "no" assert "Invalid LIGHTRAG_RUNTIME_TARGET" in result.stderr def test_validate_required_variables_requires_opensearch_basic_auth() -> None: """OpenSearch storages should require both OPENSEARCH_USER and OPENSEARCH_PASSWORD.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state ENV_VALUES[LIGHTRAG_KV_STORAGE]="OpenSearchKVStorage" ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]="OpenSearchVectorDBStorage" ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]="OpenSearchGraphStorage" ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]="OpenSearchDocStatusStorage" ENV_VALUES[OPENSEARCH_HOSTS]="localhost:9200" if validate_required_variables \ "${{ENV_VALUES[LIGHTRAG_KV_STORAGE]}}" \ "${{ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]}}" \ "${{ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]}}" \ "${{ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]}}"; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """ ) assert values["VALID"] == "no" def test_collect_opensearch_config_preserves_graphlookup_auto_detection() -> None: """collect_opensearch_config should leave PPL graphlookup unset unless explicitly configured.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state confirm_default_yes() {{ return 0; }} confirm_default_no() {{ return 1; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} collect_opensearch_config "yes" if [[ -v 'ENV_VALUES[OPENSEARCH_USE_PPL_GRAPHLOOKUP]' ]]; then printf 'GRAPHLOOKUP_SET=yes\\n' else printf 'GRAPHLOOKUP_SET=no\\n' fi """ ) assert values["GRAPHLOOKUP_SET"] == "no" def test_collect_opensearch_config_preserves_explicit_graphlookup_override() -> None: """collect_opensearch_config should keep an existing PPL graphlookup override.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state ENV_VALUES[OPENSEARCH_USE_PPL_GRAPHLOOKUP]="true" confirm_default_yes() {{ return 0; }} confirm_default_no() {{ return 1; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} collect_opensearch_config "yes" printf 'GRAPHLOOKUP=%s\\n' "${{ENV_VALUES[OPENSEARCH_USE_PPL_GRAPHLOOKUP]}}" """ ) assert values["GRAPHLOOKUP"] == "true" def test_collect_opensearch_config_forces_docker_verify_certs_false() -> None: """collect_opensearch_config should force OPENSEARCH_VERIFY_CERTS=false for Docker.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state ENV_VALUES[OPENSEARCH_USE_SSL]="false" ENV_VALUES[OPENSEARCH_VERIFY_CERTS]="true" confirm_default_yes() {{ return 0; }} confirm_default_no() {{ return 1; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} collect_opensearch_config "yes" printf 'USE_SSL=%s\\n' "${{ENV_VALUES[OPENSEARCH_USE_SSL]}}" printf 'VERIFY_CERTS=%s\\n' "${{ENV_VALUES[OPENSEARCH_VERIFY_CERTS]}}" """ ) assert values["USE_SSL"] == "false" assert values["VERIFY_CERTS"] == "false" def test_collect_opensearch_config_defaults_docker_tls_flags_when_unset() -> None: """collect_opensearch_config should supply Docker TLS defaults when .env has no values.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state confirm_default_yes() {{ return 0; }} confirm_default_no() {{ return 1; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} collect_opensearch_config "yes" printf 'USE_SSL=%s\\n' "${{ENV_VALUES[OPENSEARCH_USE_SSL]}}" printf 'VERIFY_CERTS=%s\\n' "${{ENV_VALUES[OPENSEARCH_VERIFY_CERTS]}}" """ ) assert values["USE_SSL"] == "true" assert values["VERIFY_CERTS"] == "false" def test_collect_opensearch_config_uses_original_index_settings_as_defaults() -> None: """collect_opensearch_config should prefer ORIGINAL_ENV_VALUES for shard/replica defaults.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state default_log="$(mktemp)" ORIGINAL_ENV_VALUES[OPENSEARCH_NUMBER_OF_SHARDS]="3" ORIGINAL_ENV_VALUES[OPENSEARCH_NUMBER_OF_REPLICAS]="2" ENV_VALUES[OPENSEARCH_NUMBER_OF_SHARDS]="9" ENV_VALUES[OPENSEARCH_NUMBER_OF_REPLICAS]="8" confirm_default_yes() {{ return 1; }} confirm_default_no() {{ return 1; }} prompt_until_valid() {{ case "$1" in "Number of index shards"|"Number of index replicas (use 2 for 3-AZ clusters)") printf '%s=%s\\n' "$1" "$2" >> "$default_log" ;; esac printf '%s' "$2" }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} collect_opensearch_config "no" printf 'SHARDS=%s\\n' "${{ENV_VALUES[OPENSEARCH_NUMBER_OF_SHARDS]}}" printf 'REPLICAS=%s\\n' "${{ENV_VALUES[OPENSEARCH_NUMBER_OF_REPLICAS]}}" printf 'DEFAULTS=%s\\n' "$(tr '\\n' ';' < "$default_log")" """ ) assert values["SHARDS"] == "3" assert values["REPLICAS"] == "2" assert "Number of index shards=3;" in values["DEFAULTS"] assert "Number of index replicas (use 2 for 3-AZ clusters)=2;" in values["DEFAULTS"] def test_collect_opensearch_config_validates_index_settings_during_prompt() -> None: """collect_opensearch_config should validate shard and replica prompts.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state validator_file="$(mktemp)" confirm_default_yes() {{ return 1; }} confirm_default_no() {{ return 1; }} prompt_until_valid() {{ case "$1" in "Number of index shards"|"Number of index replicas (use 2 for 3-AZ clusters)") printf '%s=%s\\n' "$1" "$3" >> "$validator_file" ;; esac printf '%s' "$2" }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} collect_opensearch_config "no" printf 'VALIDATORS=%s\\n' "$(tr '\\n' ';' < "$validator_file")" """ ) assert "Number of index shards=validate_positive_integer;" in values["VALIDATORS"] assert ( "Number of index replicas (use 2 for 3-AZ clusters)=validate_non_negative_integer;" in values["VALIDATORS"] ) def test_collect_opensearch_config_validates_hosts_during_prompt() -> None: """collect_opensearch_config should validate OPENSEARCH_HOSTS at prompt time.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state validator_file="$(mktemp)" confirm_default_yes() {{ return 0; }} confirm_default_no() {{ return 1; }} prompt_until_valid() {{ printf '%s' "$3" > "$validator_file" printf '%s' "$2" }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$2"; }} collect_opensearch_config "yes" printf 'HOST_VALIDATOR=%s\\n' "$(cat "$validator_file")" """ ) assert values["HOST_VALIDATOR"] == "validate_opensearch_hosts_format" def test_collect_opensearch_config_validates_password_during_prompt() -> None: """collect_opensearch_config should validate OPENSEARCH_PASSWORD at prompt time.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" reset_state validator_file="$(mktemp)" confirm_default_yes() {{ return 0; }} confirm_default_no() {{ return 1; }} prompt_until_valid() {{ printf '%s' "$2"; }} prompt_with_default() {{ printf '%s' "$2"; }} prompt_secret_until_valid_with_default() {{ printf '%s' "$3" > "$validator_file" printf '%s' "$2" }} collect_opensearch_config "yes" printf 'PASSWORD_VALIDATOR=%s\\n' "$(cat "$validator_file")" """ ) assert values["PASSWORD_VALIDATOR"] == "validate_opensearch_password_strength" def test_validate_env_file_rejects_invalid_opensearch_index_settings( tmp_path: Path, ) -> None: """validate_env_file should reject invalid OpenSearch shard and replica counts.""" write_text_lines( tmp_path / ".env", [ "LIGHTRAG_KV_STORAGE=OpenSearchKVStorage", "LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage", "LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage", "OPENSEARCH_HOSTS=localhost:9200", "OPENSEARCH_USER=admin", "OPENSEARCH_PASSWORD=StrongPass1!", "OPENSEARCH_NUMBER_OF_SHARDS=abc", "OPENSEARCH_NUMBER_OF_REPLICAS=-1", ], ) write_text_lines(tmp_path / "env.example", ["LLM_BINDING=openai"]) result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == "no" assert "OPENSEARCH_NUMBER_OF_SHARDS must be a positive integer." in result.stderr def test_validate_env_file_rejects_blank_opensearch_index_settings( tmp_path: Path, ) -> None: """validate_env_file should reject blank OpenSearch shard and replica counts.""" write_text_lines( tmp_path / ".env", [ "LIGHTRAG_KV_STORAGE=OpenSearchKVStorage", "LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage", "LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage", "OPENSEARCH_HOSTS=localhost:9200", "OPENSEARCH_USER=admin", "OPENSEARCH_PASSWORD=StrongPass1!", "OPENSEARCH_NUMBER_OF_SHARDS=", "OPENSEARCH_NUMBER_OF_REPLICAS=", ], ) write_text_lines(tmp_path / "env.example", ["LLM_BINDING=openai"]) result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == "no" assert "OPENSEARCH_NUMBER_OF_SHARDS must be a positive integer." in result.stderr def test_opensearch_index_validators_accept_zero_padded_values() -> None: """OpenSearch shard and replica validators should accept zero-padded decimals.""" values = run_bash_lines( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" if validate_positive_integer "08"; then printf 'SHARDS=valid\\n' else printf 'SHARDS=invalid\\n' fi if validate_non_negative_integer "09"; then printf 'REPLICAS=valid\\n' else printf 'REPLICAS=invalid\\n' fi """ ) assert values["SHARDS"] == "valid" assert values["REPLICAS"] == "valid" def test_validate_env_file_rejects_mongo_vector_storage_without_atlas_uri( tmp_path: Path, ) -> None: """validate_env_file must reject MongoVectorDBStorage when MONGO_URI is not Atlas (mongodb+srv://).""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=MongoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", "MONGO_URI=mongodb://localhost:27017", ] ) + "\n", encoding="utf-8", ) (tmp_path / "env.example").write_text("LLM_BINDING=openai\n", encoding="utf-8") result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == "no" assert "MongoVectorDBStorage requires a MongoDB Atlas URI" in result.stderr def test_validate_env_file_rejects_empty_opensearch_hosts(tmp_path: Path) -> None: """validate_env_file should reject an explicitly empty OPENSEARCH_HOSTS setting.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "LIGHTRAG_KV_STORAGE=OpenSearchKVStorage", "LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage", "LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage", "OPENSEARCH_HOSTS=", ] ) + "\n", encoding="utf-8", ) (tmp_path / "env.example").write_text("LLM_BINDING=openai\n", encoding="utf-8") result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == "no" assert "Empty OPENSEARCH_HOSTS" in result.stderr def test_validate_env_file_rejects_whitespace_only_opensearch_hosts( tmp_path: Path, ) -> None: """validate_env_file should reject OpenSearch host lists with only blank entries.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "LIGHTRAG_KV_STORAGE=OpenSearchKVStorage", "LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage", "LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage", "OPENSEARCH_HOSTS= , ", "OPENSEARCH_USER=admin", "OPENSEARCH_PASSWORD=StrongPass1!", ] ) + "\n", encoding="utf-8", ) (tmp_path / "env.example").write_text("LLM_BINDING=openai\n", encoding="utf-8") result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == "no" assert "OPENSEARCH_HOSTS must not contain empty host entries." in result.stderr def test_validate_env_file_rejects_docker_opensearch_without_password( tmp_path: Path, ) -> None: """validate_env_file should reject bundled OpenSearch when auth is incomplete.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "LIGHTRAG_KV_STORAGE=OpenSearchKVStorage", "LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage", "LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage", "LIGHTRAG_SETUP_OPENSEARCH_DEPLOYMENT=docker", "OPENSEARCH_HOSTS=localhost:9200", "OPENSEARCH_USER=admin", ] ) + "\n", encoding="utf-8", ) (tmp_path / "env.example").write_text("LLM_BINDING=openai\n", encoding="utf-8") result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == "no" assert ( "Bundled OpenSearch requires OPENSEARCH_USER and OPENSEARCH_PASSWORD" in result.stderr ) def test_validate_env_file_rejects_weak_docker_opensearch_password( tmp_path: Path, ) -> None: """validate_env_file should reject bundled OpenSearch passwords the image will refuse.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "LIGHTRAG_KV_STORAGE=OpenSearchKVStorage", "LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage", "LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage", "LIGHTRAG_SETUP_OPENSEARCH_DEPLOYMENT=docker", "OPENSEARCH_HOSTS=localhost:9200", "OPENSEARCH_USER=admin", "OPENSEARCH_PASSWORD=weakpass", ] ) + "\n", encoding="utf-8", ) (tmp_path / "env.example").write_text("LLM_BINDING=openai\n", encoding="utf-8") result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == "no" assert "OpenSearch requires a strong OPENSEARCH_PASSWORD" in result.stderr def test_validate_env_file_rejects_weak_host_opensearch_password( tmp_path: Path, ) -> None: """validate_env_file should reject weak OpenSearch passwords even for host deployments.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "LIGHTRAG_KV_STORAGE=OpenSearchKVStorage", "LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage", "LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage", "OPENSEARCH_HOSTS=localhost:9200", "OPENSEARCH_USER=admin", "OPENSEARCH_PASSWORD=weakpass", ] ) + "\n", encoding="utf-8", ) (tmp_path / "env.example").write_text("LLM_BINDING=openai\n", encoding="utf-8") result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == "no" assert "OpenSearch requires a strong OPENSEARCH_PASSWORD" in result.stderr def test_validate_env_file_rejects_unauthenticated_host_opensearch( tmp_path: Path, ) -> None: """validate_env_file should reject host-mode OpenSearch with no auth fields.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "LIGHTRAG_KV_STORAGE=OpenSearchKVStorage", "LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage", "LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage", "OPENSEARCH_HOSTS=localhost:9200", ] ) + "\n", encoding="utf-8", ) (tmp_path / "env.example").write_text("LLM_BINDING=openai\n", encoding="utf-8") result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == "no" assert "OPENSEARCH_USER" in result.stderr assert "OPENSEARCH_PASSWORD" in result.stderr def test_validate_env_file_rejects_partial_host_opensearch_auth( tmp_path: Path, ) -> None: """validate_env_file should reject host-mode OpenSearch when only one auth field is set.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "LIGHTRAG_KV_STORAGE=OpenSearchKVStorage", "LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage", "LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage", "OPENSEARCH_HOSTS=localhost:9200", "OPENSEARCH_USER=admin", ] ) + "\n", encoding="utf-8", ) (tmp_path / "env.example").write_text("LLM_BINDING=openai\n", encoding="utf-8") result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == "no" assert "OPENSEARCH_PASSWORD" in result.stderr def test_validate_env_file_rejects_opensearch_hosts_with_uri_scheme( tmp_path: Path, ) -> None: """validate_env_file should require OPENSEARCH_HOSTS to stay as host:port entries.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "LIGHTRAG_KV_STORAGE=OpenSearchKVStorage", "LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage", "LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage", "OPENSEARCH_HOSTS=https://localhost:9200", "OPENSEARCH_USER=admin", "OPENSEARCH_PASSWORD=StrongPass1!", ] ) + "\n", encoding="utf-8", ) (tmp_path / "env.example").write_text("LLM_BINDING=openai\n", encoding="utf-8") result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == "no" assert ( "OPENSEARCH_HOSTS must use bare host:port entries, not URLs." in result.stderr ) def test_validate_env_file_ignores_invalid_unused_storage_settings( tmp_path: Path, ) -> None: """validate_env_file should ignore malformed settings for backends not selected by storage.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", "NEO4J_URI=http://localhost:7687", "MONGO_URI=not-a-mongo-uri", "REDIS_URI=tcp://localhost:6379", "MILVUS_URI=tcp://localhost:19530", "QDRANT_URL=tcp://localhost:6333", "MEMGRAPH_URI=http://localhost:7687", "POSTGRES_PORT=99999", ] ) + "\n", encoding="utf-8", ) (tmp_path / "env.example").write_text("LLM_BINDING=openai\n", encoding="utf-8") result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == "yes" assert "Invalid NEO4J_URI" not in result.stderr assert "Invalid MONGO_URI" not in result.stderr assert "Invalid REDIS_URI" not in result.stderr assert "Invalid MILVUS_URI" not in result.stderr assert "Invalid QDRANT_URL" not in result.stderr assert "Invalid MEMGRAPH_URI" not in result.stderr assert "Invalid POSTGRES_PORT" not in result.stderr def test_validate_env_file_allows_empty_opensearch_hosts_when_unused( tmp_path: Path, ) -> None: """validate_env_file should ignore blank OpenSearch hosts when no OpenSearch storage is selected.""" env_file = tmp_path / ".env" env_file.write_text( "\n".join( [ "LIGHTRAG_KV_STORAGE=JsonKVStorage", "LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage", "LIGHTRAG_GRAPH_STORAGE=NetworkXStorage", "LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage", "OPENSEARCH_HOSTS=", ] ) + "\n", encoding="utf-8", ) (tmp_path / "env.example").write_text("LLM_BINDING=openai\n", encoding="utf-8") result = subprocess.run( [ "bash", "--norc", "--noprofile", "-c", f""" source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" reset_state if validate_env_file; then printf 'VALID=yes\\n' else printf 'VALID=no\\n' fi """, ], cwd=REPO_ROOT, capture_output=True, text=True, check=False, ) values = parse_lines(result.stdout) assert values["VALID"] == "yes" assert "Empty OPENSEARCH_HOSTS" not in result.stderr def test_backup_only_backs_up_env_and_generated_compose(tmp_path: Path) -> None: """backup_only should back up both .env and the active generated compose file.""" compose_content = ( "\n".join( [ "services:", " lightrag:", " image: example/lightrag:test", ] ) + "\n" ) write_text_lines(tmp_path / ".env", ["HOST=0.0.0.0"]) (tmp_path / "docker-compose.final.yml").write_text( compose_content, encoding="utf-8", ) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" backup_only """ ) env_backups = sorted(tmp_path.glob(".env.backup.*")) assert len(env_backups) == 1 assert env_backups[0].read_text(encoding="utf-8") == "HOST=0.0.0.0\n" assert "Backed up .env to" in output assert "Backed up compose file to" in output assert_single_compose_backup(tmp_path, compose_content) def test_backup_only_skips_compose_backup_when_no_generated_compose_exists( tmp_path: Path, ) -> None: """backup_only should still succeed when only .env exists.""" write_text_lines(tmp_path / ".env", ["HOST=0.0.0.0"]) output = run_bash( f""" set -euo pipefail source "{REPO_ROOT}/scripts/setup/setup.sh" REPO_ROOT="{tmp_path}" backup_only """ ) env_backups = sorted(tmp_path.glob(".env.backup.*")) assert len(env_backups) == 1 assert "Backed up .env to" in output assert "Backed up compose file to" not in output assert list(tmp_path.glob("docker-compose.backup*.yml")) == []