diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..f69a280c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,50 @@ +# Source control and local instructions +.git +.github +AGENTS.md +.codewhale/ +.vscode/ +.idea/ +.DS_Store + +# Python environments, caches, and test output +venv/ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Local runtime stores and derived indexes +chroma_memory/ +chroma_index/ +graphify-out/ +*.sqlite +*.sqlite3 +*.sqlite-* +*.sqlite3-* +*.db +*.db-* +*.log +.kyrozen/ +.kyrozen_config.json + +# Build and packaging output +build/ +dist/ +*.egg-info/ + +# Credentials and local configuration +.env +.env.* +*.pem +*.key +*.crt +*.cer +*.p12 +*.pfx +credentials* +secrets* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b57207f..0b387581 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,5 +48,12 @@ jobs: run: | python -c "from providers import *; print('All providers imported OK')" - - name: Docker build check - run: docker build -t openkyrozen-test . + docker-persistence: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - name: Build and replace-container persistence smoke test + run: make docker-smoke diff --git a/Dockerfile b/Dockerfile index d37b9b94..7d151c36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # OpenKyrozen Docker Image # Build: docker build -t openkyrozen . -# Run: docker run -p 8000:8000 -e DEEPSEEK_API_KEY=sk-... openkyrozen +# Run: docker run -p 8000:8000 -v kyrozen-data:/data openkyrozen FROM python:3.12-slim @@ -19,13 +19,20 @@ RUN pip install --no-cache-dir fastapi uvicorn # Copy application code COPY . . -# Run the service without root privileges. The application only needs to read -# its code and write user data under the mounted working directory/home. +# Keep the SQLite source of truth and its derived index under one explicit, +# non-root-writable data directory. A named Docker volume mounted at /data is +# therefore sufficient to survive container replacement. +ENV KYROZEN_DB_PATH=/data/openkyrozen.sqlite3 \ + KYROZEN_VECTOR_PATH=/data/chroma_index + RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin kyrozen \ + && install -d -o kyrozen -g kyrozen /data \ && chown -R kyrozen:kyrozen /app USER kyrozen +VOLUME ["/data"] + # Expose web server port EXPOSE 8000 diff --git a/Makefile b/Makefile index ae5996c5..b9b7234f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install run clean lint test check benchmark git-status git-diff git-log web +.PHONY: install run clean lint test check benchmark docker-smoke git-status git-diff git-log web # Prefer the known-stable Python 3.12, but use the active supported Python # 3.13 on clean runners that do not provide 3.12. Python 3.14 remains @@ -68,6 +68,11 @@ benchmark: --evolved-runner "$(VENV_PYTHON) benchmarks/evolved_runner.py" \ --timeout "$${BENCHMARK_TIMEOUT:-60}" +docker-smoke: + @command -v docker >/dev/null 2>&1 || { echo "Error: Docker is required for the container smoke test."; exit 1; } + docker build -t openkyrozen-smoke . + ./scripts/docker_persistence_smoke.sh openkyrozen-smoke + # Quick verification check: @echo "Checking Python syntax..." diff --git a/README.ja.md b/README.ja.md index fd683ab5..eee66597 100644 --- a/README.ja.md +++ b/README.ja.md @@ -159,7 +159,11 @@ python server.py --port 8000 # または Docker 経由: docker build -t openkyrozen . -docker run -p 8000:8000 -e DEEPSEEK_API_KEY=sk-... openkyrozen +docker run -p 8000:8000 \ + -e DEEPSEEK_API_KEY=sk-... \ + -e KYROZEN_SERVER_TOKEN=change-me \ + -v kyrozen-data:/data \ + openkyrozen ``` Web インターフェースは、リアルタイムストリーミング、コスト追跡、セッション管理を備えたダークテーマのチャット UI を提供します。 @@ -405,10 +409,14 @@ python server.py --port 8000 docker build -t openkyrozen . docker run -p 8000:8000 \ -e DEEPSEEK_API_KEY=sk-your-key \ - -v $(pwd)/chroma_memory:/app/chroma_memory \ + -e KYROZEN_SERVER_TOKEN=change-me \ + -e KYROZEN_DB_PATH=/data/openkyrozen.sqlite3 \ + -v kyrozen-data:/data \ openkyrozen ``` +イメージは非 root ユーザー `kyrozen` で実行されます。SQLite の事実上の保存先は `/data/openkyrozen.sqlite3`(イメージが `KYROZEN_DB_PATH` に設定)です。コンテナを置き換える場合も同じ名前付きボリュームを `/data` にマウントしてください。ローカルでは `make docker-smoke` で置き換え後の復元テストを実行できます。 + --- ## 🔌 プラグインシステム diff --git a/README.ko.md b/README.ko.md index f2f184ea..32dc79b6 100644 --- a/README.ko.md +++ b/README.ko.md @@ -159,7 +159,11 @@ python server.py --port 8000 # 또는 Docker 사용: docker build -t openkyrozen . -docker run -p 8000:8000 -e DEEPSEEK_API_KEY=sk-... openkyrozen +docker run -p 8000:8000 \ + -e DEEPSEEK_API_KEY=sk-... \ + -e KYROZEN_SERVER_TOKEN=change-me \ + -v kyrozen-data:/data \ + openkyrozen ``` 웹 인터페이스는 실시간 스트리밍, 비용 추적, 세션 관리 기능을 갖춘 다크 테마 채팅 UI를 제공합니다. @@ -405,10 +409,14 @@ python server.py --port 8000 docker build -t openkyrozen . docker run -p 8000:8000 \ -e DEEPSEEK_API_KEY=sk-your-key \ - -v $(pwd)/chroma_memory:/app/chroma_memory \ + -e KYROZEN_SERVER_TOKEN=change-me \ + -e KYROZEN_DB_PATH=/data/openkyrozen.sqlite3 \ + -v kyrozen-data:/data \ openkyrozen ``` +이미지는 root가 아닌 `kyrozen` 사용자로 실행됩니다. SQLite 원본 데이터는 `/data/openkyrozen.sqlite3`에 저장되며(이미지에서 `KYROZEN_DB_PATH`를 이 경로로 설정), 컨테이너를 교체할 때도 같은 이름의 볼륨을 `/data`에 마운트해야 합니다. 로컬에서는 `make docker-smoke`로 교체 후 복구 테스트를 실행할 수 있습니다. + --- ## 🔌 플러그인 시스템 diff --git a/README.md b/README.md index 779fd760..9674af86 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,11 @@ KYROZEN_SERVER_TOKEN=change-me python server.py --host 0.0.0.0 --port 8000 # Or via Docker: docker build -t openkyrozen . -docker run -p 8000:8000 -e DEEPSEEK_API_KEY=sk-... -e KYROZEN_SERVER_TOKEN=change-me openkyrozen +docker run -p 8000:8000 \ + -e DEEPSEEK_API_KEY=sk-... \ + -e KYROZEN_SERVER_TOKEN=change-me \ + -v kyrozen-data:/data \ + openkyrozen ``` The web interface provides a dark-themed chat UI with real-time streaming, cost tracking, and session management. @@ -612,10 +616,16 @@ docker build -t openkyrozen . docker run -p 8000:8000 \ -e DEEPSEEK_API_KEY=sk-your-key \ -e KYROZEN_SERVER_TOKEN=change-me \ - -v kyrozen-data:/root/.kyrozen/v2 \ + -e KYROZEN_DB_PATH=/data/openkyrozen.sqlite3 \ + -v kyrozen-data:/data \ openkyrozen ``` +The image runs as the non-root `kyrozen` user. SQLite is the durable source of +truth at `/data/openkyrozen.sqlite3` (the image sets `KYROZEN_DB_PATH` to this +path), so keep the named volume mounted at `/data` when replacing containers. +To run the same replacement-and-recovery check locally, use `make docker-smoke`. + --- ## 🔌 Plugin System diff --git a/README.zh-CN.md b/README.zh-CN.md index 38b7e211..a439f7b4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -162,7 +162,11 @@ KYROZEN_SERVER_TOKEN=change-me python server.py --host 0.0.0.0 --port 8000 # 或通过 Docker: docker build -t openkyrozen . -docker run -p 8000:8000 -e DEEPSEEK_API_KEY=sk-... -e KYROZEN_SERVER_TOKEN=change-me openkyrozen +docker run -p 8000:8000 \ + -e DEEPSEEK_API_KEY=sk-... \ + -e KYROZEN_SERVER_TOKEN=change-me \ + -v kyrozen-data:/data \ + openkyrozen ``` Web 界面提供暗色主题的聊天 UI,支持实时流式输出、费用追踪和会话管理。 @@ -447,10 +451,14 @@ docker build -t openkyrozen . docker run -p 8000:8000 \ -e DEEPSEEK_API_KEY=sk-your-key \ -e KYROZEN_SERVER_TOKEN=change-me \ - -v kyrozen-data:/root/.kyrozen/v2 \ + -e KYROZEN_DB_PATH=/data/openkyrozen.sqlite3 \ + -v kyrozen-data:/data \ openkyrozen ``` +镜像使用非 root 用户 `kyrozen` 运行。SQLite 事实主库位于 +`/data/openkyrozen.sqlite3`(镜像已将 `KYROZEN_DB_PATH` 设为该路径);替换容器时请继续将同一个命名卷挂载到 `/data`。本地可用 `make docker-smoke` 运行同样的替换容器并恢复记忆测试。 + --- ## 🔌 插件系统 diff --git a/scripts/docker_persistence_smoke.sh b/scripts/docker_persistence_smoke.sh new file mode 100755 index 00000000..bc3a1d54 --- /dev/null +++ b/scripts/docker_persistence_smoke.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +IMAGE="${1:-openkyrozen-test}" +BASE_PORT="${DOCKER_SMOKE_BASE_PORT:-18080}" +PORT_A="${BASE_PORT}" +PORT_B="$((BASE_PORT + 1))" +RUN_SUFFIX="${GITHUB_RUN_ID:-local}-${RANDOM}-$$" +VOLUME_NAME="openkyrozen-smoke-${RUN_SUFFIX}" +CONTAINER_A="openkyrozen-smoke-a-${RUN_SUFFIX}" +CONTAINER_B="openkyrozen-smoke-b-${RUN_SUFFIX}" +TOKEN="openkyrozen-smoke-token" + +require_command() { + command -v "$1" >/dev/null 2>&1 || { + echo "docker persistence smoke test requires '$1'" >&2 + exit 2 + } +} + +cleanup() { + docker rm -f "$CONTAINER_A" "$CONTAINER_B" >/dev/null 2>&1 || true + docker volume rm "$VOLUME_NAME" >/dev/null 2>&1 || true +} + +wait_for_server() { + local container="$1" + local port="$2" + local response + + for _ in $(seq 1 90); do + if response="$(curl --silent --show-error --fail --max-time 3 \ + -H "Authorization: Bearer ${TOKEN}" \ + "http://127.0.0.1:${port}/api/health" 2>/dev/null)" \ + && python3 -c 'import json, sys; data = json.load(sys.stdin); raise SystemExit(0 if data.get("status") == "ok" and data.get("provider") == "ollama" else 1)' <<<"$response"; then + return 0 + fi + sleep 1 + done + + echo "container did not become healthy: ${container}" >&2 + docker logs "$container" >&2 || true + return 1 +} + +run_server() { + local container="$1" + local port="$2" + + docker run --detach --name "$container" \ + --publish "127.0.0.1:${port}:8000" \ + --env KYROZEN_PROVIDER=ollama \ + --env KYROZEN_SERVER_TOKEN="$TOKEN" \ + --env KYROZEN_DB_PATH=/data/openkyrozen.sqlite3 \ + --env KYROZEN_DISABLE_VECTOR_INDEX=1 \ + --mount "type=volume,src=${VOLUME_NAME},dst=/data" \ + "$IMAGE" >/dev/null +} + +require_command docker +require_command curl +require_command python3 +docker image inspect "$IMAGE" >/dev/null + +trap cleanup EXIT INT TERM +docker volume create "$VOLUME_NAME" >/dev/null + +run_server "$CONTAINER_A" "$PORT_A" +wait_for_server "$CONTAINER_A" "$PORT_A" + +test "$(docker exec "$CONTAINER_A" id -u)" = "10001" +test "$(docker exec "$CONTAINER_A" sh -c 'test -w /data && stat -c %u /data')" = "10001" + +claim_payload='{"key":"docker_persistence_smoke","value":"survives container replacement","kind":"fact","authority":"owner","scope":"global","claim_type":"general","visibility":"public"}' +created="$(curl --silent --show-error --fail \ + -X POST "http://127.0.0.1:${PORT_A}/api/v2/memory/claims" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H 'Content-Type: application/json' \ + --data "$claim_payload")" +memory_id="$(python3 -c 'import json, sys; value = json.load(sys.stdin).get("memory_id"); assert value; print(value)' <<<"$created")" +test -n "$memory_id" + +docker exec "$CONTAINER_A" sh -c 'test -f /data/openkyrozen.sqlite3' +docker rm -f "$CONTAINER_A" >/dev/null + +run_server "$CONTAINER_B" "$PORT_B" +wait_for_server "$CONTAINER_B" "$PORT_B" +recovered="$(curl --silent --show-error --fail \ + "http://127.0.0.1:${PORT_B}/api/v2/memory/claims" \ + -H "Authorization: Bearer ${TOKEN}")" +python3 -c 'import json, sys; data = json.load(sys.stdin); expected = sys.argv[1]; assert any(item.get("id") == expected for item in data.get("claims", []))' "$memory_id" <<<"$recovered" + +echo "Docker persistence smoke passed: memory ${memory_id} survived container replacement as uid 10001."