From 004768cf0801fb0091c013a6bf8e13bcb17cb73b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sun, 18 Oct 2026 14:37:13 +0900 Subject: [PATCH 1/4] fix: align scoped local service readiness with application settings --- scripts/demo/local_config.py | 259 +++++++++++++++++++++++++++++++++++ scripts/local-services.sh | 83 +++++++++-- 2 files changed, 332 insertions(+), 10 deletions(-) create mode 100644 scripts/demo/local_config.py diff --git a/scripts/demo/local_config.py b/scripts/demo/local_config.py new file mode 100644 index 00000000..41566a44 --- /dev/null +++ b/scripts/demo/local_config.py @@ -0,0 +1,259 @@ +"""로컬 시연의 지원 설정만 해석하고 실제 Compose 바인딩·DB를 읽기 전용으로 확인한다.""" +from __future__ import annotations + +import argparse +import json +import os +import re +import socket +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlsplit + +LOOPBACKS = {"localhost", "127.0.0.1", "::1"} +DEFAULTS = { + "SPRING_PROFILES_ACTIVE": "local", "STORAGE_TYPE": "local", + "DB_HOST": "localhost", "DB_PORT": "55432", "DB_NAME": "app", "DB_USER": "app", + "DB_SCHEMA": "public", "DB_SSLMODE": "disable", "REDIS_HOST": "localhost", + "REDIS_PORT": "6379", "REDIS_PASSWORD": "", "SERVER_PORT": "8080", "MANAGEMENT_PORT": "8081", + "EMBEDDING_SERVER_URL": "http://localhost:8000", "OLLAMA_SERVER_URL": "http://localhost:11434", + "OLLAMA_MODEL": "qwen2.5:7b", "STORAGE_LOCAL_ROOT": "./data/docgrid", "STORAGE_BUCKET": "docgrid", +} + + +class ConfigError(Exception): + """실제 주소·자격증명을 노출하지 않는 로컬 시연 설정 오류다.""" + + +def read_env(path: Path, required=True) -> dict[str, str]: + """예제의 KEY=VALUE 부분집합만 읽으며 셸 실행·치환·모호한 인용을 허용하지 않는다.""" + if not path.exists(): + if required: + raise ConfigError("환경 파일이 없습니다. 예제 파일을 복사해 준비하세요.") + return {} + values = {} + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError): + raise ConfigError("환경 파일을 안전하게 읽을 수 없습니다.") from None + for number, line in enumerate(lines, 1): + text = line + if not text.strip() or text.lstrip().startswith("#"): + continue + match = re.fullmatch(r"([A-Z][A-Z0-9_]*)=(.*)", text) + if not match: + raise ConfigError(f"환경 파일 {number}행: 지원 형식은 KEY=VALUE입니다.") + key, value = match.groups() + if key in values: + raise ConfigError(f"중복 환경변수: {key}") + if value != value.strip() or any(c in value for c in ['"', "'", "`", "$", "\\"]) or " #" in value: + raise ConfigError(f"모호한 인용·치환·이스케이프 설정은 지원하지 않습니다: {key}") + values[key] = value + return values + + +def local_url(value: str, key: str) -> str: + """자격증명·원격 호스트·리다이렉트용 URL 요소가 없는 로컬 HTTP 주소만 받는다.""" + try: + parsed = urlsplit(value) + valid = (not any(c.isspace() for c in value) and parsed.scheme == "http" and parsed.hostname in LOOPBACKS and + parsed.username is None and parsed.password is None and not parsed.query and not parsed.fragment and + parsed.path in ("", "/") and parsed.port and 1 <= parsed.port <= 65535) + except ValueError: + valid = False + if not valid: + raise ConfigError(f"로컬 HTTP 주소만 지원합니다: {key}") + return value.rstrip("/") + + +@dataclass +class LocalConfig: + """설정 원문은 메모리 안에만 두고 서비스·런처가 같은 확정 환경을 사용하게 한다.""" + + root: Path + values: dict[str, str] + frontend: dict[str, str] + + @classmethod + def load(cls, root: Path, environ=None): + root = root.resolve() + inherited = dict(os.environ if environ is None else environ) + values = {**DEFAULTS, **read_env(root / ".env"), **inherited} + for key in values: + if (key.startswith(("SPRING_DATASOURCE_", "SPRING_FLYWAY_", "SPRING_CONFIG_", "SPRING_JPA_", + "SPRING_SQL_", "SPRING_DATA_REDIS_", "OPENSQL_")) or + (key.startswith("SPRING_PROFILES_") and key != "SPRING_PROFILES_ACTIVE") or + key in {"SPRING_APPLICATION_JSON", "SPRING_PROFILES_INCLUDE", "SPRING_PROFILES_DEFAULT", + "JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS", "GRADLE_OPTS"}) and values[key]: + raise ConfigError(f"통합 로컬 시연에서 지원하지 않는 설정 우회입니다: {key}") + if values["SPRING_PROFILES_ACTIVE"] != "local" or values["STORAGE_TYPE"] != "local": + raise ConfigError("통합 시연은 local 프로필과 Local Filesystem 저장소만 지원합니다.") + for key in ["DB_HOST", "REDIS_HOST"]: + if values[key] not in {"localhost", "127.0.0.1"}: + raise ConfigError(f"로컬 호스트만 지원합니다: {key}") + # localhost의 IPv6 SSH 터널과 IPv4 Compose 포트를 혼동하지 않도록 실제 자식 설정도 고정한다. + values[key] = "127.0.0.1" + for key in ["DB_PORT", "REDIS_PORT", "SERVER_PORT", "MANAGEMENT_PORT"]: + if not values[key].isdigit() or not 1 <= int(values[key]) <= 65535: + raise ConfigError(f"포트 설정이 잘못됐습니다: {key}") + if values["SERVER_PORT"] != "8080" or values["MANAGEMENT_PORT"] == "3000": + raise ConfigError("통합 시연은 API 8080·Web 3000과 별도의 Management 포트를 사용합니다.") + if values.get("INDEXING_WORKER_ENABLED", "true").lower() != "true": + raise ConfigError("통합 시연에는 인덱싱 Worker가 필요합니다.") + if values.get("REDIS_PASSWORD"): + raise ConfigError("기본 로컬 Compose Valkey는 인증 없는 로컬 연결을 사용합니다.") + if not values.get("DB_PASSWORD"): + raise ConfigError("DB_PASSWORD를 설정하세요.") + for key in ["DB_NAME", "DB_USER", "DB_SCHEMA", "STORAGE_BUCKET"]: + if not re.fullmatch(r"[A-Za-z0-9_-]+", values[key]): + raise ConfigError(f"로컬 식별자 형식이 잘못됐습니다: {key}") + storage = (root / values["STORAGE_LOCAL_ROOT"]).resolve() + if not any(storage == base or base in storage.parents + for base in [root / "data", root / ".demo" / "storage"]): + raise ConfigError("시연 원본 경로는 현재 저장소의 data 또는 .demo/storage 내부여야 합니다.") + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}", values["OLLAMA_MODEL"]): + raise ConfigError("지정 모델 이름 형식이 잘못됐습니다.") + for key in ["EMBEDDING_SERVER_URL", "OLLAMA_SERVER_URL"]: + values[key] = local_url(values[key], key) + front = {**read_env(root / "frontend" / ".env.local", required=False)} + for key in ["BACKEND_API_URL", "NEXT_PUBLIC_BACKEND_WS_URL"]: + front[key] = inherited.get(key, front.get(key, "http://localhost:8080")) + local_url(front[key], key) + if urlsplit(front[key]).port != 8080: + raise ConfigError(f"프런트엔드와 API 8080의 대상이 다릅니다: {key}") + values.update(front) + values["STORAGE_LOCAL_ROOT"] = str(storage) + return cls(root, values, front) + + def command(self, arguments, timeout=15, extra_env=None): + """명령의 원문 오류·환경은 출력하지 않고 호출자에게만 전달한다.""" + try: + return subprocess.run(arguments, cwd=self.root, env={**self.values, **(extra_env or {})}, + capture_output=True, text=True, timeout=timeout) + except (OSError, subprocess.TimeoutExpired): + raise ConfigError("로컬 실행 도구에 연결할 수 없거나 제한 시간을 초과했습니다.") from None + + def compose(self, *arguments): + return ["docker", "compose", "--project-directory", str(self.root), + "-f", str(self.root / "docker-compose.yml"), *arguments] + + def ensure_local_engine(self): + context = self.values.get("DOCKER_CONTEXT") + endpoint = None if context else self.values.get("DOCKER_HOST") + if not endpoint: + result = self.command(["docker", "context", "inspect", *([context] if context else []), + "--format", "{{.Endpoints.docker.Host}}"]) + endpoint = result.stdout.strip() if result.returncode == 0 else "" + if not endpoint.startswith("unix://"): + raise ConfigError("로컬 Unix 소켓 Docker Engine만 지원합니다.") + # 전역 context가 도중에 바뀌어도 이후 명령은 확인한 endpoint만 사용한다. + self.values.pop("DOCKER_CONTEXT", None) + self.values["DOCKER_HOST"] = endpoint + + def container(self, service): + result = self.command(self.compose("ps", "--all", "--quiet", service)) + ids = result.stdout.split() if result.returncode == 0 else [] + if len(ids) != 1: + raise ConfigError(f"대상 Compose 컨테이너를 확인할 수 없습니다: {service}") + inspected = self.command(["docker", "inspect", ids[0]]) + try: + data = json.loads(inspected.stdout)[0] + labels = data["Config"]["Labels"] + matches = (labels.get("com.docker.compose.service") == service and + Path(labels.get("com.docker.compose.project.working_dir", "")).resolve() == self.root) + except (ValueError, KeyError, IndexError, TypeError): + matches = False + if inspected.returncode or not matches: + raise ConfigError(f"다른 실행 환경의 컨테이너는 사용하지 않습니다: {service}") + return data + + def preflight_containers(self): + """up 전에 기존 컨테이너의 출처를 확인해 다른 작업공간의 자원 교체를 방지한다.""" + if self.command(["docker", "info", "--format", "{{.ServerVersion}}"]).returncode: + raise ConfigError("Docker Engine에 연결할 수 없습니다.") + rendered = self.command(self.compose("config", "--format", "json")) + if rendered.returncode: + raise ConfigError("Compose 설정을 안전하게 해석할 수 없습니다.") + try: + services = json.loads(rendered.stdout)["services"] + for service in ["postgres", "valkey", "embedding-server"]: + name = services[service]["container_name"] + inspected = self.command(["docker", "inspect", name]) + if inspected.returncode: + continue + data = json.loads(inspected.stdout)[0] + labels = data["Config"]["Labels"] + if (labels.get("com.docker.compose.service") != service or + Path(labels.get("com.docker.compose.project.working_dir", "")).resolve() != self.root): + raise ConfigError(f"다른 실행 환경의 컨테이너를 교체하지 않습니다: {service}") + except (ValueError, KeyError, TypeError, IndexError): + raise ConfigError("기존 Compose 자원 출처를 안전하게 확인할 수 없습니다.") from None + + def check_binding(self, service, container_port, host_port): + """루프백 주소 외에도 실제 Compose ID·프로젝트·포트 매핑을 함께 대조한다.""" + data = self.container(service) + bindings = data.get("NetworkSettings", {}).get("Ports", {}).get(f"{container_port}/tcp") or [] + matching = [b for b in bindings if b.get("HostPort") == str(host_port) and + b.get("HostIp") in {"127.0.0.1", "0.0.0.0", "::", "::1", ""}] + if not data.get("State", {}).get("Running") or not matching: + raise ConfigError(f"앱의 대상 포트와 실행 중 컨테이너가 일치하지 않습니다: {service}") + if service == "postgres": + environment = dict(item.split("=", 1) for item in data["Config"].get("Env", []) if "=" in item) + if any(environment.get(source) != self.values[target] for source, target in [ + ("POSTGRES_DB", "DB_NAME"), ("POSTGRES_USER", "DB_USER"), ("POSTGRES_PASSWORD", "DB_PASSWORD")]): + raise ConfigError("백엔드 DB 설정과 Compose DB 초기 설정이 다릅니다.") + if service in {"postgres", "valkey"}: + if not any(b.get("HostIp") in {"127.0.0.1", "0.0.0.0", ""} for b in matching): + raise ConfigError(f"IPv4 로컬 대상의 포트 바인딩이 없습니다: {service}") + try: + with socket.create_connection(("127.0.0.1", int(host_port)), timeout=3): + pass + except OSError: + raise ConfigError(f"앱이 접속할 로컬 포트에 연결할 수 없습니다: {service}") from None + return data + + def check_database(self): + self.check_binding("postgres", 5432, self.values["DB_PORT"]) + query = ("SELECT current_database(), current_user, extversion FROM pg_extension " + "WHERE extname='vector' AND 'vector'::regtype IS NOT NULL") + result = self.command(self.compose("exec", "-T", "-e", "PGPASSWORD", "-e", "PGSSLMODE", + "-e", "PGOPTIONS", "postgres", "psql", "-X", + "-h", "127.0.0.1", "-U", self.values["DB_USER"], "-d", self.values["DB_NAME"], + "-v", "ON_ERROR_STOP=1", "-A", "-t", "-c", query), extra_env={ + "PGPASSWORD": self.values["DB_PASSWORD"], "PGSSLMODE": self.values["DB_SSLMODE"], + "PGOPTIONS": "-c search_path=" + self.values["DB_SCHEMA"]}) + fields = result.stdout.strip().split("|") + if result.returncode or fields != [self.values["DB_NAME"], self.values["DB_USER"], "0.8.1"]: + raise ConfigError("대상 DB 인증·pgvector 0.8.1 읽기 전용 점검을 통과하지 못했습니다.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=["services", "database", "bindings"]) + parser.add_argument("--scope", choices=["search", "rag"], default="search") + parser.add_argument("--service-action", choices=["start", "status"], default="status") + args = parser.parse_args() + try: + config = LocalConfig.load(Path(__file__).resolve().parents[2]) + config.ensure_local_engine() + if args.action == "services": + if args.service_action == "start": + config.preflight_containers() + environment = {**config.values, "DOCGRID_LOCAL_EXPLICIT": "1", "DOCGRID_LOCAL_SCOPE": args.scope} + os.execvpe("bash", ["bash", str(config.root / "scripts/local-services.sh"), args.service_action], environment) + elif args.action == "database": + config.check_database() + else: + config.check_binding("postgres", 5432, config.values["DB_PORT"]) + config.check_binding("valkey", 6379, config.values["REDIS_PORT"]) + config.check_binding("embedding-server", 8000, urlsplit(config.values["EMBEDDING_SERVER_URL"]).port) + return 0 + except ConfigError as error: + print(f"[FAIL] {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/local-services.sh b/scripts/local-services.sh index 39fb7e56..55dc89f3 100755 --- a/scripts/local-services.sh +++ b/scripts/local-services.sh @@ -1,10 +1,21 @@ #!/usr/bin/env bash set -Eeuo pipefail -# DocGrid 로컬 실행에 필수인 Valkey, Embedding Provider, Ollama의 기동과 준비 상태만 관리한다. +# 기존 서비스 기동을 유지하고 명시적 search/rag 범위에서는 검증된 로컬 설정만 사용한다. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" ACTION="${1:-status}" +# 1. .env를 셸로 실행하지 않고 Python의 제한된 설정 해석기로 명시적 범위를 확정한다. +if (( $# > 1 )); then + if [[ $# -ne 3 || "$2" != "--scope" || ! "$3" =~ ^(search|rag)$ ]]; then + printf '[FAIL] 사용법: local-services.sh --scope \n' >&2 + exit 2 + fi + command -v python3 >/dev/null 2>&1 || { printf '[FAIL] Python 3가 필요합니다.\n' >&2; exit 2; } + exec python3 "$SCRIPT_DIR/demo/local_config.py" services --scope "$3" --service-action "$ACTION" +fi +LOCAL_SCOPE="${DOCGRID_LOCAL_SCOPE:-rag}" +LOCAL_EXPLICIT="${DOCGRID_LOCAL_EXPLICIT:-0}" WAIT_TIMEOUT_SECONDS="${LOCAL_SERVICES_WAIT_TIMEOUT_SECONDS:-900}" POLL_INTERVAL_SECONDS="${LOCAL_SERVICES_POLL_INTERVAL_SECONDS:-2}" EMBEDDING_BASE_URL="${EMBEDDING_SERVER_URL:-http://127.0.0.1:8000}" @@ -30,11 +41,15 @@ warn() { usage() { cat <<'EOF' -Usage: ./scripts/local-services.sh +Usage: ./scripts/local-services.sh [--scope search|rag] start Valkey, Embedding Provider, Ollama를 기동하고 준비 완료까지 기다립니다. status 세 서비스의 실제 연결 상태와 Ollama 모델 준비 상태를 즉시 확인합니다. + --scope search 로컬 PostgreSQL·Valkey·BGE-M3만 기동·점검합니다. Ollama는 호출하지 않습니다. + --scope rag 위 항목과 사전 기동된 Native Ollama API·모델을 점검합니다. + 인자 없는 기존 명령의 Native Ollama 기동 방식은 유지합니다. + Environment: LOCAL_SERVICES_WAIT_TIMEOUT_SECONDS start 대기 시간(기본 900초) LOCAL_SERVICES_POLL_INTERVAL_SECONDS 점검 주기(기본 2초) @@ -53,8 +68,20 @@ require_positive_integer() { fi } +compose() { + if [[ "$LOCAL_EXPLICIT" == "1" ]]; then + docker compose --project-directory "$PROJECT_ROOT" -f "$PROJECT_ROOT/docker-compose.yml" "$@" + else + docker compose "$@" + fi +} + +postgres_ready() { + python3 "$SCRIPT_DIR/demo/local_config.py" database >/dev/null 2>&1 +} + valkey_ready() { - docker compose exec -T valkey valkey-cli ping 2>/dev/null | grep -qx 'PONG' + compose exec -T valkey valkey-cli ping 2>/dev/null | grep -qx 'PONG' } embedding_ready() { @@ -152,6 +179,18 @@ start_ollama() { print_status() { local failures=0 + if [[ "$LOCAL_EXPLICIT" == "1" ]]; then + if ! python3 "$SCRIPT_DIR/demo/local_config.py" bindings; then + failures=$((failures + 1)) + fi + if postgres_ready; then + ok "대상 PostgreSQL 인증·pgvector 0.8.1 확인" + else + fail "대상 PostgreSQL 인증·확장 확인 실패 — DB 설정과 준비 상태를 확인하세요." + failures=$((failures + 1)) + fi + fi + # 1. 실행 상태가 아니라 실제 Valkey 명령 응답으로 로그인 캐시 사용 가능 여부를 확인한다. if valkey_ready; then ok "Valkey 연결 가능" @@ -169,6 +208,16 @@ print_status() { failures=$((failures + 1)) fi + # search 범위는 생성 모델의 기동·준비 상태를 호출하지 않는다. + if [[ "$LOCAL_SCOPE" == "search" ]]; then + if (( failures > 0 )); then + fail "검색 범위 준비 점검 ${failures}개 실패" + return 1 + fi + ok "검색 범위 준비 완료 · 생성 답변은 미검증" + return 0 + fi + # 3. Ollama API와 애플리케이션이 요구하는 모델을 분리해 AI 답변 가능 여부를 확인한다. if ollama_ready; then ok "Ollama API 연결 가능 (${OLLAMA_BASE_URL%/})" @@ -211,17 +260,31 @@ start_services() { return 1 } - # 1. Compose 서비스는 기존 Volume을 유지한 채 필요한 두 서비스만 멱등하게 기동한다. - log "Valkey와 Embedding Provider를 시작합니다." - COMPOSE_IGNORE_ORPHANS=true docker compose up -d valkey embedding-server + # 1. Native 서비스 관리자·앱은 명시적 시연의 소유 자원이 아니므로 준비된 서버만 재사용한다. + if [[ "$LOCAL_EXPLICIT" == "1" && "$LOCAL_SCOPE" == "rag" ]]; then + if ! ollama_ready || ! ollama_model_ready; then + fail "rag 범위에는 사전 기동된 Native Ollama와 지정 모델이 필요합니다." + return 1 + fi + fi - # 2. Native Ollama를 기동해 Docker Desktop의 CPU 추론 경로를 피한다. - start_ollama + # 2. 명시적 로컬 범위는 DB까지 포함하고, 기존 인자 없는 기동 계약은 유지한다. + if [[ "$LOCAL_EXPLICIT" == "1" ]]; then + log "로컬 PostgreSQL·Valkey·Embedding Provider를 시작합니다." + COMPOSE_IGNORE_ORPHANS=true compose up -d --no-recreate postgres valkey embedding-server + python3 "$SCRIPT_DIR/demo/local_config.py" bindings + wait_until_ready "대상 PostgreSQL" postgres_ready + else + COMPOSE_IGNORE_ORPHANS=true compose up -d valkey embedding-server + start_ollama + fi - # 3. 각 서비스가 실제 요청을 받을 수 있을 때까지 제한 시간 안에서 기다린다. + # 3. 필요한 서비스만 실제 요청을 받을 수 있을 때까지 기다린다. wait_until_ready "Valkey" valkey_ready wait_until_ready "Embedding Provider" embedding_ready - wait_until_ready "Ollama API" ollama_ready + if [[ "$LOCAL_SCOPE" == "rag" ]]; then + wait_until_ready "Ollama API" ollama_ready + fi # 4. 마지막으로 모델을 포함한 전체 상태를 같은 기준으로 다시 검증한다. print_status From 48604fb2bb73b104ec45352b3e1d85bfac9dce85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sun, 18 Oct 2026 14:37:14 +0900 Subject: [PATCH 2/4] test: cover local target guards and search-only service startup --- scripts/demo/test_local_config.py | 173 ++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 scripts/demo/test_local_config.py diff --git a/scripts/demo/test_local_config.py b/scripts/demo/test_local_config.py new file mode 100644 index 00000000..4ad2e924 --- /dev/null +++ b/scripts/demo/test_local_config.py @@ -0,0 +1,173 @@ +"""원격 대상·설정 실행·잘못된 DB 바인딩과 Ollama 필수화 회귀를 방지한다.""" +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from local_config import ConfigError, LocalConfig, local_url, read_env + + +class LocalConfigTest(unittest.TestCase): + """예제 형식과 실제 Compose 대조의 판정만 검사하며 외부 서비스를 호출하지 않는다.""" + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + (self.root / 'frontend').mkdir() + (self.root / '.env').write_text('DB_PASSWORD=synthetic-local-only\nJWT_SECRET=synthetic-not-a-real-secret\n') + + def load(self, extra=None): + return LocalConfig.load(self.root, environ=extra or {}) + + def test_defaults_match_local_quickstart(self): + c = self.load() + self.assertEqual('55432', c.values['DB_PORT']) + self.assertEqual('local', c.values['SPRING_PROFILES_ACTIVE']) + + def test_environment_overrides_file(self): + self.assertEqual('other', self.load({'DB_NAME': 'other'}).values['DB_NAME']) + + def test_shell_substitution_is_refused_without_execution(self): + marker = self.root / 'never-created' + (self.root / '.env').write_text(f'DB_PASSWORD=$(touch {marker})\n') + with self.assertRaises(ConfigError): self.load() + self.assertFalse(marker.exists()) + + def test_duplicate_key_is_refused(self): + (self.root / '.env').write_text('DB_PASSWORD=first\nDB_PASSWORD=second\n') + with self.assertRaises(ConfigError): self.load() + + def test_ambiguous_quotes_and_inline_comments_are_refused(self): + for value in ['"quoted"', 'value # comment', 'value\\n', 'value ']: + with self.subTest(value=value): + (self.root / '.env').write_text('DB_PASSWORD='+value+'\n') + with self.assertRaises(ConfigError): self.load() + + def test_remote_profile_and_storage_are_refused(self): + for extra in [{'SPRING_PROFILES_ACTIVE': 'opensql-ha'}, {'STORAGE_TYPE': 'gcs'}]: + with self.subTest(extra=extra): + with self.assertRaises(ConfigError): self.load(extra) + + def test_remote_database_and_model_are_refused(self): + for extra in [{'DB_HOST': 'remote.invalid'}, {'EMBEDDING_SERVER_URL': 'http://remote.invalid:8000'}]: + with self.subTest(extra=extra): + with self.assertRaises(ConfigError): self.load(extra) + + def test_spring_or_jvm_bypass_is_refused(self): + for key in ['SPRING_DATASOURCE_URL', 'SPRING_APPLICATION_JSON', 'JAVA_TOOL_OPTIONS', 'OPENSQL_APP_JDBC_URL', + 'SPRING_PROFILES_GROUP_LOCAL', 'SPRING_JPA_PROPERTIES_HIBERNATE_CONNECTION_URL', + 'SPRING_DATA_REDIS_URL']: + with self.subTest(key=key): + with self.assertRaises(ConfigError): self.load({key: 'controlled-bypass'}) + + def test_storage_outside_checkout_is_refused(self): + with self.assertRaises(ConfigError): self.load({'STORAGE_LOCAL_ROOT': '../outside'}) + + def test_frontend_remote_or_different_api_is_refused(self): + for url in ['http://remote.invalid:8080', 'http://localhost:9999']: + (self.root / 'frontend' / '.env.local').write_text('BACKEND_API_URL='+url+'\n') + with self.assertRaises(ConfigError): self.load() + + def test_url_credentials_query_and_non_http_are_refused(self): + for url in ['http://user:pass@localhost:8000', 'http://localhost:8000?target=elsewhere', 'https://localhost:8000']: + with self.subTest(url=url): + with self.assertRaises(ConfigError): local_url(url, 'TEST_URL') + + def test_disabled_worker_is_refused(self): + with self.assertRaises(ConfigError): self.load({'INDEXING_WORKER_ENABLED': 'false'}) + + def container(self, port='55432', cwd=None): + return {'Id': 'controlled-container', 'Config': {'Labels': { + 'com.docker.compose.service': 'postgres', + 'com.docker.compose.project.working_dir': str(cwd or self.root)}, + 'Env': ['POSTGRES_DB=app', 'POSTGRES_USER=app', 'POSTGRES_PASSWORD=synthetic-local-only']}, + 'State': {'Running': True}, 'NetworkSettings': {'Ports': { + '5432/tcp': [{'HostIp': '127.0.0.1', 'HostPort': port}]}}} + + def test_loopback_port_without_matching_container_is_refused(self): + c = self.load() + with patch.object(c, 'container', return_value=self.container(port='60000')): + with self.assertRaises(ConfigError): c.check_binding('postgres', 5432, '55432') + + def test_container_from_another_checkout_is_refused(self): + c = self.load() + outputs = [subprocess.CompletedProcess([], 0, 'controlled-container\n', ''), + subprocess.CompletedProcess([], 0, json.dumps([self.container(cwd=self.root / 'other')]), '')] + with patch.object(c, 'command', side_effect=outputs): + with self.assertRaises(ConfigError): c.container('postgres') + + def test_database_check_is_readonly_and_password_is_not_an_argument(self): + c = self.load() + with patch.object(c, 'check_binding'), patch.object(c, 'command', return_value= + subprocess.CompletedProcess([], 0, 'app|app|0.8.1\n', '')) as command: + c.check_database() + args, kwargs = command.call_args + self.assertNotIn('synthetic-local-only', ' '.join(args[0])) + self.assertIn('SELECT', args[0][-1]) + self.assertNotIn('CREATE', args[0][-1]) + self.assertEqual('synthetic-local-only', kwargs['extra_env']['PGPASSWORD']) + + def test_wrong_extension_version_fails(self): + c = self.load() + with patch.object(c, 'check_binding'), patch.object(c, 'command', return_value= + subprocess.CompletedProcess([], 0, 'app|app|0.7.0\n', '')): + with self.assertRaises(ConfigError): c.check_database() + + def test_remote_docker_endpoint_is_refused(self): + c = self.load({'DOCKER_HOST': 'tcp://remote.invalid:2376'}) + with self.assertRaises(ConfigError): c.ensure_local_engine() + + def test_docker_context_takes_precedence_over_host(self): + c = self.load({'DOCKER_CONTEXT': 'controlled-context', 'DOCKER_HOST': 'unix:///controlled-local.sock'}) + with patch.object(c, 'command', return_value= + subprocess.CompletedProcess([], 0, 'tcp://remote.invalid:2376', '')): + with self.assertRaises(ConfigError): c.ensure_local_engine() + + def test_missing_host_port_is_not_ready_even_if_container_runs(self): + c = self.load() + with patch.object(c, 'container', return_value=self.container()), patch('local_config.socket.create_connection', + side_effect=OSError('controlled refusal')): + with self.assertRaises(ConfigError): c.check_binding('postgres', 5432, '55432') + + def test_foreign_container_is_refused_before_up(self): + c = self.load() + rendered = {'services': {name: {'container_name': name} for name in + ['postgres', 'valkey', 'embedding-server']}} + outputs = [subprocess.CompletedProcess([], 0, 'controlled-version', ''), + subprocess.CompletedProcess([], 0, json.dumps(rendered), ''), + subprocess.CompletedProcess([], 0, json.dumps([self.container(cwd=self.root / 'other')]), '')] + with patch.object(c, 'command', side_effect=outputs) as command: + with self.assertRaises(ConfigError): c.preflight_containers() + self.assertFalse(any('up' in call.args[0] for call in command.call_args_list)) + + +class ServiceScopeTest(unittest.TestCase): + """실제 Docker·모델 대신 실행 파일 대체물로 search의 호출 경계를 검사한다.""" + + def test_search_start_never_calls_ollama(self): + script = Path(__file__).resolve().parents[1] / 'local-services.sh' + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + calls = root / 'calls' + for name, body in { + 'docker': 'printf "docker %s\\n" "$*" >> "$CALLS"\ncase "$*" in *valkey-cli*) echo PONG;; esac\n', + 'curl': 'printf "curl %s\\n" "$*" >> "$CALLS"\ncase "$*" in *11434*) exit 88;; esac\n', + 'python3': 'exit 0\n', + }.items(): + file = root / name + file.write_text('#!/bin/sh\n'+body) + file.chmod(0o755) + env = {**os.environ, 'PATH': str(root)+os.pathsep+os.environ['PATH'], + 'CALLS': str(calls), 'DOCGRID_LOCAL_EXPLICIT': '1', 'DOCGRID_LOCAL_SCOPE': 'search'} + result = subprocess.run(['bash', str(script), 'start'], env=env, capture_output=True, text=True) + self.assertEqual(0, result.returncode, result.stderr) + self.assertNotIn('11434', calls.read_text()) + self.assertIn('postgres valkey embedding-server', calls.read_text()) + + +if __name__ == '__main__': + unittest.main() From c2a924a390e51d0cd90a6a818c564e41bc0e35fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sun, 18 Oct 2026 14:38:11 +0900 Subject: [PATCH 3/4] test: preserve legacy readiness and preflight native rag services --- scripts/demo/test_local_config.py | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/scripts/demo/test_local_config.py b/scripts/demo/test_local_config.py index 4ad2e924..327e5366 100644 --- a/scripts/demo/test_local_config.py +++ b/scripts/demo/test_local_config.py @@ -168,6 +168,42 @@ def test_search_start_never_calls_ollama(self): self.assertNotIn('11434', calls.read_text()) self.assertIn('postgres valkey embedding-server', calls.read_text()) + def test_legacy_status_still_requires_ollama(self): + script = Path(__file__).resolve().parents[1] / 'local-services.sh' + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name, body in { + 'docker': 'case "$*" in *valkey-cli*) echo PONG;; esac\n', + 'curl': 'case "$*" in *11434*) exit 88;; esac\n', + 'python3': 'exit 0\n', + }.items(): + file = root / name + file.write_text('#!/bin/sh\n'+body) + file.chmod(0o755) + env = {k:v for k,v in os.environ.items() if not k.startswith('DOCGRID_LOCAL_')} + env['PATH'] = str(root)+os.pathsep+os.environ['PATH'] + result = subprocess.run(['bash', str(script), 'status'], env=env, capture_output=True, text=True) + self.assertEqual(1, result.returncode) + self.assertIn('Ollama API 연결 실패', result.stderr) + + def test_explicit_rag_without_ready_ollama_does_not_start_compose(self): + script = Path(__file__).resolve().parents[1] / 'local-services.sh' + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + calls = root / 'calls' + for name, body in { + 'docker': 'printf "docker %s\\n" "$*" >> "$CALLS"\n', + 'curl': 'exit 88\n', + }.items(): + file = root / name + file.write_text('#!/bin/sh\n'+body) + file.chmod(0o755) + env = {**os.environ, 'PATH': str(root)+os.pathsep+os.environ['PATH'], 'CALLS': str(calls), + 'DOCGRID_LOCAL_EXPLICIT': '1', 'DOCGRID_LOCAL_SCOPE': 'rag'} + result = subprocess.run(['bash', str(script), 'start'], env=env, capture_output=True, text=True) + self.assertEqual(1, result.returncode) + self.assertNotIn(' up ', calls.read_text()) + if __name__ == '__main__': unittest.main() From 998e9a996075d0f2d9f8ceb1e4037e739a791f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sun, 18 Oct 2026 14:39:50 +0900 Subject: [PATCH 4/4] docs: describe scoped local readiness and contract evidence --- README.md | 12 ++++ docs/design/local-demo-readiness.md | 44 ++++++++++++ .../demo-readiness-20261018/README.md | 46 +++++++++++++ .../demo-readiness-contract-01.log | 65 +++++++++++++++++ .../demo-readiness-contract-02.log | 67 ++++++++++++++++++ .../demo-readiness-contract-03.log | 69 +++++++++++++++++++ .../demo-readiness-shell-syntax-01.log | 9 +++ 7 files changed, 312 insertions(+) create mode 100644 docs/design/local-demo-readiness.md create mode 100644 docs/test-results/demo-readiness-20261018/README.md create mode 100644 docs/test-results/demo-readiness-20261018/demo-readiness-contract-01.log create mode 100644 docs/test-results/demo-readiness-20261018/demo-readiness-contract-02.log create mode 100644 docs/test-results/demo-readiness-20261018/demo-readiness-contract-03.log create mode 100644 docs/test-results/demo-readiness-20261018/demo-readiness-shell-syntax-01.log diff --git a/README.md b/README.md index e0d14f73..b0d625bd 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,18 @@ Ollama가 없어도 업로드, 인덱싱과 검색은 동작합니다. 검색 `start`는 BGE-M3 준비 완료까지 최대 15분 기다리며, 실패하면 서비스별 복구 명령을 출력합니다. +검색만 시연할 때는 명시적 로컬 범위를 사용합니다. `.env`의 local·Local Filesystem 설정과 실제 +Compose 대상 DB를 확인하고 PostgreSQL·Valkey·BGE-M3를 준비하며 Ollama는 호출하지 않습니다. + +```bash +./scripts/local-services.sh start --scope search +./scripts/local-services.sh status --scope search +``` + +`--scope rag`는 위 서비스와 사전 기동된 Native Ollama·모델을 확인합니다. 이 명시적 범위에서는 +Homebrew 서비스나 Ollama 앱을 자동 기동하지 않습니다. 인자 없는 기존 명령의 동작은 유지됩니다. +설정 파일은 실행하지 않고 `KEY=VALUE` 형식만 읽으며, 원격 프로필·저장소·모호한 설정은 자동 수정하지 않고 거부합니다. + Apple Silicon에서는 `ollama ps`의 `PROCESSOR`가 GPU인지 확인합니다. 설치와 성능 관련 상세 내용은 [백엔드 실행 문서](backend/README.md#ollama-rag-llm-서버)를 참고하세요. diff --git a/docs/design/local-demo-readiness.md b/docs/design/local-demo-readiness.md new file mode 100644 index 00000000..3465e6a3 --- /dev/null +++ b/docs/design/local-demo-readiness.md @@ -0,0 +1,44 @@ +# 로컬 시연 설정과 준비 점검 + +## 목적 + +명시적 search/rag 범위는 앱이 사용할 설정과 실제 로컬 Compose 자원을 대조한다. +기존 인자 없는 `local-services.sh start/status`의 Native Ollama 기동·필수 판정은 유지한다. + +## 설정 해석 + +- 예제의 `KEY=VALUE` 형식만 허용한다. `.env`를 source/eval하지 않는다. +- 환경변수는 파일 값보다 우선한다. 셸 치환·모호한 따옴표·이스케이프·중복 키는 거부한다. +- local 프로필·Local Filesystem·IPv4 로컬 DB/Valkey만 지원한다. DB/Valkey의 localhost는 자식 환경에서도 127.0.0.1로 확정한다. +- 원본 경로는 저장소 `data` 또는 `.demo/storage` 아래로 제한한다. 원격 OpenSQL·GCS·Spring/JVM 설정 우회는 이 경로에서 거부한다. +- 프런트엔드 파일과 프로세스 환경에서 읽은 API·WS 주소도 로컬 API 8080과 맞는지 검사한다. +- 개인 환경 파일은 읽기만 하며 자동 수정하지 않는다. 지원되지 않는 파일 문법은 명확히 거부한다. + +## Docker·DB 대조 + +Docker CLI의 DOCKER_CONTEXT 우선순위에 맞춰 실제 endpoint를 확인한다. Unix 소켓만 허용하고 이후 자식 명령은 +그 endpoint에 고정해 전역 context 변경의 영향을 줄인다. [공식 CLI 환경변수 문서](https://docs.docker.com/reference/cli/docker/). + +```text +설정 해석 → 로컬 endpoint 확인 + → up 전에 기존 컨테이너의 서비스·작업 디렉터리 확인 + → --no-recreate로 대상 서비스 기동 + → 컨테이너 ID·포트·DB 초기 설정 대조 + → 앱이 사용할 호스트 TCP 포트 연결 확인 + → 같은 DB 계정·SSL·search_path로 SELECT만 실행 + → pgvector 0.8.1 사용 가능 여부 확인 +``` + +단순 localhost 판정만으로 SSH 터널과 실제 로컬 DB를 구분했다고 주장하지 않는다. +DB 점검은 Compose 식별·바인딩·연결·인증·vector를 함께 확인한다. pg_trgm은 Flyway 마이그레이션 뒤 준비되는 항목이다. + +## 서비스 범위 + +| 호출 | Compose | Ollama | +| --- | --- | --- | +| 인자 없는 기존 start/status | 기존 Valkey·BGE-M3 계약 | 기존 Native 기동·필수 판정 유지 | +| `start/status --scope search` | PostgreSQL·Valkey·BGE-M3 | 기동·API 호출·필수 판정 모두 생략 | +| `start/status --scope rag` | 같은 3개 서비스 | 사전 기동된 Native API·모델 재사용. 서비스 관리자·앱을 자동 기동하지 않음 | + +명시적 rag의 Native 서버가 준비되지 않았으면 Compose 기동 전에 실패한다. +실제 생성 모델 답변 시험은 준비 상태 확인과 별개다. diff --git a/docs/test-results/demo-readiness-20261018/README.md b/docs/test-results/demo-readiness-20261018/README.md new file mode 100644 index 00000000..e3069e63 --- /dev/null +++ b/docs/test-results/demo-readiness-20261018/README.md @@ -0,0 +1,46 @@ +# 로컬 설정·준비 범위 계약 검증 + +## 목적 + +앱과 점검 도구가 서로 다른 설정을 사용하는 문제를 줄이고, 명시적 search 범위가 Ollama를 요구하지 않도록 한다. +로컬 주소뿐 아니라 실제 Compose 자원을 대조하는 계약도 검사한다. + +## 테스트 환경 + +- 로컬 Python 3.9.6·Bash, 구현 `004768c`, 최종 시험 `c2a924a`. +- API·Docker 명령은 제어 대체물이다. 실제 Docker 기동·DB 접속·모델 호출은 0건이다. +- 원본 환경 파일이나 기존 실행 자원은 수정하지 않았다. + +## 테스트 방법 + +```bash +python3 -m unittest discover -s scripts/demo -p 'test_*.py' -v +bash -n scripts/local-services.sh +``` + +1. 임시 환경 파일에서 치환·중복·인용 오류와 원격 DB·저장소·설정 우회를 검사한다. +2. 컨테이너 출처·포트 불일치·호스트 포트 단절·DB 인증/확장 결과를 제어한다. +3. 실행 파일 대체물로 실제 Bash start/status 분기를 실행해 search의 Ollama 호출 0과 기존 계약을 확인한다. +4. 이전 시연 판정 회귀도 함께 실행한다. + +## 관측 결과 + +| 실행 ID | 목적 | 결과 | 근거 | +| --- | --- | --- | --- | +| demo-readiness-contract-01 | 초기 설정·범위 계약 | 51/51 통과 | [로그](demo-readiness-contract-01.log) | +| demo-readiness-shell-syntax-01 | Bash 문법 | 종료 코드 0 | [로그](demo-readiness-shell-syntax-01.log) | +| demo-readiness-contract-02 | context 우선순위·호스트 포트 보완 | 53/53 통과 | [로그](demo-readiness-contract-02.log) | +| demo-readiness-contract-03 | 기존 명령·Native 사전 준비 보완 | 55/55 통과 | [로그](demo-readiness-contract-03.log) | + +search 제어 기동은 PostgreSQL·Valkey·BGE-M3만 요청했고 Ollama 주소 호출은 0건이었다. +Native 미준비 rag는 Compose up 이전에 중단했다. 이는 실제 모델·독립 환경의 E2E 결과가 아니다. + +## 검증 과정에서 확인된 사항 + +- 기존 Bash는 자체적으로 `.env`를 읽지 않았고 Ollama를 항상 필수로 다뤘다. +- Docker CLI는 DOCKER_CONTEXT가 DOCKER_HOST보다 우선한다. 해당 순서와 이후 endpoint 고정을 별도 검사했다. +- 다른 작업공간의 컨테이너를 up 뒤에만 검사하면 늦을 수 있어, up 전 출처 확인과 --no-recreate를 적용했다. +- 컨테이너 내부 SQL만 성공하면 앱이 접속하는 호스트 포트 단절을 놓칠 수 있어 TCP 연결도 검사한다. +- PostgreSQL SELECT는 같은 사용자·비밀번호·SSL·search_path를 사용하고 비밀번호는 명령 인자로 넣지 않는다. +- 엄격한 설정 부분집합이므로 모호한 기존 파일은 자동 수정하지 않고 거부한다. +- 실제 서비스·원격 OpenSQL·Native Ollama·새 환경 시연은 이 회차에서 실행하지 않았다. 임시 제어 파일은 각 시험 종료 시 정리했다. diff --git a/docs/test-results/demo-readiness-20261018/demo-readiness-contract-01.log b/docs/test-results/demo-readiness-20261018/demo-readiness-contract-01.log new file mode 100644 index 00000000..717f80af --- /dev/null +++ b/docs/test-results/demo-readiness-20261018/demo-readiness-contract-01.log @@ -0,0 +1,65 @@ +실행 ID: demo-readiness-contract-01 +목적: 로컬 설정·컨테이너 식별·검색 범위 회귀 +환경: Python 3.9.6, 제어 명령·API 응답, 실제 Docker 변경 없음 +합격 기준: 51개 검사 통과, search의 Ollama 호출 0건 +명령: python3 -m unittest discover -s scripts/demo -p test_*.py -v +시작 시각: 2026-10-18T14:32:40+09:00 +test_changed_target_refuses_replay_before_upload (test_demo_verdict.DemoVerdictTest) ... ok +test_citation_outside_search_candidates_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_different_previous_document_id_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_download_hash_mismatch_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_empty_search_results_fail (test_demo_verdict.DemoVerdictTest) ... ok +test_event_redaction_happens_before_storage (test_demo_verdict.DemoVerdictTest) ... ok +test_existing_report_is_never_overwritten (test_demo_verdict.DemoVerdictTest) ... ok +test_full_search_scope_passes_without_rag_polling (test_demo_verdict.DemoVerdictTest) ... ok +test_invalid_candidate_is_not_a_valid_search (test_demo_verdict.DemoVerdictTest) ... ok +test_missing_expected_document_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_missing_query_id_is_not_a_valid_search (test_demo_verdict.DemoVerdictTest) ... ok +test_previous_document_ids_are_compared (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_failed_is_not_success (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_incomplete_is_not_success (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_scope_requires_success_and_valid_citations (test_demo_verdict.DemoVerdictTest) ... ok +test_search_http_error_fails_after_all_documents_indexed (test_demo_verdict.DemoVerdictTest) ... ok +test_table_text_missing_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_unrun_required_stages_cannot_pass (test_demo_verdict.DemoVerdictTest) ... ok +test_http_error_preserves_code_for_signup_decision (test_demo_verdict.HttpBoundaryTest) ... ok +test_oversized_original_is_rejected (test_demo_verdict.HttpBoundaryTest) ... ok +test_ambiguous_quotes_and_inline_comments_are_refused (test_local_config.LocalConfigTest) ... ok +test_container_from_another_checkout_is_refused (test_local_config.LocalConfigTest) ... ok +test_database_check_is_readonly_and_password_is_not_an_argument (test_local_config.LocalConfigTest) ... ok +test_defaults_match_local_quickstart (test_local_config.LocalConfigTest) ... ok +test_disabled_worker_is_refused (test_local_config.LocalConfigTest) ... ok +test_duplicate_key_is_refused (test_local_config.LocalConfigTest) ... ok +test_environment_overrides_file (test_local_config.LocalConfigTest) ... ok +test_foreign_container_is_refused_before_up (test_local_config.LocalConfigTest) ... ok +test_frontend_remote_or_different_api_is_refused (test_local_config.LocalConfigTest) ... ok +test_loopback_port_without_matching_container_is_refused (test_local_config.LocalConfigTest) ... ok +test_remote_database_and_model_are_refused (test_local_config.LocalConfigTest) ... ok +test_remote_docker_endpoint_is_refused (test_local_config.LocalConfigTest) ... ok +test_remote_profile_and_storage_are_refused (test_local_config.LocalConfigTest) ... ok +test_shell_substitution_is_refused_without_execution (test_local_config.LocalConfigTest) ... ok +test_spring_or_jvm_bypass_is_refused (test_local_config.LocalConfigTest) ... ok +test_storage_outside_checkout_is_refused (test_local_config.LocalConfigTest) ... ok +test_url_credentials_query_and_non_http_are_refused (test_local_config.LocalConfigTest) ... ok +test_wrong_extension_version_fails (test_local_config.LocalConfigTest) ... ok +test_search_start_never_calls_ollama (test_local_config.ServiceScopeTest) ... ok +test_claimed_or_retrying_job_is_processing (test_seed_demo_corpus.ClassifyTest) ... ok +test_failed_job (test_seed_demo_corpus.ClassifyTest) ... ok +test_indexed_document (test_seed_demo_corpus.ClassifyTest) ... ok +test_new_upload_waits_for_worker (test_seed_demo_corpus.ClassifyTest) ... ok +test_embedding_hint_first_when_server_not_ready (test_seed_demo_corpus.DiagnoseTest) ... ok +test_embedding_hint_for_failed_documents (test_seed_demo_corpus.DiagnoseTest) ... ok +test_no_hint_when_everything_indexed (test_seed_demo_corpus.DiagnoseTest) ... ok +test_slow_hint_when_worker_and_server_are_up (test_seed_demo_corpus.DiagnoseTest) ... ok +test_worker_hint_when_no_job_started (test_seed_demo_corpus.DiagnoseTest) ... ok +test_different_bytes_produce_different_keys (test_seed_demo_corpus.IdempotencyKeyTest) ... ok +test_same_bytes_produce_same_valid_key (test_seed_demo_corpus.IdempotencyKeyTest) ... ok +test_committed_corpus_is_complete (test_seed_demo_corpus.ManifestTest) ... ok + +---------------------------------------------------------------------- +Ran 51 tests in 1.219s + +OK +종료 시각: 2026-10-18T14:32:42+09:00 +종료 코드: 0 +소요 시간: 1.315초 diff --git a/docs/test-results/demo-readiness-20261018/demo-readiness-contract-02.log b/docs/test-results/demo-readiness-20261018/demo-readiness-contract-02.log new file mode 100644 index 00000000..f2f1ad7f --- /dev/null +++ b/docs/test-results/demo-readiness-20261018/demo-readiness-contract-02.log @@ -0,0 +1,67 @@ +실행 ID: demo-readiness-contract-02 +목적: Docker context 우선순위·실제 호스트 포트·DB 설정 정합성 최종 회귀 +환경: Python 3.9.6, 제어 명령·API 응답 +합격 기준: 53개 검사 통과, 실제 Docker 변경·모델 호출 0건 +명령: python3 -m unittest discover -s scripts/demo -p test_*.py -v +시작 시각: 2026-10-18T14:36:38+09:00 +test_changed_target_refuses_replay_before_upload (test_demo_verdict.DemoVerdictTest) ... ok +test_citation_outside_search_candidates_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_different_previous_document_id_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_download_hash_mismatch_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_empty_search_results_fail (test_demo_verdict.DemoVerdictTest) ... ok +test_event_redaction_happens_before_storage (test_demo_verdict.DemoVerdictTest) ... ok +test_existing_report_is_never_overwritten (test_demo_verdict.DemoVerdictTest) ... ok +test_full_search_scope_passes_without_rag_polling (test_demo_verdict.DemoVerdictTest) ... ok +test_invalid_candidate_is_not_a_valid_search (test_demo_verdict.DemoVerdictTest) ... ok +test_missing_expected_document_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_missing_query_id_is_not_a_valid_search (test_demo_verdict.DemoVerdictTest) ... ok +test_previous_document_ids_are_compared (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_failed_is_not_success (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_incomplete_is_not_success (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_scope_requires_success_and_valid_citations (test_demo_verdict.DemoVerdictTest) ... ok +test_search_http_error_fails_after_all_documents_indexed (test_demo_verdict.DemoVerdictTest) ... ok +test_table_text_missing_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_unrun_required_stages_cannot_pass (test_demo_verdict.DemoVerdictTest) ... ok +test_http_error_preserves_code_for_signup_decision (test_demo_verdict.HttpBoundaryTest) ... ok +test_oversized_original_is_rejected (test_demo_verdict.HttpBoundaryTest) ... ok +test_ambiguous_quotes_and_inline_comments_are_refused (test_local_config.LocalConfigTest) ... ok +test_container_from_another_checkout_is_refused (test_local_config.LocalConfigTest) ... ok +test_database_check_is_readonly_and_password_is_not_an_argument (test_local_config.LocalConfigTest) ... ok +test_defaults_match_local_quickstart (test_local_config.LocalConfigTest) ... ok +test_disabled_worker_is_refused (test_local_config.LocalConfigTest) ... ok +test_docker_context_takes_precedence_over_host (test_local_config.LocalConfigTest) ... ok +test_duplicate_key_is_refused (test_local_config.LocalConfigTest) ... ok +test_environment_overrides_file (test_local_config.LocalConfigTest) ... ok +test_foreign_container_is_refused_before_up (test_local_config.LocalConfigTest) ... ok +test_frontend_remote_or_different_api_is_refused (test_local_config.LocalConfigTest) ... ok +test_loopback_port_without_matching_container_is_refused (test_local_config.LocalConfigTest) ... ok +test_missing_host_port_is_not_ready_even_if_container_runs (test_local_config.LocalConfigTest) ... ok +test_remote_database_and_model_are_refused (test_local_config.LocalConfigTest) ... ok +test_remote_docker_endpoint_is_refused (test_local_config.LocalConfigTest) ... ok +test_remote_profile_and_storage_are_refused (test_local_config.LocalConfigTest) ... ok +test_shell_substitution_is_refused_without_execution (test_local_config.LocalConfigTest) ... ok +test_spring_or_jvm_bypass_is_refused (test_local_config.LocalConfigTest) ... ok +test_storage_outside_checkout_is_refused (test_local_config.LocalConfigTest) ... ok +test_url_credentials_query_and_non_http_are_refused (test_local_config.LocalConfigTest) ... ok +test_wrong_extension_version_fails (test_local_config.LocalConfigTest) ... ok +test_search_start_never_calls_ollama (test_local_config.ServiceScopeTest) ... ok +test_claimed_or_retrying_job_is_processing (test_seed_demo_corpus.ClassifyTest) ... ok +test_failed_job (test_seed_demo_corpus.ClassifyTest) ... ok +test_indexed_document (test_seed_demo_corpus.ClassifyTest) ... ok +test_new_upload_waits_for_worker (test_seed_demo_corpus.ClassifyTest) ... ok +test_embedding_hint_first_when_server_not_ready (test_seed_demo_corpus.DiagnoseTest) ... ok +test_embedding_hint_for_failed_documents (test_seed_demo_corpus.DiagnoseTest) ... ok +test_no_hint_when_everything_indexed (test_seed_demo_corpus.DiagnoseTest) ... ok +test_slow_hint_when_worker_and_server_are_up (test_seed_demo_corpus.DiagnoseTest) ... ok +test_worker_hint_when_no_job_started (test_seed_demo_corpus.DiagnoseTest) ... ok +test_different_bytes_produce_different_keys (test_seed_demo_corpus.IdempotencyKeyTest) ... ok +test_same_bytes_produce_same_valid_key (test_seed_demo_corpus.IdempotencyKeyTest) ... ok +test_committed_corpus_is_complete (test_seed_demo_corpus.ManifestTest) ... ok + +---------------------------------------------------------------------- +Ran 53 tests in 0.771s + +OK +종료 시각: 2026-10-18T14:36:39+09:00 +종료 코드: 0 +소요 시간: 0.875초 diff --git a/docs/test-results/demo-readiness-20261018/demo-readiness-contract-03.log b/docs/test-results/demo-readiness-20261018/demo-readiness-contract-03.log new file mode 100644 index 00000000..82ec65e2 --- /dev/null +++ b/docs/test-results/demo-readiness-20261018/demo-readiness-contract-03.log @@ -0,0 +1,69 @@ +실행 ID: demo-readiness-contract-03 +목적: 기존 명령 호환성과 명시적 범위의 최종 회귀 +환경: Python 3.9.6, 제어 API·명령 대체물, c2a924a +합격 기준: 55개 검사 통과, 실제 Docker 자원 변경 없음 +명령: python3 -m unittest discover -s scripts/demo -p test_*.py -v +시작 시각: 2026-10-18T14:39:00+09:00 +test_changed_target_refuses_replay_before_upload (test_demo_verdict.DemoVerdictTest) ... ok +test_citation_outside_search_candidates_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_different_previous_document_id_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_download_hash_mismatch_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_empty_search_results_fail (test_demo_verdict.DemoVerdictTest) ... ok +test_event_redaction_happens_before_storage (test_demo_verdict.DemoVerdictTest) ... ok +test_existing_report_is_never_overwritten (test_demo_verdict.DemoVerdictTest) ... ok +test_full_search_scope_passes_without_rag_polling (test_demo_verdict.DemoVerdictTest) ... ok +test_invalid_candidate_is_not_a_valid_search (test_demo_verdict.DemoVerdictTest) ... ok +test_missing_expected_document_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_missing_query_id_is_not_a_valid_search (test_demo_verdict.DemoVerdictTest) ... ok +test_previous_document_ids_are_compared (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_failed_is_not_success (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_incomplete_is_not_success (test_demo_verdict.DemoVerdictTest) ... ok +test_rag_scope_requires_success_and_valid_citations (test_demo_verdict.DemoVerdictTest) ... ok +test_search_http_error_fails_after_all_documents_indexed (test_demo_verdict.DemoVerdictTest) ... ok +test_table_text_missing_fails (test_demo_verdict.DemoVerdictTest) ... ok +test_unrun_required_stages_cannot_pass (test_demo_verdict.DemoVerdictTest) ... ok +test_http_error_preserves_code_for_signup_decision (test_demo_verdict.HttpBoundaryTest) ... ok +test_oversized_original_is_rejected (test_demo_verdict.HttpBoundaryTest) ... ok +test_ambiguous_quotes_and_inline_comments_are_refused (test_local_config.LocalConfigTest) ... ok +test_container_from_another_checkout_is_refused (test_local_config.LocalConfigTest) ... ok +test_database_check_is_readonly_and_password_is_not_an_argument (test_local_config.LocalConfigTest) ... ok +test_defaults_match_local_quickstart (test_local_config.LocalConfigTest) ... ok +test_disabled_worker_is_refused (test_local_config.LocalConfigTest) ... ok +test_docker_context_takes_precedence_over_host (test_local_config.LocalConfigTest) ... ok +test_duplicate_key_is_refused (test_local_config.LocalConfigTest) ... ok +test_environment_overrides_file (test_local_config.LocalConfigTest) ... ok +test_foreign_container_is_refused_before_up (test_local_config.LocalConfigTest) ... ok +test_frontend_remote_or_different_api_is_refused (test_local_config.LocalConfigTest) ... ok +test_loopback_port_without_matching_container_is_refused (test_local_config.LocalConfigTest) ... ok +test_missing_host_port_is_not_ready_even_if_container_runs (test_local_config.LocalConfigTest) ... ok +test_remote_database_and_model_are_refused (test_local_config.LocalConfigTest) ... ok +test_remote_docker_endpoint_is_refused (test_local_config.LocalConfigTest) ... ok +test_remote_profile_and_storage_are_refused (test_local_config.LocalConfigTest) ... ok +test_shell_substitution_is_refused_without_execution (test_local_config.LocalConfigTest) ... ok +test_spring_or_jvm_bypass_is_refused (test_local_config.LocalConfigTest) ... ok +test_storage_outside_checkout_is_refused (test_local_config.LocalConfigTest) ... ok +test_url_credentials_query_and_non_http_are_refused (test_local_config.LocalConfigTest) ... ok +test_wrong_extension_version_fails (test_local_config.LocalConfigTest) ... ok +test_explicit_rag_without_ready_ollama_does_not_start_compose (test_local_config.ServiceScopeTest) ... ok +test_legacy_status_still_requires_ollama (test_local_config.ServiceScopeTest) ... ok +test_search_start_never_calls_ollama (test_local_config.ServiceScopeTest) ... ok +test_claimed_or_retrying_job_is_processing (test_seed_demo_corpus.ClassifyTest) ... ok +test_failed_job (test_seed_demo_corpus.ClassifyTest) ... ok +test_indexed_document (test_seed_demo_corpus.ClassifyTest) ... ok +test_new_upload_waits_for_worker (test_seed_demo_corpus.ClassifyTest) ... ok +test_embedding_hint_first_when_server_not_ready (test_seed_demo_corpus.DiagnoseTest) ... ok +test_embedding_hint_for_failed_documents (test_seed_demo_corpus.DiagnoseTest) ... ok +test_no_hint_when_everything_indexed (test_seed_demo_corpus.DiagnoseTest) ... ok +test_slow_hint_when_worker_and_server_are_up (test_seed_demo_corpus.DiagnoseTest) ... ok +test_worker_hint_when_no_job_started (test_seed_demo_corpus.DiagnoseTest) ... ok +test_different_bytes_produce_different_keys (test_seed_demo_corpus.IdempotencyKeyTest) ... ok +test_same_bytes_produce_same_valid_key (test_seed_demo_corpus.IdempotencyKeyTest) ... ok +test_committed_corpus_is_complete (test_seed_demo_corpus.ManifestTest) ... ok + +---------------------------------------------------------------------- +Ran 55 tests in 2.590s + +OK +종료 시각: 2026-10-18T14:39:03+09:00 +종료 코드: 0 +소요 시간: 2.686초 diff --git a/docs/test-results/demo-readiness-20261018/demo-readiness-shell-syntax-01.log b/docs/test-results/demo-readiness-20261018/demo-readiness-shell-syntax-01.log new file mode 100644 index 00000000..17d3f011 --- /dev/null +++ b/docs/test-results/demo-readiness-20261018/demo-readiness-shell-syntax-01.log @@ -0,0 +1,9 @@ +실행 ID: demo-readiness-shell-syntax-01 +목적: 서비스 범위 변경 후 Bash 문법 점검 +환경: 로컬 Bash, 서비스 기동 없음 +합격 기준: bash -n 종료 코드 0 +명령: bash -n scripts/local-services.sh +시작 시각: 2026-10-18T14:32:40+09:00 +종료 시각: 2026-10-18T14:32:40+09:00 +종료 코드: 0 +소요 시간: 0.004초