Files
2026-07-13 12:08:54 +08:00

1861 lines
60 KiB
Python

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