From 3a1766891f690cec85f4a1b634a08df6fdd5fd61 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 15:04:56 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=A1=9C=EC=BB=AC=20=EC=8B=9C?= =?UTF-8?q?=EC=97=B0=20=EC=8B=9C=EC=9E=91=C2=B7=EA=B2=80=EC=A6=9D=C2=B7?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=ED=86=B5=ED=95=A9=20(#555)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/demo.py | 679 +++++++++++++++++++++++++++++++ scripts/demo/local_config.py | 3 + scripts/demo/seed_demo_corpus.py | 10 +- 3 files changed, 691 insertions(+), 1 deletion(-) create mode 100755 scripts/demo.py diff --git a/scripts/demo.py b/scripts/demo.py new file mode 100755 index 00000000..87c07f81 --- /dev/null +++ b/scripts/demo.py @@ -0,0 +1,679 @@ +#!/usr/bin/env python3 +"""로컬 시연의 시작·프런트엔드 경유 검증·소유 자원 종료를 기존 도구로 연결한다.""" +from __future__ import annotations + +import argparse +import contextlib +import fcntl +import hashlib +import json +import os +import re +import secrets +import shutil +import signal +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts" / "demo")) +from local_config import ConfigError, LocalConfig +import seed_demo_corpus as seed + +SERVICES = ("postgres", "valkey", "embedding-server") + + +class DemoError(Exception): + """비밀값이나 임의 하위 명령 출력을 포함하지 않는 실행 관리 오류다.""" + + +def now(): + return datetime.now(timezone(timedelta(hours=9))).isoformat(timespec="seconds") + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest() + + +def write_json(path, value): + """운영 상태는 동일 디렉터리의 임시 파일을 원자 교체하고 실행 로그는 별도로 유지한다.""" + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + temporary = path.with_name(path.name + "." + uuid.uuid4().hex + ".tmp") + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as target: + json.dump(value, target, ensure_ascii=False, indent=2) + os.replace(temporary, path) + + +def read_json(path): + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + raise DemoError("실행 상태를 읽을 수 없습니다. 상태 파일을 임의로 덮어쓰지 않습니다.") from None + + +def identity(pid): + """자신이 기록한 PID의 그룹·시작 시각·명령 fingerprint를 재확인한다.""" + if type(pid) is not int or pid <= 1: + return None + result = subprocess.run(["ps", "-ww", "-p", str(pid), "-o", "pid=,pgid=,stat=,lstart=,args="], + capture_output=True, text=True, env={**os.environ, "LC_ALL": "C"}) + parts = result.stdout.split() + if result.returncode or len(parts) < 9 or "Z" in parts[2]: + return None + return {"pid": int(parts[0]), "pgid": int(parts[1]), "started": " ".join(parts[3:8]), + "commandHash": digest(" ".join(parts[8:]))} + + +def group_members(pgid): + result = subprocess.run(["ps", "-eo", "pid=,pgid=,stat="], capture_output=True, text=True) + if result.returncode: + raise DemoError("프로세스 그룹 상태를 확인할 수 없습니다.") + members = [] + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) == 3 and parts[1] == str(pgid) and "Z" not in parts[2]: + members.append(int(parts[0])) + return members + + +def port_open(port): + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.3): + return True + except OSError: + return False + + +def listener_owned(port, record): + """HTTP 성공 외에 실제 수신 프로세스가 소유 그룹에 속하는지도 확인한다.""" + result = subprocess.run(["lsof", "-nP", "-a", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"], + capture_output=True, text=True) + pids = set(result.stdout.split()) + if result.returncode or not pids: + return False + return all(p.isdigit() and (item := identity(int(p))) and item["pgid"] == record["pgid"] for p in pids) + + +def http_bytes(url): + try: + with urllib.request.build_opener(seed.NoRedirect).open(url, timeout=2) as response: + raw = response.read(1048577) + if response.status != 200 or len(raw) > 1048576: + return None + return raw + except (OSError, ValueError, urllib.error.URLError): + return None + + +def http_json(url): + try: + raw = http_bytes(url) + return json.loads(raw) if raw else None + except ValueError: + return None + + +def source_fingerprint(root): + """생성 산출물·개인 환경 파일을 제외하고 실제 실행 소스의 변경을 감지한다.""" + result = subprocess.run(["git", "ls-files", "-z"], cwd=root, capture_output=True, text=True) + if result.returncode: + raise DemoError("Git 작업공간에서 실행하세요.") + values = {} + for name in result.stdout.split("\0"): + if name and (name.startswith(("backend/", "frontend/", "scripts/demo")) or + name in {"docker-compose.yml", "scripts/local-services.sh"}): + path = root / name + if path.is_file(): + values[name] = hashlib.sha256(path.read_bytes()).hexdigest() + return digest(values) + + +def config_fingerprint(config): + keys = set(seed_key for seed_key in config.values if seed_key.startswith(( + "DB_", "REDIS_", "STORAGE_", "EMBEDDING_", "OLLAMA_"))) + keys.update(["SPRING_PROFILES_ACTIVE", "SERVER_PORT", "MANAGEMENT_PORT", "BACKEND_API_URL", + "NEXT_PUBLIC_BACKEND_WS_URL", "JWT_SECRET", "DOCKER_HOST"]) + return digest({key: config.values.get(key) for key in keys}) + + +def endpoint_reference(endpoint): + """Unix 소켓의 사용자 홈은 원문 사용자명 대신 상대 참조로 보관한다.""" + path = Path(endpoint.removeprefix("unix://")) + try: + return {"base": "home", "path": str(path.relative_to(Path.home()))} + except ValueError: + return {"base": "absolute", "path": str(path)} + + +class DemoRuntime: + """한 로컬 작업공간의 세션·소유 자원·취소 정리만 관리하며 업무 API 로직은 위임한다.""" + + def __init__(self, root=ROOT): + self.root = Path(root).resolve() + self.directory = self.root / ".demo" / "launcher" + self.active = self.directory / "active.json" + self.state = None + self.config = None + self.handles = {} + + @contextlib.contextmanager + def lock(self): + if (self.root / ".demo").is_symlink() or self.directory.is_symlink(): + raise DemoError("실행 상태 경로의 심볼릭 링크는 지원하지 않습니다.") + self.directory.mkdir(parents=True, exist_ok=True, mode=0o700) + fd = os.open(self.directory / "command.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise DemoError("다른 시작·검증·종료 명령이 실행 중입니다.") from None + yield + finally: + os.close(fd) + + def session_path(self, session_id): + if not isinstance(session_id, str) or not re.fullmatch(r"[a-f0-9]{32}", session_id): + raise DemoError("실행 세션 식별자가 잘못됐습니다.") + path = self.directory / "sessions" / session_id + if ((self.directory / "sessions").is_symlink() or path.is_symlink() or + self.directory.resolve() not in path.resolve().parents): + raise DemoError("세션 상태 경로의 외부 연결은 지원하지 않습니다.") + return path + + def save(self): + write_json(self.session_path(self.state["id"]) / "state.json", self.state) + write_json(self.active, {"id": self.state["id"]}) + + def component_path(self, component, suffix): + prefix = component + if component == "verify": + operation = self.state.get("verification", {}).get("id") + if not isinstance(operation, str) or not re.fullmatch(r"[a-f0-9]{32}", operation): + raise DemoError("검증 실행 식별자가 잘못됐습니다.") + prefix += "-" + operation + return self.session_path(self.state["id"]) / (prefix + suffix) + + def load(self): + if (self.root / ".demo").is_symlink() or self.directory.is_symlink() or self.active.is_symlink(): + raise DemoError("실행 상태 경로의 외부 연결은 지원하지 않습니다.") + if not self.active.exists(): + return None + active = read_json(self.active) + self.state = read_json(self.session_path(active["id"]) / "state.json") + # 자식이 먼저 기록하므로 부모가 갑자기 종료돼도 알려진 세션의 소유 PID를 회수할 수 있다. + for name in ["install", "services", "backend", "frontend", "verify"]: + if name == "verify" and not self.state.get("verification"): + continue + path = self.component_path(name, ".pid.json") + if path.exists(): + self.state.setdefault("processes", {})[name] = read_json(path) + return self.state + + def event(self, message): + safe = seed.safe_text(message) + print(safe, flush=True) + if self.state: + path = self.session_path(self.state["id"]) / "events.jsonl" + with path.open("a", encoding="utf-8") as target: + target.write(json.dumps({"timestamp": now(), "message": safe}, ensure_ascii=False) + "\n") + + def prepare_files(self): + for name, template in [(".env", ".env.example"), ("frontend/.env.local", "frontend/.env.example")]: + path = self.root / name + if path.exists() or path.is_symlink(): + continue + text = (self.root / template).read_text(encoding="utf-8") + if name == ".env": + text += "\nJWT_SECRET=" + secrets.token_urlsafe(48) + "\n" + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as target: + target.write(text) + + def check_tools(self): + for name in ["git", "docker", "java", "node", "npm", "curl", "lsof", "ps", "bash"]: + if not shutil.which(name): + raise DemoError(f"필수 실행 도구가 없습니다: {name}") + node = subprocess.run(["node", "--version"], capture_output=True, text=True).stdout.strip() + match = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)", node) + if not match or tuple(map(int, match.groups())) < (22, 13, 0): + raise DemoError("Node.js 22.13.0 이상이 필요합니다.") + java = subprocess.run(["java", "-version"], capture_output=True, text=True) + if not re.search(r'version "17[.\"]', java.stderr + java.stdout): + raise DemoError("JAVA_HOME·PATH에 Java 17을 준비하세요.") + + def snapshot(self): + output = {} + for service in SERVICES: + ps = self.config.command(self.config.compose("ps", "--all", "--quiet", service)) + if ps.returncode: + raise DemoError("Compose 자원 상태를 확인할 수 없습니다.") + if not ps.stdout.strip(): + output[service] = None + else: + data = self.config.container(service) + output[service] = {"id": data["Id"], "running": data["State"]["Running"]} + return output + + def reconcile_containers(self): + current = self.snapshot() + for service, item in current.items(): + before = self.state["before"].get(service) + if before and item and before["id"] != item["id"]: + raise DemoError("기동 중 컨테이너 식별자가 변경됐습니다. 자동 교체하지 않습니다.") + if item: + self.state["containers"][service] = {**item, "owned": not before or not before["running"]} + self.save() + + def spawn(self, component): + env = {**self.config.values} + if component != "verify": + env.pop("DOCGRID_DEMO_PASSWORD", None) + proc = subprocess.Popen([sys.executable, str(self.root / "scripts/demo.py"), "_serve", + self.state["id"], component], cwd=self.root, env=env, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + self.handles[component] = proc + provisional = identity(proc.pid) + if not provisional or provisional["pgid"] != proc.pid: + proc.terminate() + raise DemoError("전용 프로세스 식별정보를 기록할 수 없습니다.") + self.state["processes"][component] = provisional + self.save() + path = self.component_path(component, ".pid.json") + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and not path.exists() and proc.poll() is None: + time.sleep(0.05) + if not path.exists(): + raise DemoError(f"전용 실행 프로세스가 준비되지 않았습니다: {component}") + record = read_json(path) + if record.get("pid") != proc.pid or record.get("pgid") != proc.pid: + raise DemoError("전용 프로세스 그룹을 확인할 수 없습니다.") + self.state["processes"][component] = record + self.save() + return record + + def wait(self, predicate, timeout, label, component=None): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + if component: + outcome = self.component_path(component, ".exit.json") + record = self.state.get("processes", {}).get(component) + if outcome.exists() or (record and identity(record.get("pid")) != record): + raise DemoError(f"준비 전에 실행이 종료됐습니다: {label}") + time.sleep(0.5) + raise DemoError(f"준비 제한 시간을 초과했습니다: {label}") + + def wait_command(self, component, timeout): + path = self.component_path(component, ".exit.json") + self.wait(path.exists, timeout, component, component) + code = read_json(path)["code"] + if code: + relative = self.component_path(component, ".log").relative_to(self.root) + raise DemoError(f"하위 명령 실패: {component} (코드 {code}), 기록: {relative}") + + def backend_ready(self): + record = self.state["processes"].get("backend", {}) + body = http_json(f"http://127.0.0.1:{self.config.values['MANAGEMENT_PORT']}/actuator/health/readiness") + return (body and body.get("status") == "UP" and identity(record.get("pid")) == record and + listener_owned(8080, record) and listener_owned(int(self.config.values["MANAGEMENT_PORT"]), record)) + + def frontend_ready(self): + record = self.state["processes"].get("frontend", {}) + if identity(record.get("pid")) != record or not listener_owned(3000, record): + return False + direct = http_json("http://127.0.0.1:8080/departments") + proxied = http_json("http://127.0.0.1:3000/api/backend/departments") + page = http_bytes("http://127.0.0.1:3000/login") + return bool(direct and proxied and direct.get("success") is True and proxied.get("success") is True and + proxied.get("data") == direct.get("data") and page and b"DocGrid" in page) + + def start(self, scope, timeout): + self.check_tools() + self.prepare_files() + self.config = LocalConfig.load(self.root) + self.config.ensure_local_engine() + if len(self.config.values.get("JWT_SECRET", "").encode()) < 32: + raise DemoError("기존 환경 파일에 32바이트 이상의 JWT_SECRET을 준비하세요.") + if self.load() and self.state["status"] == "READY": + if self.state["scope"] != scope or not self.valid_configuration(): + raise DemoError("활성 세션과 요청 범위·설정이 다릅니다. 기존 세션 상태를 확인하세요.") + self.assert_ready() + self.event("[재사용] 이미 준비된 시연 세션입니다. Web: http://localhost:3000") + return 0 + if self.state and self.state["status"] != "STOPPED": + if self.stop() != 0: + raise DemoError("이전 실행을 안전하게 정리하지 못했습니다.") + self.config = LocalConfig.load(self.root) + self.config.ensure_local_engine() + for port in [8080, 3000, int(self.config.values["MANAGEMENT_PORT"])]: + if port_open(port): + raise DemoError(f"식별되지 않은 프로세스가 필요한 포트를 사용 중입니다: {port}") + self.config.preflight_containers() + before = self.snapshot() + engine = self.config.command(["docker", "info", "--format", "{{.ID}}"]) + if engine.returncode or not engine.stdout.strip(): + raise DemoError("Docker Engine 식별자를 확인할 수 없습니다.") + self.state = {"id": uuid.uuid4().hex, "status": "STARTING", "scope": scope, "createdAt": now(), + "configHash": config_fingerprint(self.config), "sourceHash": source_fingerprint(self.root), + "engineHash": digest(engine.stdout.strip()), + "endpoint": endpoint_reference(self.config.values["DOCKER_HOST"]), + "before": before, "containers": {}, "processes": {}, "timeout": timeout, + "managementPort": int(self.config.values["MANAGEMENT_PORT"])} + self.save() + try: + # 1. 기존 의존성을 검사하고 없는 경우에만 최초 npm ci를 연결한다. + self.event(f"[시작] 로컬 시연 세션 {self.state['id']} · 범위 {scope}") + if not (self.root / "frontend/node_modules").exists(): + self.spawn("install") + self.wait_command("install", timeout) + elif self.config.command(["npm", "--prefix", "frontend", "ls", "--depth=0"], timeout=60).returncode: + raise DemoError("기존 프런트엔드 의존성이 맞지 않습니다. npm ci로 준비한 뒤 재시도하세요.") + # 2. 기존 서비스 스크립트로 준비하고 컨테이너 전후 상태를 기록한다. + self.spawn("services") + self.wait_command("services", timeout + 30) + self.reconcile_containers() + # 3. 기존 공식 명령을 전용 그룹에서 실행하고 실제 수신 프로세스를 대조한다. + self.spawn("backend") + self.wait(self.backend_ready, timeout, "백엔드", "backend") + self.spawn("frontend") + self.wait(self.frontend_ready, timeout, "프런트엔드 API 프록시", "frontend") + self.state["status"] = "READY" + self.state["readyAt"] = now() + self.save() + self.event("[준비 완료] Web: http://localhost:3000 · verify로 예제 시연을 검사하세요.") + return 0 + except BaseException: + self.event("[기동 중단] 이번 실행이 시작한 자원을 정리합니다.") + try: + self.reconcile_containers() + except (ConfigError, DemoError): + self.event("[조치 필요] 일부 컨테이너의 소유권을 확인하지 못했습니다.") + self.stop() + raise + + def valid_configuration(self): + return (config_fingerprint(self.config) == self.state["configHash"] and + source_fingerprint(self.root) == self.state["sourceHash"]) + + def assert_ready(self): + if not self.valid_configuration() or not self.backend_ready() or not self.frontend_ready(): + raise DemoError("세션 설정·소스·실제 서버 소유권이 달라 자동 검증을 거부합니다.") + current = self.snapshot() + if any(not current[s] or current[s]["id"] != self.state["containers"].get(s, {}).get("id") for s in SERVICES): + raise DemoError("세션의 Compose 대상이 변경돼 자동 업로드를 거부합니다.") + self.config.check_database() + + def verify(self, timeout): + if not self.load() or self.state["status"] != "READY": + raise DemoError("먼저 start로 준비된 세션을 만드세요.") + self.config = LocalConfig.load(self.root) + self.config.ensure_local_engine() + self.assert_ready() + # 4. 프런트엔드 프록시를 경유하는 기존 검증 도구에만 계정 비밀번호를 전달한다. + password = seed.read_password(self.config.values.get("DOCGRID_DEMO_EMAIL", "demo@docgrid.local")) + self.config.values["DOCGRID_DEMO_PASSWORD"] = password + report = ".demo/reports/" + uuid.uuid4().hex + context = digest([self.state["engineHash"], self.state["containers"]["postgres"]["id"]]) + self.state["verification"] = {"id": uuid.uuid4().hex, "report": report, "context": context, "timeout": timeout} + previous = self.directory / "last-report.json" + if previous.exists(): + item = read_json(previous) + if item.get("context") == context: + self.state["verification"]["previous"] = item["report"] + self.save() + self.spawn("verify") + try: + self.wait_command("verify", timeout + 180) + except BaseException: + self.stop_process("verify") + self.event("[검증 실패·중단] 필수 단계가 완료되지 않았습니다. 서버는 상태 확인 후 stop으로 종료하세요.") + raise + summary = read_json(self.root / report / "summary.json") + if summary.get("exitCode") != 0: + raise DemoError("필수 검증이 통과하지 않았습니다.") + write_json(previous, {"context": context, "report": report + "/summary.json"}) + self.event(f"[검증 통과] 업로드·인덱싱·검색·원본 검사 완료. 기록: {report}/summary.json") + return 0 + + def stop_process(self, name): + record = self.state.get("processes", {}).get(name) + if name == "verify" and not self.state.get("verification"): + return True + pid_file = self.component_path(name, ".pid.json") + if pid_file.exists(): + record = read_json(pid_file) + if not isinstance(record, dict): + return False + if record is None: + return True + if (type(record.get("pid")) is not int or record["pid"] <= 1 or + record.get("pgid") != record["pid"] or not record.get("started") or not record.get("commandHash")): + return False + current = identity(record.get("pid")) + if current is None: + handle = self.handles.get(name) + if handle and handle.poll() is not None: + handle.wait() + return not group_members(record.get("pgid")) + if current != record or record["pgid"] != record["pid"]: + self.event(f"[조치 필요] 식별정보가 달라 자동 종료하지 않습니다: {name}") + return False + members = {pid: identity(pid) for pid in group_members(record["pgid"])} + os.killpg(record["pgid"], signal.SIGTERM) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and group_members(record["pgid"]): + time.sleep(0.2) + remaining = group_members(record["pgid"]) + if remaining: + if not all(pid in members and identity(pid) == members[pid] for pid in remaining): + return False + os.killpg(record["pgid"], signal.SIGKILL) + time.sleep(0.2) + released = not group_members(record["pgid"]) + handle = self.handles.get(name) + if released and handle: + handle.wait(timeout=2) + return released + + def stop(self): + if not self.state and not self.load(): + print("[종료] 실행 세션이 없습니다.") + return 0 + if self.state["status"] == "STOPPED": + self.event("[종료] 이미 종료된 세션입니다.") + return 0 + released = True + if self.state["status"] == "STARTING": + try: + candidate = LocalConfig.load(self.root) + candidate.ensure_local_engine() + if config_fingerprint(candidate) != self.state["configHash"]: + raise DemoError("중단된 기동의 설정이 변경됐습니다.") + self.config = candidate + self.reconcile_containers() + except (ConfigError, DemoError, KeyError): + released = False + self.event("[조치 필요] 중단된 기동의 컨테이너 전후 상태를 확인하지 못했습니다.") + for name in ["verify", "frontend", "backend", "services", "install"]: + try: + released = self.stop_process(name) and released + except (DemoError, OSError, ValueError, KeyError): + released = False + self.event(f"[조치 필요] 소유 프로세스 종료를 확인하지 못했습니다: {name}") + # Docker 종료는 저장된 endpoint·Engine ID·컨테이너 ID·작업공간을 모두 다시 대조한다. + reference = self.state["endpoint"] + socket_path = (Path.home() / reference["path"] if reference["base"] == "home" else Path(reference["path"])) + env = {k:v for k,v in os.environ.items() if k != "DOCKER_CONTEXT"} + env["DOCKER_HOST"] = "unix://" + str(socket_path) + try: + engine = subprocess.run(["docker", "info", "--format", "{{.ID}}"], env=env, + capture_output=True, text=True, timeout=15) + same_engine = engine.returncode == 0 and digest(engine.stdout.strip()) == self.state["engineHash"] + except (OSError, subprocess.TimeoutExpired): + same_engine = False + for service, item in self.state.get("containers", {}).items(): + if not item.get("owned"): + continue + if not same_engine: + released = False + continue + try: + if service not in SERVICES or not re.fullmatch(r"[a-f0-9]{64}", item["id"]): + raise DemoError("기록된 컨테이너 식별자가 잘못됐습니다.") + inspected = subprocess.run(["docker", "inspect", item["id"]], env=env, + capture_output=True, text=True, timeout=15) + if inspected.returncode: + released = False + continue + data = json.loads(inspected.stdout)[0] + labels = data["Config"].get("Labels") or {} + if (data["Id"] != item["id"] or labels.get("com.docker.compose.service") != service or + Path(labels.get("com.docker.compose.project.working_dir", "")).resolve() != self.root): + released = False + continue + result = subprocess.run(["docker", "stop", "--time", "15", item["id"]], env=env, + capture_output=True, text=True, timeout=25) + checked = subprocess.run(["docker", "inspect", item["id"]], env=env, + capture_output=True, text=True, timeout=15) + stopped = checked.returncode == 0 and not json.loads(checked.stdout)[0]["State"]["Running"] + released = released and result.returncode == 0 and stopped + except (DemoError, OSError, ValueError, KeyError, IndexError, subprocess.TimeoutExpired): + released = False + self.event(f"[조치 필요] 소유 컨테이너의 종료를 확인하지 못했습니다: {service}") + for name, port in [("backend", 8080), ("backend", self.state.get("managementPort", 8081)), + ("frontend", 3000)]: + if name in self.state.get("processes", {}) and port_open(port): + released = False + self.state["status"] = "STOPPED" if released else "NEEDS_ATTENTION" + self.state["stoppedAt"] = now() + self.save() + self.event("[종료 완료] 소유 서버·컨테이너 중지, 데이터·원본·모델 캐시 보존" if released else + "[조치 필요] 종료를 확인하지 못한 자원이 있습니다. 다른 프로세스를 강제로 종료하지 않았습니다.") + reused = sum(not item.get("owned") for item in self.state.get("containers", {}).values()) + if reused: + self.event(f"[재사용 유지] 실행 전에 켜져 있던 Compose 서비스 {reused}개") + if self.state["scope"] == "rag": + self.event("[재사용 유지] Native Ollama는 외부 관리 서버로 유지합니다.") + return 0 if released else 1 + + def status(self): + if not self.load(): + print("[상태] 실행 세션이 없습니다.") + return 0 + print(f"[기록된 상태] {self.state['status']} · 범위 {self.state['scope']}") + print(f"[기록] .demo/launcher/sessions/{self.state['id']}/") + for name in ["backend", "frontend"]: + record = self.state.get("processes", {}).get(name) + print(f"[{name}] {'소유 프로세스 확인' if record and identity(record.get('pid')) == record else '미실행·확인 필요'}") + if self.state["status"] == "READY": + try: + self.config = LocalConfig.load(self.root) + self.config.ensure_local_engine() + self.assert_ready() + except (ConfigError, DemoError, OSError, KeyError): + print("[현재 상태] 준비 확인 실패·설정 변경. verify를 성공으로 처리하지 않습니다.") + return 1 + print("[현재 상태] 서버·API 프록시·대상 DB 준비 확인") + return 0 if self.state["status"] in {"READY", "STOPPED"} else 1 + + +def supervise(runtime, session_id, component): + """전용 그룹에 남아 기존 명령 출력을 저장 전에 정제하고 자식의 수명을 관리한다.""" + directory = runtime.session_path(session_id) + state = read_json(directory / "state.json") + runtime.state = state + if (state.get("id") != session_id or state.get("status") not in {"STARTING", "READY"} or + component not in {"install", "services", "backend", "frontend", "verify"}): + raise DemoError("소유 세션과 실행 구성요소가 일치하지 않습니다.") + write_json(runtime.component_path(component, ".pid.json"), identity(os.getpid())) + code = 2 + with runtime.component_path(component, ".log").open("x", encoding="utf-8") as output: + try: + config = LocalConfig.load(runtime.root) + config.ensure_local_engine() + for key, value in config.values.items(): + if any(word in key for word in ["PASSWORD", "SECRET", "TOKEN", "KEY"]) and value: + seed.SECRET_VALUES.append(value) + commands = { + "install": ["npm", "--prefix", "frontend", "ci"], + "services": ["bash", "scripts/local-services.sh", "start", "--scope", state["scope"]], + "backend": [str(runtime.root / "backend/gradlew"), "-p", "backend", "--no-daemon", "bootRun"], + "frontend": ["npm", "--prefix", "frontend", "run", "dev"], + } + if component == "verify": + check = state["verification"] + report = runtime.root / check["report"] + if runtime.root / ".demo/reports" not in report.resolve().parents: + raise DemoError("검증 결과 경로가 잘못됐습니다.") + command = [sys.executable, "scripts/demo/seed_demo_corpus.py", "--base-url", + "http://127.0.0.1:3000/api/backend", "--embedding-url", config.values["EMBEDDING_SERVER_URL"], + "--scope", state["scope"], "--report-dir", str(report), "--context-id", check["context"], + "--timeout", str(check["timeout"])] + if check.get("previous"): + previous = (runtime.root / check["previous"]).resolve() + if runtime.root / ".demo/reports" not in previous.parents: + raise DemoError("이전 원장 경로가 잘못됐습니다.") + command += ["--previous-report", str(previous)] + else: + command = commands[component] + config.values["LOCAL_SERVICES_WAIT_TIMEOUT_SECONDS"] = str(state["timeout"]) + child = subprocess.Popen(command, cwd=runtime.root, env=config.values, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + encoding="utf-8", errors="replace") + for line in child.stdout: + line = line.replace(str(runtime.root), "<작업공간>").replace(str(Path.home()), "<사용자 홈>") + output.write(now() + " " + seed.safe_text(line)) + output.flush() + code = child.wait() + except Exception as error: + output.write(now() + " 하위 실행 오류: " + type(error).__name__ + "\n") + write_json(runtime.component_path(component, ".exit.json"), {"code": code, "finishedAt": now()}) + return code + + +def main(): + if len(sys.argv) == 4 and sys.argv[1] == "_serve": + return supervise(DemoRuntime(), sys.argv[2], sys.argv[3]) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["start", "verify", "status", "stop"]) + parser.add_argument("--scope", choices=["search", "rag"]) + parser.add_argument("--timeout", type=int, default=900) + args = parser.parse_args() + if not 1 <= args.timeout <= 3600: + parser.error("timeout은 1~3600초여야 합니다.") + if args.scope is not None and args.command != "start": + parser.error("scope는 start에서만 지정하며 verify는 세션 범위를 이어받습니다.") + runtime = DemoRuntime() + def cancelled(signum, frame): + raise KeyboardInterrupt() + signal.signal(signal.SIGTERM, cancelled) + try: + if args.command == "status": + return runtime.status() + with runtime.lock(): + if args.command == "start": + return runtime.start(args.scope or "search", args.timeout) + if args.command == "verify": + return runtime.verify(args.timeout) + return getattr(runtime, args.command)() + except (DemoError, ConfigError, OSError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired) as error: + print("[실패] " + seed.safe_text(str(error) if isinstance(error, (DemoError, ConfigError)) else type(error).__name__), + file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("[중단] 실행을 취소했습니다.", file=sys.stderr) + return 130 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/demo/local_config.py b/scripts/demo/local_config.py index 41566a44..0e6e2c7b 100644 --- a/scripts/demo/local_config.py +++ b/scripts/demo/local_config.py @@ -106,6 +106,9 @@ def load(cls, root: Path, environ=None): raise ConfigError("기본 로컬 Compose Valkey는 인증 없는 로컬 연결을 사용합니다.") if not values.get("DB_PASSWORD"): raise ConfigError("DB_PASSWORD를 설정하세요.") + if any(("\n" in value or "\r" in value) for key, value in values.items() + if any(word in key for word in ["PASSWORD", "SECRET", "TOKEN", "KEY"])): + raise ConfigError("출력 전 마스킹을 보장할 수 없는 여러 줄 비밀값은 지원하지 않습니다.") 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}") diff --git a/scripts/demo/seed_demo_corpus.py b/scripts/demo/seed_demo_corpus.py index c624e5ea..a373c4b7 100644 --- a/scripts/demo/seed_demo_corpus.py +++ b/scripts/demo/seed_demo_corpus.py @@ -52,6 +52,13 @@ class SeedError(Exception): """사용자가 바로 조치할 수 있는 실패 안내를 담는다.""" +class NoRedirect(urllib.request.HTTPRedirectHandler): + """검증 요청이 다른 서버로 리다이렉트돼 인증정보·파일을 전달하는 것을 막는다.""" + + def redirect_request(self, request, fp, code, message, headers, new_url): + return None + + def safe_text(message: str) -> str: """터미널·파일에 기록하기 전에 자격증명·이메일·서버 주소를 제거한다.""" for value in SECRET_VALUES: @@ -167,6 +174,7 @@ class DocGridClient: def __init__(self, base_url: str): self.base_url = base_url.rstrip("/") self.token: str | None = None + self.opener = urllib.request.build_opener(NoRedirect) def call_bytes(self, method: str, path: str, body: bytes | None = None, headers: dict[str, str] | None = None, timeout: float = 60, @@ -177,7 +185,7 @@ def call_bytes(self, method: str, path: str, body: bytes | None = None, request = urllib.request.Request(self.base_url + path, data=body, method=method, headers=outgoing) try: - with urllib.request.urlopen(request, timeout=timeout) as response: + with self.opener.open(request, timeout=timeout) as response: status, raw = response.status, response.read(limit + 1) except urllib.error.HTTPError as error: with error: From 3fd9eb74dfaf64641a5a4480dec53a250c631667 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 15:05:02 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test:=20=ED=86=B5=ED=95=A9=20=EC=8B=9C?= =?UTF-8?q?=EC=97=B0=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20=EC=86=8C?= =?UTF-8?q?=EC=9C=A0=EA=B6=8C=EA=B3=BC=20=EB=B3=B4=EC=A1=B4=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EA=B2=80=EC=A6=9D=20(#555)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/demo/test_demo_runtime.py | 233 ++++++++++++++++++++++++++++++ scripts/demo/test_demo_verdict.py | 14 +- scripts/demo/test_local_config.py | 3 + 3 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 scripts/demo/test_demo_runtime.py diff --git a/scripts/demo/test_demo_runtime.py b/scripts/demo/test_demo_runtime.py new file mode 100644 index 00000000..7cf225d2 --- /dev/null +++ b/scripts/demo/test_demo_runtime.py @@ -0,0 +1,233 @@ +"""제어 HTTP 프로세스로 소유권·종료·잠금·설정 보존을 검사하며 실제 Docker는 기동하지 않는다.""" +import contextlib +import importlib.util +import io +import json +import os +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import unittest +import uuid +from pathlib import Path +from unittest.mock import patch + +SCRIPT = Path(__file__).resolve().parents[1] / 'demo.py' +spec = importlib.util.spec_from_file_location('demo_runtime', SCRIPT) +demo = importlib.util.module_from_spec(spec) +spec.loader.exec_module(demo) + + +class RuntimeContractTest(unittest.TestCase): + """한시적 작업공간·프로세스만 만들어 생명주기와 실패 판정을 확인한다.""" + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + (self.root / 'scripts/demo').mkdir(parents=True) + (self.root / 'frontend').mkdir() + (self.root / 'backend').mkdir() + shutil.copyfile(SCRIPT, self.root / 'scripts/demo.py') + for name in ['local_config.py', 'seed_demo_corpus.py']: + shutil.copyfile(SCRIPT.parent / 'demo' / name, self.root / 'scripts/demo' / name) + (self.root / '.env').write_text('DB_PASSWORD=controlled-private-value\nJWT_SECRET=' + 'j' * 40 + '\n') + (self.root / '.env.example').write_text('DB_PASSWORD=controlled-private-value\n') + (self.root / 'frontend/.env.local').write_text('BACKEND_API_URL=http://localhost:8080\n') + (self.root / 'frontend/.env.example').write_text('BACKEND_API_URL=http://localhost:8080\n') + binary = self.root / 'bin' + binary.mkdir() + docker = binary / 'docker' + docker.write_text('#!/bin/sh\necho unix:///var/run/docker.sock\n') + docker.chmod(0o755) + self.environment = {'PATH': str(binary) + os.pathsep + os.environ['PATH'], + 'HOME': os.environ['HOME'], 'CONTROLLED_PORT_FILE': str(self.root / 'port')} + self.runtime = demo.DemoRuntime(self.root) + self.runtime.config = demo.LocalConfig.load(self.root, environ=self.environment) + self.runtime.config.ensure_local_engine() + self.runtime.state = {'id': uuid.uuid4().hex, 'status': 'STARTING', 'scope': 'search', + 'processes': {}, 'containers': {}, 'before': {}, 'timeout': 5, + 'endpoint': {'base': 'absolute', 'path': '/var/run/docker.sock'}, + 'engineHash': demo.digest('controlled-engine')} + self.runtime.save() + + def start_controlled_backend(self): + script = self.root / 'backend/gradlew' + script.write_text('''#!/usr/bin/env python3 +import os, subprocess, sys, time +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path +child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(120)']) +server = HTTPServer(('127.0.0.1', 0), BaseHTTPRequestHandler) +Path(os.environ['CONTROLLED_PORT_FILE']).write_text(str(server.server_port)) +print('controlled-ready', os.environ['DB_PASSWORD'], os.environ['JWT_SECRET'], 'user@example.invalid', flush=True) +server.serve_forever() +''') + script.chmod(0o755) + record = self.runtime.spawn('backend') + def cleanup(): + if demo.identity(record['pid']) == record: + with contextlib.suppress(ProcessLookupError): + os.killpg(record['pgid'], signal.SIGKILL) + handle = self.runtime.handles.get('backend') + if handle: + handle.wait(timeout=5) + self.addCleanup(cleanup) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and not (self.root / 'port').exists(): + time.sleep(0.05) + self.assertTrue((self.root / 'port').exists()) + return record, int((self.root / 'port').read_text()) + + def test_starting_component_records_actual_group_and_listener(self): + record, port = self.start_controlled_backend() + self.assertEqual(record, demo.identity(record['pid'])) + self.assertTrue(demo.listener_owned(port, record)) + self.assertFalse(demo.listener_owned(port, {**record, 'pgid': record['pgid'] + 100000})) + + def test_stop_releases_children_and_actual_server_port(self): + record, port = self.start_controlled_backend() + self.assertTrue(self.runtime.stop_process('backend')) + self.assertFalse(demo.port_open(port)) + self.assertEqual([], demo.group_members(record['pgid'])) + + def test_changed_identity_never_kills_live_process(self): + record, port = self.start_controlled_backend() + changed = {**record, 'started': 'controlled-stale-start'} + demo.write_json(self.runtime.component_path('backend', '.pid.json'), changed) + with contextlib.redirect_stdout(io.StringIO()): + self.assertFalse(self.runtime.stop_process('backend')) + self.assertTrue(demo.port_open(port)) + self.assertEqual(record, demo.identity(record['pid'])) + + def test_child_record_recovers_parent_missing_process_map(self): + record, port = self.start_controlled_backend() + self.runtime.state['processes'] = {} + self.runtime.save() + recovered = demo.DemoRuntime(self.root) + recovered.load() + self.assertEqual(record, recovered.state['processes']['backend']) + self.assertTrue(recovered.stop_process('backend')) + self.assertFalse(demo.port_open(port)) + + def test_supervisor_redacts_before_writing(self): + self.start_controlled_backend() + log = self.runtime.component_path('backend', '.log') + deadline = time.monotonic() + 3 + while time.monotonic() < deadline and (not log.exists() or 'controlled-ready' not in log.read_text()): + time.sleep(0.05) + text = log.read_text() + self.assertIn('controlled-ready', text) + self.assertNotIn('controlled-private-value', text) + self.assertNotIn('j' * 40, text) + self.assertNotIn('user@example.invalid', text) + + def test_verify_records_use_distinct_paths(self): + self.runtime.state['verification'] = {'id': uuid.uuid4().hex} + first = self.runtime.component_path('verify', '.exit.json') + demo.write_json(first, {'code': 0}) + self.runtime.state['verification'] = {'id': uuid.uuid4().hex} + second = self.runtime.component_path('verify', '.exit.json') + self.assertNotEqual(first, second) + self.assertTrue(first.exists()) + self.assertFalse(second.exists()) + + def test_existing_personal_environment_files_are_preserved(self): + paths = [self.root / '.env', self.root / 'frontend/.env.local'] + before = [(p.read_bytes(), p.stat().st_mtime_ns) for p in paths] + self.runtime.prepare_files() + self.assertEqual(before, [(p.read_bytes(), p.stat().st_mtime_ns) for p in paths]) + + def test_new_env_is_initialized_without_default_jwt(self): + (self.root / '.env').unlink() + self.runtime.prepare_files() + values = demo.LocalConfig.load(self.root, environ=self.environment).values + self.assertGreaterEqual(len(values['JWT_SECRET']), 32) + self.assertEqual(0o600, (self.root / '.env').stat().st_mode & 0o777) + + def test_concurrent_command_lock_refuses_second_process(self): + code = '''import importlib.util,sys +s=importlib.util.spec_from_file_location('controlled',sys.argv[1]);m=importlib.util.module_from_spec(s);s.loader.exec_module(m) +try: + with m.DemoRuntime(sys.argv[2]).lock(): print('unexpected-acquired') +except m.DemoError: raise SystemExit(3) +''' + with self.runtime.lock(): + result = subprocess.run([sys.executable, '-c', code, str(SCRIPT), str(self.root)], capture_output=True, text=True) + self.assertEqual(3, result.returncode, '동시 명령이 거부돼야 함') + + def test_changed_configuration_refuses_before_database_or_upload(self): + with patch.object(self.runtime, 'valid_configuration', return_value=False), \ + patch.object(self.runtime.config, 'check_database') as database: + with self.assertRaises(demo.DemoError): self.runtime.assert_ready() + database.assert_not_called() + + def test_frontend_proxy_comparison_ignores_response_timestamp(self): + record = {'pid': 123, 'pgid': 123, 'started': 'controlled', 'commandHash': 'controlled'} + self.runtime.state['processes']['frontend'] = record + bodies = [{'success': True, 'data': [1], 'timestamp': 'first'}, + {'success': True, 'data': [1], 'timestamp': 'second'}] + with patch.object(demo, 'identity', return_value=record), \ + patch.object(demo, 'listener_owned', return_value=True), \ + patch.object(demo, 'http_json', side_effect=bodies), \ + patch.object(demo, 'http_bytes', return_value=b'DocGrid login'): + self.assertTrue(self.runtime.frontend_ready()) + + def test_stop_preserves_reused_containers(self): + self.runtime.state['status'] = 'READY' + self.runtime.state['containers'] = {'postgres': {'id': 'controlled-id', 'owned': False}} + with patch.object(demo.subprocess, 'run', return_value= + subprocess.CompletedProcess([], 0, 'controlled-engine\n', '')) as command, \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(0, self.runtime.stop()) + self.assertFalse(any('stop' in call.args[0] for call in command.call_args_list)) + + def test_partial_start_failure_runs_cleanup(self): + self.runtime.state = None + with patch.object(self.runtime, 'check_tools'), patch.object(self.runtime, 'prepare_files'), \ + patch.object(self.runtime, 'load', return_value=None), \ + patch.object(demo.LocalConfig, 'load', return_value=self.runtime.config), \ + patch.object(self.runtime.config, 'preflight_containers'), \ + patch.object(self.runtime.config, 'command', return_value=subprocess.CompletedProcess([], 0, 'controlled-engine\n', '')), \ + patch.object(self.runtime, 'snapshot', return_value={s: None for s in demo.SERVICES}), \ + patch.object(demo, 'source_fingerprint', return_value='controlled'), \ + patch.object(demo, 'port_open', return_value=False), \ + patch.object(self.runtime, 'spawn', side_effect=demo.DemoError('controlled failure')), \ + patch.object(self.runtime, 'stop', return_value=0) as stop, \ + contextlib.redirect_stdout(io.StringIO()): + with self.assertRaises(demo.DemoError): self.runtime.start('search', 5) + stop.assert_called_once() + + def test_container_transition_preserves_reused_and_marks_newly_started(self): + self.runtime.state['before'] = {'postgres': {'id': 'same-db', 'running': True}, + 'valkey': {'id': 'same-cache', 'running': False}, 'embedding-server': None} + current = {'postgres': {'id': 'same-db', 'running': True}, + 'valkey': {'id': 'same-cache', 'running': True}, + 'embedding-server': {'id': 'new-model', 'running': True}} + with patch.object(self.runtime, 'snapshot', return_value=current): + self.runtime.reconcile_containers() + self.assertFalse(self.runtime.state['containers']['postgres']['owned']) + self.assertTrue(self.runtime.state['containers']['valkey']['owned']) + self.assertTrue(self.runtime.state['containers']['embedding-server']['owned']) + + def test_session_symlink_cannot_redirect_state_writes(self): + outside = self.root / 'outside' + outside.mkdir() + other_id = uuid.uuid4().hex + (self.runtime.directory / 'sessions' / other_id).symlink_to(outside, target_is_directory=True) + with self.assertRaises(demo.DemoError): self.runtime.session_path(other_id) + + def test_status_does_not_report_dead_ready_session_as_success(self): + self.runtime.state['status'] = 'READY' + self.runtime.save() + with patch.object(demo.LocalConfig, 'load', return_value=self.runtime.config), \ + patch.object(self.runtime, 'assert_ready', side_effect=demo.DemoError('controlled not ready')), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(1, self.runtime.status()) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/demo/test_demo_verdict.py b/scripts/demo/test_demo_verdict.py index 667a962c..e511dd2a 100644 --- a/scripts/demo/test_demo_verdict.py +++ b/scripts/demo/test_demo_verdict.py @@ -203,8 +203,9 @@ class HttpBoundaryTest(unittest.TestCase): def test_http_error_preserves_code_for_signup_decision(self): error = urllib.error.HTTPError("http://localhost:8080/auth/login", 401, "controlled", {}, io.BytesIO(b'{"code":"USER-004"}')) - with patch.object(seed.urllib.request, "urlopen", side_effect=error): - status, body = seed.DocGridClient("http://localhost:8080").call("POST", "/auth/login") + client = seed.DocGridClient("http://localhost:8080") + with patch.object(client.opener, "open", side_effect=error): + status, body = client.call("POST", "/auth/login") self.assertEqual((401, "USER-004"), (status, body["code"])) def test_oversized_original_is_rejected(self): @@ -212,9 +213,14 @@ def test_oversized_original_is_rejected(self): response.__enter__.return_value = response response.status = 200 response.read.return_value = b"12345" - with patch.object(seed.urllib.request, "urlopen", return_value=response): + client = seed.DocGridClient("http://localhost:8080") + with patch.object(client.opener, "open", return_value=response): with self.assertRaises(seed.SeedError): - seed.DocGridClient("http://localhost:8080").call_bytes("GET", "/file", limit=4) + client.call_bytes("GET", "/file", limit=4) + + def test_redirect_does_not_forward_credentials(self): + self.assertIsNone(seed.NoRedirect().redirect_request(None, None, 302, 'controlled', {}, + 'http://remote.invalid/')) if __name__ == "__main__": diff --git a/scripts/demo/test_local_config.py b/scripts/demo/test_local_config.py index 327e5366..5da1e6b8 100644 --- a/scripts/demo/test_local_config.py +++ b/scripts/demo/test_local_config.py @@ -80,6 +80,9 @@ def test_url_credentials_query_and_non_http_are_refused(self): def test_disabled_worker_is_refused(self): with self.assertRaises(ConfigError): self.load({'INDEXING_WORKER_ENABLED': 'false'}) + def test_multiline_secret_is_refused_before_logging(self): + with self.assertRaises(ConfigError): self.load({'JWT_SECRET': 'controlled\nprivate'}) + def container(self, port='55432', cwd=None): return {'Id': 'controlled-container', 'Config': {'Labels': { 'com.docker.compose.service': 'postgres', From a40fe5ee07139dbd22bd2b85edd70b5bc0b435c3 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 15:06:18 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=ED=86=B5=ED=95=A9=20=EC=8B=9C?= =?UTF-8?q?=EC=97=B0=20=EB=AA=85=EB=A0=B9=EA=B3=BC=20=EC=83=9D=EB=AA=85?= =?UTF-8?q?=EC=A3=BC=EA=B8=B0=20=EA=B2=80=EC=A6=9D=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?(#555)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 +++++ docs/design/local-demo-lifecycle.md | 75 ++++++++++++++++ .../demo-lifecycle-20261018/README.md | 42 +++++++++ .../demo-runtime-contract-01.log | 83 ++++++++++++++++++ .../demo-runtime-contract-02.log | 87 +++++++++++++++++++ 5 files changed, 310 insertions(+) create mode 100644 docs/design/local-demo-lifecycle.md create mode 100644 docs/test-results/demo-lifecycle-20261018/README.md create mode 100644 docs/test-results/demo-lifecycle-20261018/demo-runtime-contract-01.log create mode 100644 docs/test-results/demo-lifecycle-20261018/demo-runtime-contract-02.log diff --git a/README.md b/README.md index b0d625bd..7403f7c6 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,29 @@ Pub/Sub 신호는 재조회 계기이며, 내구성 있는 메시지 큐로 사 아래는 GCP 접근 권한 없이 로컬 PostgreSQL·Valkey와 Local Filesystem 저장소로 핵심 기능을 확인하는 경로입니다. 명령은 저장소 루트에서 실행합니다. +### 통합 명령으로 시작·검증·종료 + +macOS·Linux에서 아래 사전 준비 도구와 Python 3.9 이상, Bash·curl·lsof·ps를 준비한 뒤 실행합니다. + +```bash +./scripts/demo.py start +./scripts/demo.py verify +./scripts/demo.py stop +``` + +`start`는 없는 환경 파일만 만들고 PostgreSQL·Valkey·BGE-M3·백엔드·프런트엔드를 연결합니다. +Web은 `http://localhost:3000`이며, `verify`는 비밀번호를 입력받아 프런트엔드 API 프록시로 예제 15건의 +인덱싱·기대 검색 3개·원본 해시 15개를 확인합니다. 반복 실행은 이전 원장과 문서 ID도 대조합니다. +준비 상태는 `./scripts/demo.py status`로 다시 확인합니다. + +기본 범위 `search`는 Ollama가 필요 없습니다. 실제 생성 답변·인용까지 검증하려면 Native Ollama와 지정 +모델을 먼저 준비하고 `start --scope rag`를 사용합니다. Native Ollama는 이 명령이 자동 종료하지 않습니다. +기존 개인 환경 파일은 덮어쓰지 않으며, 원격 OpenSQL·GCS나 지원하지 않는 설정이면 자동 변경 없이 거부합니다. +`stop`은 이번 실행이 시작한 자원만 중지하고 DB·원본·모델 캐시를 보존합니다. 다른 작업공간의 고정 이름 +컨테이너가 있으면 별도 Docker 환경을 사용하세요. 상세 경계는 [통합 명령 설계](docs/design/local-demo-lifecycle.md)를 참고하세요. + +아래 개별 실행 절차도 계속 사용할 수 있습니다. + ### 1. 사전 준비 - Git diff --git a/docs/design/local-demo-lifecycle.md b/docs/design/local-demo-lifecycle.md new file mode 100644 index 00000000..b1370fdf --- /dev/null +++ b/docs/design/local-demo-lifecycle.md @@ -0,0 +1,75 @@ +# 로컬 시연 통합 명령 + +## 목적과 경계 + +`scripts/demo.py`가 기존 Compose·서비스 스크립트·Gradle·npm·예제 검증기를 연결한다. +업무 API나 인덱싱 구현을 새로 만들지 않는다. macOS·Linux의 POSIX 프로세스 그룹을 사용하며 Windows는 지원하지 않는다. +기본 범위는 search이고, Native Ollama가 이미 준비된 경우에만 start의 `--scope rag`를 선택한다. + +## 사용자 명령 + +```bash +./scripts/demo.py start +./scripts/demo.py verify +./scripts/demo.py status +./scripts/demo.py stop +``` + +Python 3.9 이상, Java 17, Node.js 22.13 이상, Docker Compose, Git·Bash·curl·lsof·ps가 필요하다. +처음에는 없는 환경 파일만 예제에서 만들고 JWT 서명 키를 무작위 생성한다. 기존 파일은 수정하지 않는다. +node_modules가 없으면 npm ci를 연결하고, 이미 있으면 npm ls로 확인한다. 맞지 않는 기존 의존성은 자동 교체하지 않는다. + +## 기동과 검증 + +```text +사용자 start + → 명령 잠금 획득: 다른 start/verify/stop과 동시 변경 거부 + → LocalConfig: local·파일 저장소·로컬 DB·고정 API/Web 포트 확인 + → 시작 전 컨테이너 ID와 실행 상태, Docker Engine ID 기록 + → local-services.sh start --scope search + → 전용 프로세스 그룹에서 Gradle bootRun + └ readiness UP + API/관리 포트의 실제 수신 프로세스 대조 + → 전용 그룹에서 npm run dev + └ 로그인 HTML + 직접/프록시 departments 결과 대조 + → READY 기록 + +사용자 verify + → 설정·소스 fingerprint, 서버 소유권, 컨테이너 ID, DB 재확인 + → 프런트엔드 /api/backend 프록시로 기존 예제 검증기 실행 + → 업로드·INDEXED·기대 검색·다운로드 해시 판정 + → 같은 DB 컨텍스트의 이전 원장과 문서 ID 대조 + → 매 실행을 별도 디렉터리에 기록 +``` + +search는 Ollama를 기동·필수 확인하지 않는다. 백엔드 자체 RAG Worker의 동작을 비활성화하는 설정은 아니며, +Ollama가 없으면 생성 답변은 실패 대체 경로를 사용한다. search 검증은 생성 답변을 미실행으로 표시한다. + +## 종료·취소와 보존 + +```text +stop 또는 start 도중 취소 + → PID + 시작 시각 + 명령 fingerprint + 프로세스 그룹 대조 + → 소유 그룹 TERM → 확인된 잔존 자식만 필요시 KILL + → 저장된 Docker endpoint·Engine·컨테이너 ID·Compose 작업공간 대조 + → 이번 실행이 시작한 서비스만 stop + → API·관리·Web 포트 해제 확인 + → STOPPED 또는 NEEDS_ATTENTION 기록 + +처음부터 실행 중이던 Compose → 재사용 후 유지 +Native Ollama → 외부 관리 서버로 유지 +DB 볼륨·원본·모델 캐시·이전 실행 기록 → 보존 +식별자가 다르거나 소유권 불명 → 자동 종료 거부, 조치 필요 표시 +``` + +자식 supervisor가 먼저 PID 파일을 기록해 부모 상태 저장 중단에도 회수할 수 있게 한다. +기동 중 중단된 세션은 컨테이너 전후 상태도 다시 대조한다. 같은 PID만으로 종료하지 않는다. +READY 기록이 있어도 status는 실제 준비 상태를 다시 검사하며, 검증 실패 후에는 서버를 남겨 원인을 확인할 수 있다. + +## 기록과 제한 + +- `.demo/launcher/sessions//`: 정제된 구성요소 로그·상태·이벤트. 운영 상태 JSON은 원자 교체한다. +- `.demo/reports//`: 예제 검증의 불변 실행별 원장. 반복 검증은 이전 로그를 덮어쓰지 않는다. +- 비밀값·이메일·URL은 supervisor에서 파일 기록 전에 마스킹한다. 환경 원문은 로그에 저장하지 않는다. +- 원격 OpenSQL·GCS나 다른 작업공간 Compose는 자동 수정하지 않고 거부한다. +- 프로세스 소유권·주소 검증은 로컬 시연 오작동 방지이며 악의적인 로컬 관리자에 대한 보안 격리는 아니다. +- 실제 모델·새 Docker 환경의 E2E는 별도 실행 기록으로 확인한다. 이 설계 자체는 실행 증거가 아니다. diff --git a/docs/test-results/demo-lifecycle-20261018/README.md b/docs/test-results/demo-lifecycle-20261018/README.md new file mode 100644 index 00000000..901c65fc --- /dev/null +++ b/docs/test-results/demo-lifecycle-20261018/README.md @@ -0,0 +1,42 @@ +# 통합 시연 명령의 생명주기 검증 + +## 목적 + +시작·검증·종료를 묶으면서 다른 작업의 서버나 개인 환경 파일을 변경하지 않는지 확인한다. +실제 HTTP 자식 프로세스와 포트로 종료 계약을 검사한다. + +## 테스트 환경 + +- 로컬 Python 3.9.6·POSIX·lsof·ps. 최종 구현 `3a17668`, 검사 `3fd9eb7`. +- 임시 작업공간의 제어 HTTP 서버·sleep 자식은 실제 프로세스다. +- Docker 명령·일부 API 상태는 제어 대체물이다. 실제 Docker 기동·DB 연결·BGE-M3 추론은 0회다. +- 공개 서비스와 사용자 실행 자원은 사용하지 않았다. + +## 테스트 방법 + +```bash +python3 -m unittest discover -s scripts/demo -p 'test_*.py' -v +``` + +전용 프로세스 그룹, 실제 listener 소유권, 자식 종료와 포트 해제, 잘못된 시작 정보의 종료 거부, +부모 상태가 누락된 PID 기록 회수, 동시 명령 거부, 부분 기동 실패 정리, 설정 변경 시 업로드 거부를 확인했다. +개인 환경 파일은 bytes와 수정 시각이 모두 유지되는지 검사했다. 원격 리다이렉트 거부와 여러 줄 비밀값 거부도 포함한다. + +## 관측 결과 + +| 실행 ID | 목적 | 관측 | 판정·근거 | +| --- | --- | --- | --- | +| demo-runtime-contract-01 | 최초 소유권·생명주기 회귀 | 69/69, 6.904초 | 통과 · [로그](demo-runtime-contract-01.log) | +| demo-runtime-contract-02 | 관리 포트·중단 컨테이너 회수·실제 status 보완 | 73/73, 8.166초 | 통과 · [로그](demo-runtime-contract-02.log) | + +HTTP 서버의 수신 프로세스와 그룹을 대조했고, 종료 후 자식 그룹과 실제 수신 포트가 사라졌다. +변경된 PID 식별정보에서는 서버가 계속 살아 있어 자동 종료를 거부함을 확인했다. + +## 검증 과정에서 확인된 사항 + +- PID만 기록하면 재사용된 PID를 오인할 수 있어 시작 시각·명령 fingerprint·그룹을 함께 대조한다. +- 반복 verify는 별도 operation ID를 사용한다. 이전 완료 파일 때문에 다음 실행이 즉시 성공하는 것을 방지한다. +- API 성공만으로 준비를 판정하지 않고 실제 수신 그룹과 관리 포트도 검사한다. +- 원문 비밀번호·JWT·이메일을 제어 자식 출력에 넣어 로그에 남기 전에 마스킹되는지 검사했다. +- 이 검사는 실제 Gradle·프런트엔드·Compose 전체 기동이나 독립 환경의 모델 E2E를 증명하지 않는다. +- 각 검사 임시 프로세스·파일을 정리했다. Native Ollama 실제 기동·종료는 실행하지 않았다. diff --git a/docs/test-results/demo-lifecycle-20261018/demo-runtime-contract-01.log b/docs/test-results/demo-lifecycle-20261018/demo-runtime-contract-01.log new file mode 100644 index 00000000..8a136ce7 --- /dev/null +++ b/docs/test-results/demo-lifecycle-20261018/demo-runtime-contract-01.log @@ -0,0 +1,83 @@ +실행 ID: demo-runtime-contract-01 +목적: 통합 실행의 소유권·잠금·실제 제어 프로세스 종료 회귀 +환경: 로컬 Python 3.9.6·POSIX·lsof, 임시 HTTP 서버와 명령 대체물 +합격 기준: 69개 검사 통과, 제어 서버 포트·자식 프로세스 정리, 실제 Docker 변경 없음 +명령: python3 -m unittest discover -s scripts/demo -p test_*.py -v +시작 시각: 2026-10-18T14:56:51+09:00 +test_changed_configuration_refuses_before_database_or_upload (test_demo_runtime.RuntimeContractTest) ... ok +test_changed_identity_never_kills_live_process (test_demo_runtime.RuntimeContractTest) ... ok +test_child_record_recovers_parent_missing_process_map (test_demo_runtime.RuntimeContractTest) ... ok +test_concurrent_command_lock_refuses_second_process (test_demo_runtime.RuntimeContractTest) ... ok +test_existing_personal_environment_files_are_preserved (test_demo_runtime.RuntimeContractTest) ... ok +test_frontend_proxy_comparison_ignores_response_timestamp (test_demo_runtime.RuntimeContractTest) ... ok +test_new_env_is_initialized_without_default_jwt (test_demo_runtime.RuntimeContractTest) ... ok +test_partial_start_failure_runs_cleanup (test_demo_runtime.RuntimeContractTest) ... ok +test_starting_component_records_actual_group_and_listener (test_demo_runtime.RuntimeContractTest) ... ok +test_stop_preserves_reused_containers (test_demo_runtime.RuntimeContractTest) ... ok +test_stop_releases_children_and_actual_server_port (test_demo_runtime.RuntimeContractTest) ... ok +test_supervisor_redacts_before_writing (test_demo_runtime.RuntimeContractTest) ... ok +test_verify_records_use_distinct_paths (test_demo_runtime.RuntimeContractTest) ... ok +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_redirect_does_not_forward_credentials (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 69 tests in 6.809s + +OK +종료 시각: 2026-10-18T14:56:58+09:00 +종료 코드: 0 +소요 시간: 6.904초 diff --git a/docs/test-results/demo-lifecycle-20261018/demo-runtime-contract-02.log b/docs/test-results/demo-lifecycle-20261018/demo-runtime-contract-02.log new file mode 100644 index 00000000..d1df111f --- /dev/null +++ b/docs/test-results/demo-lifecycle-20261018/demo-runtime-contract-02.log @@ -0,0 +1,87 @@ +실행 ID: demo-runtime-contract-02 +목적: 통합 명령의 소유권·중단 정리·설정 보존 회귀 +환경: 로컬 Python 3.9.6·POSIX 제어 HTTP 자식 프로세스, Docker 실제 기동 0회 +합격 기준: 전체 Python 검사 통과, 다른 자원 종료 없음 +명령: python3 -m unittest discover -s scripts/demo -p test_*.py -v +시작 시각: 2026-10-18T15:04:40+09:00 +test_changed_configuration_refuses_before_database_or_upload (test_demo_runtime.RuntimeContractTest) ... ok +test_changed_identity_never_kills_live_process (test_demo_runtime.RuntimeContractTest) ... ok +test_child_record_recovers_parent_missing_process_map (test_demo_runtime.RuntimeContractTest) ... ok +test_concurrent_command_lock_refuses_second_process (test_demo_runtime.RuntimeContractTest) ... ok +test_container_transition_preserves_reused_and_marks_newly_started (test_demo_runtime.RuntimeContractTest) ... ok +test_existing_personal_environment_files_are_preserved (test_demo_runtime.RuntimeContractTest) ... ok +test_frontend_proxy_comparison_ignores_response_timestamp (test_demo_runtime.RuntimeContractTest) ... ok +test_new_env_is_initialized_without_default_jwt (test_demo_runtime.RuntimeContractTest) ... ok +test_partial_start_failure_runs_cleanup (test_demo_runtime.RuntimeContractTest) ... ok +test_session_symlink_cannot_redirect_state_writes (test_demo_runtime.RuntimeContractTest) ... ok +test_starting_component_records_actual_group_and_listener (test_demo_runtime.RuntimeContractTest) ... ok +test_status_does_not_report_dead_ready_session_as_success (test_demo_runtime.RuntimeContractTest) ... ok +test_stop_preserves_reused_containers (test_demo_runtime.RuntimeContractTest) ... ok +test_stop_releases_children_and_actual_server_port (test_demo_runtime.RuntimeContractTest) ... ok +test_supervisor_redacts_before_writing (test_demo_runtime.RuntimeContractTest) ... ok +test_verify_records_use_distinct_paths (test_demo_runtime.RuntimeContractTest) ... ok +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_redirect_does_not_forward_credentials (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_multiline_secret_is_refused_before_logging (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 73 tests in 8.083s + +OK +종료 시각: 2026-10-18T15:04:48+09:00 +종료 코드: 0 +소요 시간: 8.166초