| """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.""" |
|
|
| |
| 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", |
| ) |
|
|
| |
| 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") |
|
|
| |
| assert "/app/data/certs/cert.pem" not in result |
| assert "/app/data/certs/key.pem" not in result |
| |
| 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"] == "" |
| |
| assert values["RERANK_MODEL"] != "BAAI/bge-reranker-v2-m3" |
| assert values["RERANK_MODEL"] == "rerank-v3.5" |
| |
| 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" |
| |
| 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" |
| |
| 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.""" |
|
|
| |
| 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", |
| ) |
|
|
| |
| 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") |
| |
| assert "postgres:" not in result |
| assert "neo4j:" not in result |
| assert "postgres_data:" not in result |
| assert "neo4j_data:" not in result |
| |
| 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"') |
|
|
| |
| 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", |
| ], |
| ) |
|
|
| |
| |
| 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") |
| |
| 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 |
| ) |
| |
| 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")) == [] |
|
|