P0/M1: 收编 encryption-test 工具链入 tooling/

- legacy_plantai_server_package/: phase_a–f + compile_* + bin/plantai_admin.py
  + tools/(audit/elf_visibility/smoke/build_delivery_manifest) 完整基线
- legacy_docker_packager/: protected 形态参考
- crypto/client/: plantai_core.py(AES-GCM 原语) + install_plantai.sh 参考
- secrets/provision 严格排除(gitignore),入 git 前已脱敏清理
- LEGACY_MAPPING.md 记录源与改造方向

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jerry
2026-08-31 12:59:29 +08:00
co-authored by Claude Fable 5
parent 0875bb036c
commit 27946edc2b
48 changed files with 6919 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# 密钥与客户交付物(绝不入 git)
secrets/
server_fingers/
provision/
**/secrets/
*.bak_*
# 构建产物
build/
out/
+15
View File
@@ -0,0 +1,15 @@
# 收编映射(encryption-test → tooling)
源:`/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/`(2026-08-31 收编,原工具链未入 git)
| 源 | 目标 | 说明 |
|---|---|---|
| server/plantai_server_package/ | legacy_plantai_server_package/ | 完整基线:phase_a–f、compile_{plantai,qa,rag}.sh、start/stop_all、bin/plantai_admin.py、tools/(audit_compiled_tree/elf_visibility/smoke_dual_build/build_delivery_manifest) |
| server/plantai_server_package/secrets/ | **不复制**(gitignore) | supplier 私钥/口令/master_keys,绝不入 git |
| server/plantai_server_package/provision/ | **不复制** | 客户交付物(含机器指纹),运行时产物 |
| server/protected_docker_packager/ | legacy_docker_packager/ | 参考实现(docker 形态,本方案走 shell 形态) |
| client/bin/plantai_core.py | crypto/client/plantai_core.py | AES-GCM/SHA256 原语 + Ed25519(后续剥离 TPM/RSA-OAEP) |
| client/test/install_plantai.sh | crypto/client/install_plantai.sh | 安装器参考(后续去指纹/license/TPM) |
| package_client.sh | package_client.sh | 客户端打包参考 |
改造方向(P1–P6 落地):phase_a → build_all.sh + compile/*;compile_qa.sh → 参数化 ENTRY_SCRIPT + compile.manifest 白名单;phase_b → crypto/init_keys.sh(去供应商 RSA);phase_c → assemble/(去 TPM/license/Ollama);phase_f → kb/encrypt_kb.sh;plantai_core.py → crypto/(AES-GCM+SHA256)。server.env 旧路径由新 tooling.env 覆盖。
+186
View File
@@ -0,0 +1,186 @@
# PlantAI + RAG + QA 客户端部署包
本目录可以交给客户:不含供应商私钥和明文 `K_master`,只能采集指纹、配置 TPM、验证供应商签名、解封密钥和解密已授权资产。
## 客户专属配置 — `install.env` 变量详解
以下表格覆盖 `install.env.example` 中所有变量。每客户交付时据此填写 `install.env`。
| 变量 | 默认值 | 作用 | 影响路径 | 每客户必改 | 取值依据 |
|------|--------|------|----------|:----------:|----------|
| `PLANTAI_ROOT` | `/opt/PlantAI` 或 `$HOME/PlantAI` | 安装根目录,其下自动创建所有子目录 | → `bin/` `config/` `assets/` `data/` `releases/` `runtime/` | 通常不改 | 客户目录规范(如 `/data/PlantAI`) |
| `RELEASE_SRC` | `./release` | 供应商发行包解压目录,安装时从此取 VERSION / app/ / assets/ / config/ | → `$PLANTAI_ROOT/releases/<VERSION>/` 的源 | 不改 | 保留默认值 |
| `DEVICE_ID` | `$(hostname)` | 设备业务标识,写入许可证和日志,用于绑定设备身份 | → 出现在 `license.json` / 监控系统 | **必改** | `<客户缩写>-<机器角色>-<序号>`,如 `huanuo-web01` |
| `PLANTAI_WEB_PORT` | `7880` | PlantAI Web 服务监听端口 | → 服务绑定端口 | 偶尔改 | 客户现有服务已占用时 |
| `RAG_PORT` | `7900` | RAG 服务监听端口 | → 服务绑定端口 | 偶尔改 | 客户现有服务已占用时 |
| `PLANTAI_PYTHON` | `python3` | PlantAI 后端使用的 Python 解释器路径 | → 执行 `plantai_client.py` / 后端进程 | **必改** | 问客户 conda 环境路径,如 `/opt/miniconda3/envs/plantai/bin/python` |
| `RAG_PYTHON` | 同 `PLANTAI_PYTHON` | RAG 服务使用的 Python 解释器路径 | → 执行 `start_qa_v1_0_0_7908.sh` 等 | **必改** | 同上,通常与 `PLANTAI_PYTHON` 相同 |
| `NEO4J_URI` | `bolt://127.0.0.1:7687` | Neo4j 图数据库连接地址 | → 应用通过此 URI 连接图数据库 | 通常改 IP | 客户 Neo4j 实际主机 IP |
| `NEO4J_USER` | `neo4j` | Neo4j 用户名 | → 数据库连接认证 | 通常不改 | 除非客户改了用户名 |
| `NEO4J_PASSWORD` | 无 | Neo4j 密码 | → 数据库连接认证 | **必改** | 客户管理员提供,**禁用默认/空密码** |
| `QDRANT_URL` | `http://127.0.0.1:6333` | Qdrant 向量数据库 HTTP 地址 | → 应用通过此 URL 读写向量 | 通常改 IP | 客户 Qdrant 实际主机 IP |
| `V15_QDRANT_COLLECTION` | 无 | Qdrant 集合名,反映客户 + 数据版本 | → 读写此集合中的向量数据 | **必改** | `v15_<客户缩写>_<日期>`,如 `v15_huanuo_20260701` |
| `V15_BGE_MODEL` | `bge-m3:567m` | Ollama 中的 embedding 模型名 | → 文本向量化使用的模型 | 不改 | 仅更换 embedding 策略时动 |
| `V15_OLLAMA_EMBED_URL` | `http://127.0.0.1:11434` | Ollama 服务 URL | → embedding API 调用地址 | 偶尔改 | Ollama 不在本机时改 IP |
| `INSTALL_USER_SERVICE` | `0` | 是否注册 systemd --user 自启服务 | → `$HOME/.config/systemd/user/plantai.service` | 可选 | 客户要求开机自启时设 `=1` |
**安全要求:**
- `install.env` 创建后执行 `chmod 600`
- **只写入真实密码**到 `install.env`,`install.env.example` 永远只含占位符
- `install.env` 包含客户凭证,禁止提交到代码仓库或分享
## 交付结构
```text
client/
bin/plantai_client.py # 客户端工具(采集指纹、TPM、解密)
bin/PlantAI-launcher # 启动器(解密代码运行资产到 tmpfs,启动三项目)
install_plantai.sh # 安装脚本(fingerprint / install / tpm2-setup)
install.env.example # ⬅ 客户据此创建 install.env(每客户不同)
phase_d_tpm.sh # 阶段 D:TPM 初始化 + 指纹采集(硬件)
phase_d_swtpm_test.sh # 软件 TPM 测试版本
README.md # 本文件
release/ ⬅ 解压供应商发行包到这里
VERSION
app/PlantAI_compiled/
app/RAG/
app/QA/
app/start_all.sh
DELIVERY_MANIFEST.json
assets/runtime-assets.enc
assets/runtime-assets.manifest.json
config/supplier_public_key.pem
```
另随包交付 `plantai-rag-runtime-data-<VERSION>.tar` 及对应 `.sha256`,其中包含
Qdrant、Neo4j 数据卷、BGE reranker 和本地 Ollama 模型
`bge-m3:567m`、`qwen2.5:14b-instruct`。数据库卷不放入 `/dev/shm`,避免约
70GB 持久数据占满内存文件系统。
> `install.env`(含真实密码)由客户在部署时根据 `install.env.example` 创建,不包含在初始交付包中。
## 每客户修改指南(交付前必做)
`install.env.example` → `install.env` 按上方表格填写即可,此处只列 README 中需要客户化的文本:
| 位置 | 当前内容 | 改为 |
|------|----------|------|
| 准备配置 | `tar -C release -xzf plantai-rag-release-1.0.0.tar.gz` | 版本号改为本次实际版本 |
| 导入授权 | `PROVISION_BUNDLE=/path/to/plantai-provision-DEVICE.tar.gz` | `DEVICE` 替换为 `install.env` 中 `DEVICE_ID` |
| 正式启动 | `PLANTAI_ROOT=/opt/PlantAI` | 改为 `install.env` 中 `PLANTAI_ROOT` 值 |
| 外部服务 | `ollama pull qwen3:0.6b` | 模型版本按本次实际版本改 |
另在 README 末尾追加客户档案:
```markdown
## 本次交付记录
- **客户名称**:
- **DEVICE_ID**:
- **发行包版本**:
- **交付日期**:
- **供应商负责人**:
```
### 交付前 checklist
- [ ] `client/` 中删除了 `install.env`(只留 `.example`)
- [ ] `client/` 中删除了 `package_client.sh`
- [ ] `install.env.example` 中无真实密码
- [ ] 发行包 `plantai-rag-release-<VERSION>.tar.gz` 已单独发给客户
## 1. 准备配置与发行包
```bash
cp install.env.example install.env
chmod 600 install.env
mkdir -p release
tar -C release -xzf plantai-rag-release-1.0.0.tar.gz
sha256sum -c release/SHA256SUMS
```
在 `install.env` 设置安装路径、Python 解释器以及 Neo4j/Qdrant/Ollama 变量。真实密码不要写入 `install.env.example`。
## 2. TPM 2.0 初始化(每台目标机只做一次)
推荐直接执行阶段 D 一键脚本:
```bash
./phase_d_tpm.sh
```
它会依次检查 TPM、复用或创建 TPM 密钥、采集指纹,并拒绝缺少 TPM 公钥/attestation 的弱指纹。
下面是等价的手工命令,仅用于排错:
```bash
ls -l /dev/tpmrm0 /dev/tpm0
tpm2_getrandom 8
./install_plantai.sh tpm2-setup
```
不要删除 `$PLANTAI_ROOT/config/tpm/`,也不要反复运行初始化。更换 TPM/主板或删除持久化 TPM key 后,旧授权将无法解封。
## 3. 采集指纹并交给供应商
### 无真实 TPM 时的软件测试入口
```bash
./phase_d_swtpm_test.sh
```
该入口会使用端口 2321/2322 的 swtpm,自动运行阶段 D,并在指纹中写入 `test_only_swtpm=true`。它只能验证流程,不能提供硬件级保护。阶段 E 测试签发必须显式设置 `ALLOW_SOFTWARE_TPM_TEST=1`。
```bash
./install_plantai.sh fingerprint
```
将 `$PLANTAI_ROOT/config/device_fingerprint.json` 发给供应商。生产授权必须包含 `tpm_public_key` 和 `attestation`;只有普通 DMI/磁盘信息的指纹不能提供 TPM 硬件绑定。
## 4. 导入授权并安装
```bash
PROVISION_BUNDLE=/path/to/plantai-provision-DEVICE.tar.gz ./install_plantai.sh install
```
校验并展开随包交付的持久化运行数据:
```bash
sha256sum -c plantai-rag-runtime-data-1.0.0.tar.sha256
mkdir -p "${PLANTAI_ROOT:-/opt/PlantAI}/data"
tar -C "${PLANTAI_ROOT:-/opt/PlantAI}/data" \
-xf plantai-rag-runtime-data-1.0.0.tar
```
安装结束会执行 `PlantAI-launcher --check-only`:验证许可证签名、硬件匹配、TPM 解封、资产清单和 AES-GCM 解密。任何一步失败都不应启动服务。
正式启动:
```bash
PLANTAI_ROOT=/opt/PlantAI /opt/PlantAI/bin/PlantAI-launcher
```
启动器默认把加密的 PlantAI/RAG `models/prompts` 解密到 `/dev/shm`;QA 的
Qdrant、Neo4j 与大模型保留在 `$PLANTAI_ROOT/data/runtime-assets`。客户启动
脚本会依次检查/启动 Qdrant、Neo4j、独立端口的本地 Ollama、QA、RAG、
PlantAI。不要设置 `PLANTAI_PERSIST_RUNTIME=1`,除非明确接受代码运行资产明文落盘风险。
## 5. 依赖与端口
客户机需预装 Docker 与 Ollama。启动脚本会使用交付数据自动拉起依赖,无需
联网拉取模型。默认端口如下:
```bash
curl -fsS http://127.0.0.1:16333/collections # Qdrant
curl -fsS http://127.0.0.1:17474 # Neo4j HTTP
curl -fsS http://127.0.0.1:11435/api/tags # 交付专用 Ollama
curl -fsS http://127.0.0.1:7925/api/v1/qa-v1-1-73/health
curl -fsS http://127.0.0.1:7900/health
curl -fsS http://127.0.0.1:7880/
```
## 安全边界
- `.so` 是逆向加固,不是密码学加密。
- TPM 保护的是解封 `K_master` 的能力;私钥从不离开 TPM。
- 许可证和 `asset.key.enc` 通过 SHA-256 绑定,不能跨设备混用。
- 客户端没有生成供应商密钥、自签许可证或重新加密资产的命令。
- 供应商的 `plantai_server_package`、私钥、口令文件、`master_keys/` 永不进入 client。
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env bash
# PlantAI client-side installer. Safe to ship to any customer: this script
# and its bin/ folder contain no private key and no master-key logic, only
# the ability to collect a fingerprint and consume vendor-issued files.
#
# Usage:
# ./install_plantai.sh fingerprint
# -> collects this machine's hardware fingerprint and tells you where
# to send it to the vendor. Run this FIRST, before you have a
# license.
#
# ./install_plantai.sh install
# -> sets up the directory tree, installs the app + pre-encrypted
# assets from ./release/, and imports the vendor-issued
# license.json / asset.key.enc if you've placed the provision
# bundle at ./release/provision/ (or point PROVISION_BUNDLE at the
# .tar.gz the vendor sent you).
set -euo pipefail
umask 027 # Config files owner rwx, group rx; prevent world-readable by default
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
for cfg in "/etc/plantai/install.env" "$SCRIPT_DIR/install.env"; do
[ -f "$cfg" ] && . "$cfg"
done
# ---------------------------------------------------------------------------
# Safety: validate that a path is safe to rm -rf
# ---------------------------------------------------------------------------
validate_path_under() {
local target="$1"
local root="$2"
local desc="${3:-path}"
[ -z "$target" ] && { echo "SECURITY: $desc is empty — refusing to rm -rf" >&2; exit 1; }
[ "$target" = "/" ] && { echo "SECURITY: $desc is '/' — refusing to rm -rf" >&2; exit 1; }
local resolved
resolved="$(cd "$(dirname "$target" 2>/dev/null || echo /)" 2>/dev/null && pwd)/$(basename "$target")" || true
local root_resolved
root_resolved="$(cd "$root" 2>/dev/null && pwd)" || root_resolved=""
if [ -n "$root_resolved" ] && [ -n "$resolved" ]; then
case "$resolved" in
"$root_resolved"|"$root_resolved/"*) ;;
*)
echo "SECURITY: $desc ($resolved) is NOT under expected root ($root_resolved) — refusing to rm -rf" >&2
exit 1
;;
esac
fi
[ "$target" = "$HOME" ] && { echo "SECURITY: $desc is \$HOME — refusing to rm -rf" >&2; exit 1; }
return 0
}
validate_version() {
local v="$1"
[ -z "$v" ] && { echo "SECURITY: version string is empty" >&2; exit 1; }
[[ "$v" =~ ^[A-Za-z0-9._-]+$ ]] || {
echo "SECURITY: version string '$v' contains illegal characters — only [A-Za-z0-9._-] allowed" >&2
exit 1
}
[[ "$v" == *".."* ]] && { echo "SECURITY: version string '$v' contains '..'" >&2; exit 1; }
return 0
}
default_root() {
if [ "$(id -u)" = "0" ] || [ -w "/opt" ]; then
echo "/opt/PlantAI"
else
echo "${HOME}/PlantAI"
fi
}
PLANTAI_ROOT="${PLANTAI_ROOT:-$(default_root)}"
RELEASE_SRC="${RELEASE_SRC:-$SCRIPT_DIR/release}"
DEVICE_ID="${DEVICE_ID:-$(hostname)}"
INSTALL_USER_SERVICE="${INSTALL_USER_SERVICE:-0}"
WEB_PORT="${PLANTAI_WEB_PORT:-7880}"
PYTHON="${PLANTAI_PYTHON:-python3}"
if [ -z "${BBAV5_CONDA_BASE:-}" ] && command -v conda >/dev/null 2>&1; then
BBAV5_CONDA_BASE="$(conda info --base 2>/dev/null || true)"
fi
BBAV5_CONDA_BASE="${BBAV5_CONDA_BASE:-}"
BBAV5_DEFAULT_ENV="${BBAV5_DEFAULT_ENV:-plantai}"
BBAV5_QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_${WEB_PORT}}"
BBAV5_REDIS_URL="${BBAV5_REDIS_URL:-redis://localhost:6379/0}"
cmd_fingerprint() {
mkdir -p "$PLANTAI_ROOT/config"
"$PYTHON" "$SCRIPT_DIR/bin/plantai_client.py" collect-fingerprint \
--device-id "$DEVICE_ID" \
--root "$PLANTAI_ROOT" \
--output "$PLANTAI_ROOT/config/device_fingerprint.json"
echo
echo "Fingerprint written to: $PLANTAI_ROOT/config/device_fingerprint.json"
echo "Send this file to your vendor. They will send back a provision bundle"
echo "(license.json + asset.key.enc) -- place it at $RELEASE_SRC/provision/"
echo "or set PROVISION_BUNDLE=/path/to/plantai-provision-*.tar.gz, then run:"
echo " ./install_plantai.sh install"
}
cmd_install() {
need_file() { [ -e "$1" ] || { echo "Required path not found: $1" >&2; exit 1; }; }
need_file "$RELEASE_SRC/app"
need_file "$SCRIPT_DIR/bin/plantai_client.py"
need_file "$SCRIPT_DIR/bin/plantai_core.py"
need_file "$SCRIPT_DIR/bin/PlantAI-launcher"
VERSION="$(cat "$RELEASE_SRC/VERSION" 2>/dev/null || echo unknown)"
validate_version "$VERSION"
RELEASE_DIR="$PLANTAI_ROOT/releases/$VERSION"
APP_DIR="$RELEASE_DIR/app"
echo "[1/6] Create PlantAI directory tree: $PLANTAI_ROOT"
mkdir -p \
"$PLANTAI_ROOT/bin" "$PLANTAI_ROOT/config" "$PLANTAI_ROOT/assets" \
"$PLANTAI_ROOT/data/input" "$PLANTAI_ROOT/data/workspace" "$PLANTAI_ROOT/data/final" \
"$PLANTAI_ROOT/data/logs" "$PLANTAI_ROOT/data/memory_storage" "$PLANTAI_ROOT/data/reproducibility" \
"$PLANTAI_ROOT/var/cache" "$PLANTAI_ROOT/var/tmp" "$PLANTAI_ROOT/var/lock" "$PLANTAI_ROOT/var/audit" \
"$PLANTAI_ROOT/runtime" "$PLANTAI_ROOT/tools" "$RELEASE_DIR"
echo "[2/6] Install app code from release payload (already excludes models/prompts)"
validate_path_under "$APP_DIR" "$PLANTAI_ROOT/releases" "APP_DIR"
rm -rf "$APP_DIR"
mkdir -p "$APP_DIR"
rsync -a --delete "$RELEASE_SRC/app/" "$APP_DIR/"
ln -sfn "$RELEASE_DIR" "$PLANTAI_ROOT/releases/current"
echo "[3/6] Install launcher + client tool"
install -m 0755 "$SCRIPT_DIR/bin/plantai_client.py" "$PLANTAI_ROOT/bin/plantai_client.py"
install -m 0644 "$SCRIPT_DIR/bin/plantai_core.py" "$PLANTAI_ROOT/bin/plantai_core.py"
install -m 0755 "$SCRIPT_DIR/bin/PlantAI-launcher" "$PLANTAI_ROOT/bin/PlantAI-launcher"
echo "[4/6] Install pre-encrypted assets + public key from release payload"
rsync -a "$RELEASE_SRC/assets/" "$PLANTAI_ROOT/assets/"
install -m 0644 "$RELEASE_SRC/config/supplier_public_key.pem" "$PLANTAI_ROOT/config/supplier_public_key.pem"
echo "[5/6] Import vendor-issued license + asset key, if available"
PROVISION_BUNDLE="${PROVISION_BUNDLE:-}"
if [ -n "$PROVISION_BUNDLE" ] && [ -f "$PROVISION_BUNDLE" ]; then
# Extract to a temp dir first so we can inspect filenames before
# installing to the real config directory.
PROV_TMP="$(mktemp -d "${TMPDIR:-/tmp}/plantai-provision.XXXXXX")"
tar -C "$PROV_TMP" -xzf "$PROVISION_BUNDLE" license.json asset.key.enc
for fn in license.json asset.key.enc; do
if [ ! -f "$PROV_TMP/$fn" ]; then
echo "ERROR: provision bundle is missing required file: $fn" >&2
validate_path_under "$PROV_TMP" "${TMPDIR:-/tmp}" "PROV_TMP"
rm -rf "$PROV_TMP"
exit 1
fi
if [ -L "$PROV_TMP/$fn" ]; then
echo "ERROR: provision bundle contains a symlink for $fn (unsafe)" >&2
validate_path_under "$PROV_TMP" "${TMPDIR:-/tmp}" "PROV_TMP"
rm -rf "$PROV_TMP"
exit 1
fi
done
install -m 0644 "$PROV_TMP/license.json" "$PLANTAI_ROOT/config/license.json"
install -m 0644 "$PROV_TMP/asset.key.enc" "$PLANTAI_ROOT/config/asset.key.enc"
validate_path_under "$PROV_TMP" "${TMPDIR:-/tmp}" "PROV_TMP"
rm -rf "$PROV_TMP"
elif [ -f "$RELEASE_SRC/provision/license.json" ] && [ -f "$RELEASE_SRC/provision/asset.key.enc" ]; then
install -m 0644 "$RELEASE_SRC/provision/license.json" "$PLANTAI_ROOT/config/license.json"
install -m 0644 "$RELEASE_SRC/provision/asset.key.enc" "$PLANTAI_ROOT/config/asset.key.enc"
else
echo " No license.json/asset.key.enc found yet."
echo " Run './install_plantai.sh fingerprint' first if you haven't, send it to"
echo " your vendor, then place their reply at $RELEASE_SRC/provision/ and re-run install."
fi
{
echo "PLANTAI_ROOT=$PLANTAI_ROOT"
echo "PLANTAI_RUNTIME=$PLANTAI_ROOT/runtime"
echo "PLANTAI_WEB_PORT=$WEB_PORT"
echo "PLANTAI_PYTHON=$PYTHON"
[ -n "$BBAV5_CONDA_BASE" ] && echo "BBAV5_CONDA_BASE=$BBAV5_CONDA_BASE"
echo "BBAV5_DEFAULT_ENV=$BBAV5_DEFAULT_ENV"
echo "BBAV5_QUEUE_PREFIX=$BBAV5_QUEUE_PREFIX"
echo "BBAV5_REDIS_URL=$BBAV5_REDIS_URL"
for name in \
NEO4J_URI NEO4J_USER NEO4J_PASSWORD QA_NEO4J_PASSWORD \
QDRANT_URL V15_QDRANT_COLLECTION V15_BGE_MODEL V15_OLLAMA_EMBED_URL \
RAG_PYTHON RAG_PORT QA_PYTHON QA_PORT \
QA_QDRANT_PORT QA_QDRANT_GRPC_PORT \
QA_NEO4J_HTTP_PORT QA_NEO4J_BOLT_PORT \
OLLAMA_PORT PLANTAI_RUNTIME_DATA \
QA_OLLAMA_GENERATION_MODEL QA_OLLAMA_EMBED_MODEL
do
if [ -n "${!name:-}" ]; then
printf '%s=%s\n' "$name" "${!name}"
fi
done
} > "$PLANTAI_ROOT/config/service.env"
chmod 600 "$PLANTAI_ROOT/config/service.env"
echo "[6/6] Create systemd user service file"
mkdir -p "$PLANTAI_ROOT/config/systemd"
cat > "$PLANTAI_ROOT/config/systemd/plantai.service" <<EOF
[Unit]
Description=PlantAI service
After=network.target
[Service]
Type=simple
WorkingDirectory=$APP_DIR
EnvironmentFile=$PLANTAI_ROOT/config/service.env
ExecStart=$PLANTAI_ROOT/bin/PlantAI-launcher
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
EOF
if [ "$INSTALL_USER_SERVICE" = "1" ]; then
mkdir -p "$HOME/.config/systemd/user"
cp "$PLANTAI_ROOT/config/systemd/plantai.service" "$HOME/.config/systemd/user/"
systemctl --user daemon-reload
systemctl --user enable plantai.service
echo "User service enabled. Start with: systemctl --user start plantai.service"
fi
if [ -f "$PLANTAI_ROOT/config/license.json" ] && [ -f "$PLANTAI_ROOT/config/asset.key.enc" ]; then
echo "Verifying install..."
PLANTAI_ROOT="$PLANTAI_ROOT" "$PLANTAI_ROOT/bin/PlantAI-launcher" --check-only
fi
echo "Done. PLANTAI_ROOT=$PLANTAI_ROOT"
}
cmd_tpm2_setup() {
echo "Setting up TPM 2.0 hardware-bound wrapping key..."
"$PYTHON" "$SCRIPT_DIR/bin/plantai_client.py" tpm2-setup --root "$PLANTAI_ROOT"
echo
echo "TPM setup complete. Now run:"
echo " ./install_plantai.sh fingerprint"
echo "to collect a new fingerprint that includes the TPM public key."
echo "Send that fingerprint to your vendor to get TPM-bound license + asset key."
}
case "${1:-}" in
fingerprint) cmd_fingerprint ;;
install) cmd_install ;;
tpm2-setup) cmd_tpm2_setup ;;
*) echo "Usage: $0 {fingerprint|install|tpm2-setup}" >&2; exit 1 ;;
esac
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
ARG BASE_IMAGE=python:3.11-bookworm
FROM ${BASE_IMAGE}
ARG PIP_INDEX_URL=https://pypi.org/simple
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
PLANTAI_PROJECT_ROOT=/opt/plantai \
BBAV5_PROJECT_ROOT=/opt/plantai \
PLANTAI_RAG_ROOT=/opt/rag \
GENE_FAMILY_ROOT=/opt/qa \
PYTHONPATH=/opt/plantai
# Identify requires the HMMER and NCBI BLAST command-line tools. This is the
# minimum native runtime, not the old allenv/Conda environment.
RUN set -eux; \
if [ -f /etc/apt/sources.list.d/debian.sources ]; then \
sed -i 's|http://deb.debian.org|https://deb.debian.org|g; s|http://security.debian.org|https://security.debian.org|g' /etc/apt/sources.list.d/debian.sources; \
fi; \
if [ -f /etc/apt/sources.list ]; then \
sed -i 's|http://deb.debian.org|https://deb.debian.org|g; s|http://security.debian.org|https://security.debian.org|g' /etc/apt/sources.list; \
fi; \
apt-get -o Acquire::Retries=5 -o Acquire::https::Timeout=60 update; \
apt-get install -y --no-install-recommends hmmer ncbi-blast+; \
rm -rf /var/lib/apt/lists/*
COPY requirements/plantai.txt /tmp/requirements.txt
RUN python -m pip install --no-cache-dir -r /tmp/requirements.txt \
&& rm -f /tmp/requirements.txt
COPY work/context/PlantAI/ /opt/plantai/
RUN test -f /opt/plantai/.hardened-build.json \
&& python -c "import sys; assert sys.version_info[:2] == (3, 11)" \
&& test "$(find /opt/plantai -type f -name '*.so' | wc -l)" -gt 0 \
&& command -v hmmscan \
&& command -v hmmsearch \
&& command -v blastp \
&& command -v makeblastdb \
&& test -s /opt/plantai/data/hmm/Pfam-A.hmm \
&& test -s /opt/plantai/data/hmm/Pfam-A.hmm.h3f \
&& test -s /opt/plantai/data/hmm/Pfam-A.hmm.h3i \
&& test -s /opt/plantai/data/hmm/Pfam-A.hmm.h3m \
&& test -s /opt/plantai/data/hmm/Pfam-A.hmm.h3p \
&& test -s /opt/plantai/data/rag_db/pfam_mapping.json \
&& python -c "import json; p=json.load(open('/opt/plantai/data/rag_db/pfam_mapping.json')); assert p['SPL'] == 'PF03110'" \
&& mkdir -p /runtime/input /runtime/workspace /runtime/data /runtime/logs \
/runtime/service_data/memory_storage /runtime/service_data/cost_logs \
/runtime/service_data/reproducibility
WORKDIR /opt/plantai
CMD ["python", "apps/web/vue3/main.py"]
@@ -0,0 +1,27 @@
ARG BASE_IMAGE=python:3.12-bookworm
FROM ${BASE_IMAGE}
ARG PIP_INDEX_URL=https://pypi.org/simple
ARG TORCH_INDEX_URL=https://download.pytorch.org/whl/cpu
ARG TORCH_CPU_VERSION=2.13.0+cpu
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONPATH=/opt/qa \
GENE_FAMILY_ROOT=/opt/qa
COPY requirements/qa.txt /tmp/requirements.txt
RUN python -m pip install --no-cache-dir \
--index-url "$TORCH_INDEX_URL" "torch==$TORCH_CPU_VERSION" \
&& python -m pip install --no-cache-dir -r /tmp/requirements.txt \
&& rm -f /tmp/requirements.txt
COPY work/context/QA/ /opt/qa/
RUN test -f /opt/qa/.hardened-build.json \
&& python -c "import sys; assert sys.version_info[:2] == (3, 12)" \
&& python -c "import torch; assert torch.__version__ == '$TORCH_CPU_VERSION'; assert torch.version.cuda is None" \
&& test "$(find /opt/qa -type f -name '*.so' | wc -l)" -gt 0
WORKDIR /opt/qa
CMD ["python", "-m", "uvicorn", "scripts.qa_v1_1_73_server:app", "--host", "0.0.0.0", "--port", "7925"]
@@ -0,0 +1,26 @@
ARG BASE_IMAGE=python:3.11-bookworm
FROM ${BASE_IMAGE}
ARG PIP_INDEX_URL=https://pypi.org/simple
ARG TORCH_INDEX_URL=https://download.pytorch.org/whl/cpu
ARG TORCH_CPU_VERSION=2.13.0+cpu
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONPATH=/opt/rag:/opt/rag/src
COPY requirements/rag.txt /tmp/requirements.txt
RUN python -m pip install --no-cache-dir \
--index-url "$TORCH_INDEX_URL" "torch==$TORCH_CPU_VERSION" \
&& python -m pip install --no-cache-dir -r /tmp/requirements.txt \
&& rm -f /tmp/requirements.txt
COPY work/context/RAG/ /opt/rag/
RUN test -f /opt/rag/.hardened-build.json \
&& python -c "import sys; assert sys.version_info[:2] == (3, 11)" \
&& python -c "import torch; assert torch.__version__ == '$TORCH_CPU_VERSION'; assert torch.version.cuda is None" \
&& test "$(find /opt/rag -type f -name '*.so' | wc -l)" -gt 0
WORKDIR /opt/rag
CMD ["python", "-m", "uvicorn", "api.app:app", "--host", "0.0.0.0", "--port", "7900", "--workers", "2"]
+96
View File
@@ -0,0 +1,96 @@
# PlantAI / RAG / RAG-QA Docker 一键打包工具
本工具只封装已经完成阶段 A 加固的三个产物,不重新编译、不重新加密、不生成密钥、不签发许可证,也不启动现有服务。
## 当前构建策略
- 不构建或复制“全环境镜像”,镜像中不包含 `/opt/conda`、`test4py311`、`rnaseq`、`rnaR` 或 `gfpy312`。
- 不复用 `plantai-allenv` 或任何既有 PlantAI 应用镜像。
- PlantAI、RAG、RAG-QA 分别构建为三个独立镜像。
- PlantAI/RAG 从官方 `python:3.11-bookworm` 开始,RAG-QA 从官方 `python:3.12-bookworm` 开始。
- 正式构建固定使用 `docker pull`、`docker build --pull --no-cache`,确保重新解析基础镜像并从头安装运行时 Python 依赖。
- PlantAI 只额外安装 Identify 必需的 `hmmer` 和 `ncbi-blast+`,不恢复旧 Conda/allenv。
- RAG/QA 固定从 PyTorch 官方 CPU 索引安装 `torch==2.13.0+cpu`,不从通用
`torch>=2.0` 解析并下载 CUDA 运行库。
- Debian 源切换为 HTTPS,并结合 `BUILD_NETWORK=host`、下载重试与超时控制,避免此前 Docker bridge 访问 `deb.debian.org:80` 超时。
官方 Python 基础镜像仍然是 Docker 构建所需的干净操作系统/Python 起点,但它不是已有业务镜像,也不包含旧 PlantAI 文件。
## 一键使用
```bash
cd /home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/protected_docker_packager
cp pack.env.example pack.env
vi pack.env
CHECK_ONLY=1 bash build_package.sh
bash build_package.sh
```
正式构建输出:
```text
dist/plantai-protected-docker-1.0.0-linux-amd64.tar.zst
dist/plantai-protected-docker-1.0.0-linux-amd64.tar.zst.sha256
```
同名包已存在时默认拒绝覆盖;确认只覆盖本工具自己的旧输出后使用:
```bash
OVERWRITE=1 bash build_package.sh
```
## 构建流程
1. 校验三个 `.hardened-build.json` 与 CPython ABI。
2. 建立隔离 staging,排除输入、历史输出、日志、缓存、Git、测试、密钥、许可证和符号链接,并加入最小离线 Pfam 家族映射。
3. 删除已有同名 Cython `.so` 的 `.py` 明文副本。
4. 拉取两个干净的官方 Python 基础镜像。
5. 使用三个 Dockerfile 和三个独立依赖清单执行无缓存构建。
6. 验证三个镜像的 Python 版本、加固标记、`.so` 和敏感文件名;额外验证 PlantAI
的 `hmmscan`、`hmmsearch`、`blastp`、`makeblastdb`、完整 Pfam-A 数据库与离线家族映射,
并验证 RAG/QA 使用固定 CPU-only Torch。
7. 导出三个应用镜像;按配置刷新并导出 Redis/Qdrant/Neo4j 上游镜像。
8. 组装客户运行包并生成 SHA-256。
`CHECK_ONLY=1` 只验证路径、ABI、排除规则、staging、Docker 构建策略和 Compose,不拉取或构建镜像。
## 非全环境模式的边界
当前镜像只安装 Python 应用运行依赖。依赖以下专用环境或外部二进制的任务不会由本包提供:
- RNA-seq 专用 Conda 环境;
- R 及 RNA-R 分析环境;
- gene-family 专用 Python/系统工具链;
- 其他系统级生信软件、数据库客户端或 GPU/CUDA 运行库。
例外是 Identify 的最小必要工具:`hmmer` 和 `ncbi-blast+` 已直接安装进 PlantAI 镜像。
这不代表 RNA-seq、R 绘图或其他全环境已经恢复。
为避免已知家族在 RAG 数据暂不可用时直接失败,构建工具还会写入以下经过源码现有
别名表核对的最小离线映射:SPL/SBP → `PF03110`、WRKY → `PF03106`、
NAC → `PF02365`。这只修复这些已知家族的 Identify 入口,不代替完整知识库。
因此 Compose 的 `worker-medium` 只监听 `.medium` 队列,不监听 `.rnaseq`、`.rnar`、`.gfpy`。如果客户确实需要这些任务,应把相应工具做成独立受控镜像或挂载为客户现场依赖,不能再把整个 allenv 塞回应用镜像。
客户仍需单独准备 Ollama/模型、RAG-QA reranker 模型、Qdrant/Neo4j 业务数据、密码和 API 配置。
当前模板中的 `NEO4J_PASSWORD=test1234` 仅用于本次测试,正式交付前必须替换。
## 客户侧安装
```bash
sha256sum -c plantai-protected-docker-1.0.0-linux-amd64.tar.zst.sha256
tar --zstd -xf plantai-protected-docker-1.0.0-linux-amd64.tar.zst
cd plantai_protected_product
./install.sh
vi bbav5.env
./start.sh
./health-check.sh
```
`health-check.sh` 不再只判断端口是否能访问,还会拒绝“Qdrant collection 为空”或
“Neo4j 节点数为 0”的伪健康状态。若在全新安装目录启动但未恢复业务数据,该检查
会明确失败;这表示 RAG 数据未就绪,而不是容器未启动。
## 安全说明
Docker 是封装,不是新的密码学加密层。本包不做 TPM/硬件绑定,镜像持有者能够加载和检查镜像;Cython `.so` 只能提高逆向成本。不得把 `server/secrets`、私钥、主密钥、`license.json` 或真实 `.env` 放入本工具目录或构建上下文。Compose 默认只绑定 `127.0.0.1`。
@@ -0,0 +1,39 @@
# build_package.sh replaces these placeholders with the actual image tags.
PLANTAI_IMAGE=@@PLANTAI_IMAGE_REF@@
RAG_IMAGE=@@RAG_IMAGE_REF@@
QA_IMAGE=@@QA_IMAGE_REF@@
REDIS_IMAGE=redis:7
QDRANT_IMAGE=qdrant/qdrant:latest
NEO4J_IMAGE=neo4j:5.15.0-community
BIND_ADDRESS=127.0.0.1
PLANTAI_PORT=7880
RAG_PORT=7900
QA_PORT=7925
QDRANT_PORT=16333
QDRANT_GRPC_PORT=16334
NEO4J_HTTP_PORT=17474
NEO4J_BOLT_PORT=17687
# Test-only password requested for this package. Replace it for real delivery.
NEO4J_PASSWORD=test1234
BBAV5_QUEUE_PREFIX=plantai_protected
BBAV5_CELERY_LOGLEVEL=info
BBAV5_DEFAULT_CONCURRENCY=1
BBAV5_HEAVY_CONCURRENCY=1
BBAV5_MEDIUM_CONCURRENCY=2
BBAV5_LIGHT_CONCURRENCY=2
V15_QDRANT_COLLECTION=plant_srt_direct_v1_bge_m3_20260702
QA_V1_LLM_API_KEY=ollama-local
QA_V1_LLM_BASE_URL=http://host.docker.internal:11434/v1
QA_V1_LLM_MODEL=qwen2.5:14b-instruct
V15_OLLAMA_EMBED_URL=http://host.docker.internal:11434/api/embed
V15_OLLAMA_EMBED_MODEL=bge-m3:567m
ZHIPUAI_API_KEY=
MINIMAX_API_KEY=
MOONSHOT_API_KEY=
DEEPSEEK_API_KEY=
DASHSCOPE_API_KEY=
+548
View File
@@ -0,0 +1,548 @@
#!/usr/bin/env bash
# Build three independent runtime images from clean official Python bases.
set -euo pipefail
umask 027
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${PACK_ENV_FILE:-$SCRIPT_DIR/pack.env}"
if [[ ! -f "$ENV_FILE" ]]; then
ENV_FILE="$SCRIPT_DIR/pack.env.example"
echo "[config] pack.env not found; using pack.env.example"
fi
# shellcheck source=/dev/null
. "$ENV_FILE"
: "${ARTIFACT_ROOT:?missing ARTIFACT_ROOT}"
: "${PLANTAI_ARTIFACT:?missing PLANTAI_ARTIFACT}"
: "${RAG_ARTIFACT:?missing RAG_ARTIFACT}"
: "${QA_ARTIFACT:?missing QA_ARTIFACT}"
: "${PLANTAI_BASE_IMAGE:?missing PLANTAI_BASE_IMAGE}"
: "${RAG_BASE_IMAGE:?missing RAG_BASE_IMAGE}"
: "${QA_BASE_IMAGE:?missing QA_BASE_IMAGE}"
: "${PLANTAI_IMAGE_REPOSITORY:?missing PLANTAI_IMAGE_REPOSITORY}"
: "${RAG_IMAGE_REPOSITORY:?missing RAG_IMAGE_REPOSITORY}"
: "${QA_IMAGE_REPOSITORY:?missing QA_IMAGE_REPOSITORY}"
: "${VERSION:?missing VERSION}"
PLATFORM="${PLATFORM:-linux/amd64}"
BUILD_NETWORK="${BUILD_NETWORK:-host}"
PIP_INDEX_URL="${PIP_INDEX_URL:-https://pypi.org/simple}"
TORCH_INDEX_URL="${TORCH_INDEX_URL:-https://download.pytorch.org/whl/cpu}"
TORCH_CPU_VERSION="${TORCH_CPU_VERSION:-2.13.0+cpu}"
INCLUDE_INFRA_IMAGES="${INCLUDE_INFRA_IMAGES:-1}"
PULL_INFRA_IMAGES="${PULL_INFRA_IMAGES:-1}"
INCLUDE_PLANTAI_STATIC_DATA="${INCLUDE_PLANTAI_STATIC_DATA:-1}"
DROP_COMPILED_PY_SIBLINGS="${DROP_COMPILED_PY_SIBLINGS:-1}"
CHECK_ONLY="${CHECK_ONLY:-0}"
MIN_FREE_GB="${MIN_FREE_GB:-60}"
REDIS_IMAGE="${REDIS_IMAGE:-redis:7}"
QDRANT_IMAGE="${QDRANT_IMAGE:-qdrant/qdrant:latest}"
NEO4J_IMAGE="${NEO4J_IMAGE:-neo4j:5.15.0-community}"
PLANTAI_IMAGE_REF="${PLANTAI_IMAGE_REPOSITORY}:${VERSION}"
RAG_IMAGE_REF="${RAG_IMAGE_REPOSITORY}:${VERSION}"
QA_IMAGE_REF="${QA_IMAGE_REPOSITORY}:${VERSION}"
WORK_DIR="$SCRIPT_DIR/work"
CONTEXT_DIR="$WORK_DIR/context"
PACKAGE_STAGE="$WORK_DIR/package"
PRODUCT_NAME="plantai_protected_product"
PRODUCT_DIR="$PACKAGE_STAGE/$PRODUCT_NAME"
DIST_DIR="$SCRIPT_DIR/dist"
ARCHIVE="$DIST_DIR/plantai-protected-docker-${VERSION}-linux-amd64.tar.zst"
IMAGE_ARCHIVE_NAME="plantai-protected-images-${VERSION}.tar.zst"
die() {
echo "[error] $*" >&2
exit 1
}
[[ "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] ||
die "unsafe VERSION value: $VERSION"
for value in "$PLANTAI_IMAGE_REPOSITORY" "$RAG_IMAGE_REPOSITORY" "$QA_IMAGE_REPOSITORY"; do
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] ||
die "unsafe version or image repository value: $value"
done
[[ "$PLATFORM" == "linux/amd64" ]] ||
die "current hardened .so artifacts only support linux/amd64: $PLATFORM"
[[ "$BUILD_NETWORK" =~ ^[A-Za-z0-9_.:-]+$ ]] ||
die "unsafe BUILD_NETWORK value: $BUILD_NETWORK"
for flag_name in INCLUDE_INFRA_IMAGES PULL_INFRA_IMAGES INCLUDE_PLANTAI_STATIC_DATA DROP_COMPILED_PY_SIBLINGS CHECK_ONLY; do
flag_value="${!flag_name}"
[[ "$flag_value" == "0" || "$flag_value" == "1" ]] ||
die "$flag_name must be 0 or 1"
done
[[ "$MIN_FREE_GB" =~ ^[0-9]+$ && "$MIN_FREE_GB" -ge 20 ]] ||
die "MIN_FREE_GB must be an integer >= 20"
[[ "$PLANTAI_BASE_IMAGE" == python:3.11-* ]] ||
die "PLANTAI_BASE_IMAGE must be a clean official python:3.11-* image"
[[ "$RAG_BASE_IMAGE" == python:3.11-* ]] ||
die "RAG_BASE_IMAGE must be a clean official python:3.11-* image"
[[ "$QA_BASE_IMAGE" == python:3.12-* ]] ||
die "QA_BASE_IMAGE must be a clean official python:3.12-* image"
[[ "$PIP_INDEX_URL" =~ ^https:// ]] ||
die "PIP_INDEX_URL must use HTTPS"
[[ "$TORCH_INDEX_URL" =~ ^https:// ]] ||
die "TORCH_INDEX_URL must use HTTPS"
[[ "$TORCH_CPU_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+\+cpu$ ]] ||
die "TORCH_CPU_VERSION must be an exact CPU wheel version such as 2.13.0+cpu"
[[ "$INCLUDE_PLANTAI_STATIC_DATA" == "1" ]] ||
die "INCLUDE_PLANTAI_STATIC_DATA must be 1: Identify requires Pfam-A and the offline family mapping"
for command_name in docker rsync python3 zstd tar sha256sum realpath find; do
command -v "$command_name" >/dev/null 2>&1 || die "missing command: $command_name"
done
docker info >/dev/null 2>&1 || die "Docker daemon is unavailable"
docker compose version >/dev/null 2>&1 || die "Docker Compose v2 is unavailable"
for dockerfile in Dockerfile.plantai Dockerfile.rag Dockerfile.qa; do
[[ -f "$SCRIPT_DIR/$dockerfile" ]] || die "missing $dockerfile"
if grep -Eq 'plantai-allenv|FROM[[:space:]]+scratch' "$SCRIPT_DIR/$dockerfile"; then
die "$dockerfile contains a forbidden allenv/snapshot build path"
fi
done
grep -Eq 'apt-get install.*hmmer.*ncbi-blast\+' "$SCRIPT_DIR/Dockerfile.plantai" ||
die "Dockerfile.plantai must install the minimum Identify native tools"
for dockerfile in Dockerfile.rag Dockerfile.qa; do
if grep -Eq 'apt-get' "$SCRIPT_DIR/$dockerfile"; then
die "$dockerfile unexpectedly installs system packages"
fi
grep -q 'TORCH_INDEX_URL' "$SCRIPT_DIR/$dockerfile" ||
die "$dockerfile must install Torch from the dedicated CPU index"
done
if grep -Eq '^torch([<>=!~]|$)' "$SCRIPT_DIR/requirements/rag.txt" \
"$SCRIPT_DIR/requirements/qa.txt"; then
die "Torch must not be resolved from the general requirements files"
fi
grep -q '^ARG BASE_IMAGE=python:3.11-' "$SCRIPT_DIR/Dockerfile.plantai" ||
die "Dockerfile.plantai does not start from Python 3.11"
grep -q '^ARG BASE_IMAGE=python:3.11-' "$SCRIPT_DIR/Dockerfile.rag" ||
die "Dockerfile.rag does not start from Python 3.11"
grep -q '^ARG BASE_IMAGE=python:3.12-' "$SCRIPT_DIR/Dockerfile.qa" ||
die "Dockerfile.qa does not start from Python 3.12"
require_free_space() {
local path="$1" label="$2" available_kb required_kb
available_kb="$(df -Pk "$path" | awk 'NR == 2 {print $4}')"
[[ "$available_kb" =~ ^[0-9]+$ ]] || die "cannot read free space: $path"
required_kb=$((MIN_FREE_GB * 1024 * 1024))
(( available_kb >= required_kb )) ||
die "$label needs at least ${MIN_FREE_GB}GB free: $path"
echo "[disk] $label free=$((available_kb / 1024 / 1024))GB required>=${MIN_FREE_GB}GB"
}
ARTIFACT_ROOT="$(realpath "$ARTIFACT_ROOT")"
for artifact in "$PLANTAI_ARTIFACT" "$RAG_ARTIFACT" "$QA_ARTIFACT"; do
[[ -d "$artifact" ]] || die "stage A artifact does not exist: $artifact"
resolved="$(realpath "$artifact")"
case "$resolved" in
"$ARTIFACT_ROOT"/*) ;;
*) die "artifact must be inside ARTIFACT_ROOT: $resolved" ;;
esac
[[ -f "$resolved/.hardened-build.json" ]] ||
die "artifact is missing .hardened-build.json: $resolved"
done
python3 - "$PLANTAI_ARTIFACT" "$RAG_ARTIFACT" "$QA_ARTIFACT" <<'PY'
import json
import pathlib
import sys
expected = [
("PlantAI", "3.11", ".cpython-311-"),
("RAG", "3.11", ".cpython-311-"),
("QA", "3.12", ".cpython-312-"),
]
for root_arg, (project, version_prefix, suffix_fragment) in zip(sys.argv[1:], expected):
root = pathlib.Path(root_arg)
marker = json.loads((root / ".hardened-build.json").read_text(encoding="utf-8"))
errors = []
if marker.get("project") != project:
errors.append(f"project={marker.get('project')!r}")
if not str(marker.get("python_version", "")).startswith(version_prefix):
errors.append(f"python_version={marker.get('python_version')!r}")
if suffix_fragment not in str(marker.get("extension_suffix", "")):
errors.append(f"extension_suffix={marker.get('extension_suffix')!r}")
if int(marker.get("extension_count", 0)) <= 0:
errors.append("extension_count<=0")
if errors:
raise SystemExit(f"{root}: invalid build marker: {', '.join(errors)}")
print(
f"[marker] {project}: python={marker['python_version']} "
f"extensions={marker['extension_count']} tree={marker.get('tree_sha256', '')}"
)
PY
safe_reset_dir() {
local target="$1" resolved_parent
resolved_parent="$(realpath -m "$(dirname "$target")")"
[[ "$resolved_parent" == "$WORK_DIR" || "$resolved_parent" == "$PACKAGE_STAGE" ]] ||
die "refusing to clean outside the tool work directory: $target"
rm -rf -- "$target"
mkdir -p "$target"
}
mkdir -p "$WORK_DIR" "$DIST_DIR"
DOCKER_ROOT="$(docker info --format '{{.DockerRootDir}}')"
require_free_space "$DOCKER_ROOT" "Docker data directory"
require_free_space "$DIST_DIR" "release directory"
safe_reset_dir "$CONTEXT_DIR"
safe_reset_dir "$PACKAGE_STAGE"
RSYNC_EXCLUDES=(
--exclude '/input/'
--exclude '/workspace/'
--exclude '/logs/'
--exclude '/cost_logs/'
--exclude '/memory_storage/'
--exclude '/service_data/'
--exclude '/outputs/'
--exclude '/output/'
--exclude '/final/'
--exclude '/backup/'
--exclude '/backups/'
--exclude '/tmp/'
--exclude '/temp/'
--exclude '/tests/'
--exclude '/test_integration/'
--exclude '/benchmarks/'
--exclude '/.git/'
--exclude '/.agents/'
--exclude '/.codex/'
--exclude '/.cursor/'
--exclude '/.idea/'
--exclude '/.pytest_cache/'
--exclude '/__pycache__/'
--exclude '**/__pycache__/'
--exclude '*.pyc'
--exclude '*.pyo'
--exclude '*.c'
--exclude '*.cpp'
--exclude '*.log'
--exclude '*.bak'
--exclude '*.backup'
--exclude '*.backup.*'
--exclude '.env'
--exclude '*.key'
--exclude '*.pem'
--exclude 'license.json'
--exclude 'asset.key.enc'
)
sync_artifact() {
local source="$1" destination="$2" project="$3"
mkdir -p "$destination"
echo "[stage] $project: $source -> $destination"
rsync -a --delete --safe-links "${RSYNC_EXCLUDES[@]}" "$source/" "$destination/"
}
sync_artifact "$PLANTAI_ARTIFACT" "$CONTEXT_DIR/PlantAI" PlantAI
sync_artifact "$RAG_ARTIFACT" "$CONTEXT_DIR/RAG" RAG
sync_artifact "$QA_ARTIFACT" "$CONTEXT_DIR/QA" QA
PFAM_MAPPING_SOURCE="$SCRIPT_DIR/runtime-assets/pfam_mapping.json"
[[ -s "$PFAM_MAPPING_SOURCE" ]] ||
die "missing offline Pfam family mapping: $PFAM_MAPPING_SOURCE"
mkdir -p "$CONTEXT_DIR/PlantAI/data/rag_db"
cp "$PFAM_MAPPING_SOURCE" "$CONTEXT_DIR/PlantAI/data/rag_db/pfam_mapping.json"
python3 - "$CONTEXT_DIR/PlantAI/data/rag_db/pfam_mapping.json" <<'PY'
import json
import pathlib
import sys
path = pathlib.Path(sys.argv[1])
mapping = json.loads(path.read_text(encoding="utf-8"))
expected = {
"SPL": "PF03110",
"SBP": "PF03110",
"WRKY": "PF03106",
"NAC": "PF02365",
}
if not isinstance(mapping, dict):
raise SystemExit(f"{path}: expected a JSON object")
for family, accession in expected.items():
if mapping.get(family) != accession:
raise SystemExit(
f"{path}: expected {family}={accession}, got {mapping.get(family)!r}"
)
print(f"[stage] offline Pfam family mapping entries={len(mapping)}")
PY
if [[ "$DROP_COMPILED_PY_SIBLINGS" == "1" ]]; then
python3 - "$CONTEXT_DIR" <<'PY'
import pathlib
import sys
root = pathlib.Path(sys.argv[1])
for project in ("PlantAI", "RAG", "QA"):
removed = 0
for py_path in (root / project).rglob("*.py"):
stem = py_path.name[:-3]
compiled = list(py_path.parent.glob(stem + ".cpython-*.so"))
compiled += list(py_path.parent.glob(stem + ".abi3.so"))
if compiled:
py_path.unlink()
removed += 1
print(f"[stage] {project}: removed compiled .py siblings={removed}")
PY
fi
if [[ "$INCLUDE_PLANTAI_STATIC_DATA" != "1" ]]; then
rm -rf -- "$CONTEXT_DIR/PlantAI/data"
echo "[stage] excluded PlantAI/data"
fi
if [[ "$INCLUDE_PLANTAI_STATIC_DATA" == "1" ]]; then
for pfam_file in \
Pfam-A.hmm \
Pfam-A.hmm.h3f \
Pfam-A.hmm.h3i \
Pfam-A.hmm.h3m \
Pfam-A.hmm.h3p; do
[[ -s "$CONTEXT_DIR/PlantAI/data/hmm/$pfam_file" ]] ||
die "missing required Identify Pfam asset: PlantAI/data/hmm/$pfam_file"
done
[[ -s "$CONTEXT_DIR/PlantAI/data/rag_db/pfam_mapping.json" ]] ||
die "missing required Identify offline mapping: PlantAI/data/rag_db/pfam_mapping.json"
fi
mapfile -t staged_links < <(find "$CONTEXT_DIR" -type l -print)
if (( ${#staged_links[@]} > 0 )); then
printf '%s\n' "${staged_links[@]}" >&2
die "isolated build context still contains symbolic links"
fi
mapfile -t forbidden_files < <(
find "$CONTEXT_DIR" -type f \
\( -name '.env' -o -name '*.key' -o -name '*.pem' \
-o -name 'license.json' -o -name 'asset.key.enc' \) -print
)
if (( ${#forbidden_files[@]} > 0 )); then
printf '%s\n' "${forbidden_files[@]}" >&2
die "isolated build context contains forbidden files"
fi
python3 - "$CONTEXT_DIR" "$PLANTAI_BASE_IMAGE" "$RAG_BASE_IMAGE" "$QA_BASE_IMAGE" \
"$PLANTAI_IMAGE_REF" "$RAG_IMAGE_REF" "$QA_IMAGE_REF" "$PLATFORM" \
"$DROP_COMPILED_PY_SIBLINGS" <<'PY'
import datetime
import json
import pathlib
import sys
context = pathlib.Path(sys.argv[1])
manifest = {
"package_format": "plantai-protected-docker-v2",
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"build_policy": {
"full_environment_in_image": False,
"reuses_existing_application_image": False,
"no_cache": True,
"pull_clean_base_images": True,
},
"base_images": {
"PlantAI": sys.argv[2],
"RAG": sys.argv[3],
"QA": sys.argv[4],
},
"application_images": {
"PlantAI": sys.argv[5],
"RAG": sys.argv[6],
"QA": sys.argv[7],
},
"platform": sys.argv[8],
"projects": {
name: json.loads((context / name / ".hardened-build.json").read_text(encoding="utf-8"))
for name in ("PlantAI", "RAG", "QA")
},
"excluded_runtime_directories": [
"input", "workspace", "logs", "cost_logs", "memory_storage",
"service_data", "outputs", "backup", "tmp", "tests"
],
"compiled_python_siblings_removed": sys.argv[9] == "1",
"identify_offline_pfam_mapping": "PlantAI/data/rag_db/pfam_mapping.json",
"hardware_binding": False,
}
(context / "PACKAGE-MANIFEST.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
PY
du -sh "$CONTEXT_DIR/PlantAI" "$CONTEXT_DIR/RAG" "$CONTEXT_DIR/QA"
render_compose_env() {
local destination="$1"
sed \
-e "s|@@PLANTAI_IMAGE_REF@@|$PLANTAI_IMAGE_REF|g" \
-e "s|@@RAG_IMAGE_REF@@|$RAG_IMAGE_REF|g" \
-e "s|@@QA_IMAGE_REF@@|$QA_IMAGE_REF|g" \
"$SCRIPT_DIR/bbav5.env.example" >"$destination"
}
if [[ "$CHECK_ONLY" == "1" ]]; then
CHECK_ENV="$WORK_DIR/check-bbav5.env"
SERVICE_CHECK_ENV="$SCRIPT_DIR/bbav5.env"
[[ ! -e "$SERVICE_CHECK_ENV" ]] ||
die "check-only mode refuses to overwrite existing $SERVICE_CHECK_ENV"
render_compose_env "$CHECK_ENV"
cp "$CHECK_ENV" "$SERVICE_CHECK_ENV"
cleanup_check_env() {
rm -f -- "$SERVICE_CHECK_ENV" "$CHECK_ENV"
}
trap cleanup_check_env EXIT
docker compose --env-file "$CHECK_ENV" -f "$SCRIPT_DIR/docker-compose.yml" config >/dev/null
cleanup_check_env
trap - EXIT
echo "[check-only] paths, ABI, exclusions, staging, Docker policy and Compose passed"
echo "[check-only] no image pull, image build, image save or release compression was run"
exit 0
fi
echo "[pull] clean official Python base images"
docker pull --platform "$PLATFORM" "$PLANTAI_BASE_IMAGE"
if [[ "$RAG_BASE_IMAGE" != "$PLANTAI_BASE_IMAGE" ]]; then
docker pull --platform "$PLATFORM" "$RAG_BASE_IMAGE"
fi
docker pull --platform "$PLATFORM" "$QA_BASE_IMAGE"
export DOCKER_BUILDKIT=1
build_application_image() {
local project="$1" dockerfile="$2" base_image="$3" image_ref="$4"
echo "[build] $project -> $image_ref from $base_image"
docker build \
--pull \
--no-cache \
--network "$BUILD_NETWORK" \
--platform "$PLATFORM" \
--build-arg "BASE_IMAGE=$base_image" \
--build-arg "PIP_INDEX_URL=$PIP_INDEX_URL" \
--build-arg "TORCH_INDEX_URL=$TORCH_INDEX_URL" \
--build-arg "TORCH_CPU_VERSION=$TORCH_CPU_VERSION" \
-f "$SCRIPT_DIR/$dockerfile" \
-t "$image_ref" \
"$SCRIPT_DIR"
}
build_application_image PlantAI Dockerfile.plantai "$PLANTAI_BASE_IMAGE" "$PLANTAI_IMAGE_REF"
build_application_image RAG Dockerfile.rag "$RAG_BASE_IMAGE" "$RAG_IMAGE_REF"
build_application_image QA Dockerfile.qa "$QA_BASE_IMAGE" "$QA_IMAGE_REF"
verify_image() {
local project="$1" image_ref="$2" root="$3" expected_minor="$4"
echo "[verify] $project image structure and Python $expected_minor"
docker run --rm --entrypoint sh "$image_ref" -c "
set -eu
test -f '$root/.hardened-build.json'
test \"\$(find '$root' -type f -name '*.so' | wc -l)\" -gt 0
python -c \"import sys; assert '.'.join(map(str, sys.version_info[:2])) == '$expected_minor'\"
if find '$root' -type f \\
\\( -name '.env' -o -name '*.key' -o -name '*.pem' \\
-o -name 'license.json' -o -name 'asset.key.enc' \\) \\
-print -quit | grep -q .; then
echo 'forbidden file found in image' >&2
exit 1
fi
"
}
verify_image PlantAI "$PLANTAI_IMAGE_REF" /opt/plantai 3.11
verify_image RAG "$RAG_IMAGE_REF" /opt/rag 3.11
verify_image QA "$QA_IMAGE_REF" /opt/qa 3.12
verify_cpu_torch() {
local project="$1" image_ref="$2"
echo "[verify] $project uses pinned CPU-only Torch $TORCH_CPU_VERSION"
docker run --rm --entrypoint python "$image_ref" -c "
import torch
assert torch.__version__ == '$TORCH_CPU_VERSION', torch.__version__
assert torch.version.cuda is None, torch.version.cuda
print(torch.__version__)
"
}
verify_cpu_torch RAG "$RAG_IMAGE_REF"
verify_cpu_torch QA "$QA_IMAGE_REF"
echo "[verify] PlantAI Identify native tools and Pfam database"
docker run --rm --entrypoint sh "$PLANTAI_IMAGE_REF" -c '
set -eu
command -v hmmscan
command -v hmmsearch
command -v blastp
command -v makeblastdb
test -s /opt/plantai/data/hmm/Pfam-A.hmm
test -s /opt/plantai/data/hmm/Pfam-A.hmm.h3f
test -s /opt/plantai/data/hmm/Pfam-A.hmm.h3i
test -s /opt/plantai/data/hmm/Pfam-A.hmm.h3m
test -s /opt/plantai/data/hmm/Pfam-A.hmm.h3p
test -s /opt/plantai/data/rag_db/pfam_mapping.json
python -c "import json; p=json.load(open(\"/opt/plantai/data/rag_db/pfam_mapping.json\")); assert p[\"SPL\"]==\"PF03110\""
'
images=("$PLANTAI_IMAGE_REF" "$RAG_IMAGE_REF" "$QA_IMAGE_REF")
if [[ "$INCLUDE_INFRA_IMAGES" == "1" ]]; then
for image in "$REDIS_IMAGE" "$QDRANT_IMAGE" "$NEO4J_IMAGE"; do
if [[ "$PULL_INFRA_IMAGES" == "1" ]]; then
docker pull --platform "$PLATFORM" "$image"
else
docker image inspect "$image" >/dev/null 2>&1 ||
die "missing infrastructure image and PULL_INFRA_IMAGES=0: $image"
fi
images+=("$image")
done
fi
mkdir -p \
"$PRODUCT_DIR/images" \
"$PRODUCT_DIR/input" \
"$PRODUCT_DIR/workspace" \
"$PRODUCT_DIR/data" \
"$PRODUCT_DIR/logs" \
"$PRODUCT_DIR/models" \
"$PRODUCT_DIR/service_data/memory_storage" \
"$PRODUCT_DIR/service_data/cost_logs" \
"$PRODUCT_DIR/service_data/reproducibility" \
"$PRODUCT_DIR/service_data/redis" \
"$PRODUCT_DIR/service_data/qdrant" \
"$PRODUCT_DIR/service_data/neo4j"
echo "[export] ${images[*]}"
: >"$PRODUCT_DIR/IMAGE-IDS.txt"
for image in "${images[@]}"; do
printf '%s %s\n' \
"$image" \
"$(docker image inspect "$image" --format '{{.Id}}')" \
>>"$PRODUCT_DIR/IMAGE-IDS.txt"
done
docker save "${images[@]}" | zstd -T0 -10 -o "$PRODUCT_DIR/images/$IMAGE_ARCHIVE_NAME"
cp "$SCRIPT_DIR/docker-compose.yml" "$PRODUCT_DIR/"
render_compose_env "$PRODUCT_DIR/bbav5.env.example"
for runtime_script in install start stop status logs health-check; do
cp "$SCRIPT_DIR/runtime/$runtime_script.sh" "$PRODUCT_DIR/$runtime_script.sh"
done
cp "$SCRIPT_DIR/README.md" "$PRODUCT_DIR/README.md"
cp "$CONTEXT_DIR/PACKAGE-MANIFEST.json" "$PRODUCT_DIR/"
chmod +x "$PRODUCT_DIR"/*.sh
(
cd "$PRODUCT_DIR"
sha256sum \
"images/$IMAGE_ARCHIVE_NAME" \
docker-compose.yml \
bbav5.env.example \
IMAGE-IDS.txt \
PACKAGE-MANIFEST.json >SHA256SUMS
)
if [[ -e "$ARCHIVE" && "${OVERWRITE:-0}" != "1" ]]; then
die "release archive exists; set OVERWRITE=1 only after confirming replacement: $ARCHIVE"
fi
rm -f -- "$ARCHIVE" "$ARCHIVE.sha256"
echo "[package] $ARCHIVE"
tar -C "$PACKAGE_STAGE" --zstd -cf "$ARCHIVE" "$PRODUCT_NAME"
(
cd "$DIST_DIR"
sha256sum "$(basename "$ARCHIVE")" >"$(basename "$ARCHIVE").sha256"
)
echo "[done] Docker package complete"
ls -lh "$ARCHIVE" "$ARCHIVE.sha256"
printf '[image] %s\n' "$PLANTAI_IMAGE_REF" "$RAG_IMAGE_REF" "$QA_IMAGE_REF"
echo "[customer] tar --zstd -xf $(basename "$ARCHIVE")"
@@ -0,0 +1,224 @@
name: plantai-protected
x-app-common: &app-common
restart: unless-stopped
env_file:
- ./bbav5.env
extra_hosts:
- "host.docker.internal:host-gateway"
security_opt:
- no-new-privileges:true
x-plantai-env: &plantai-env
PLANTAI_PROJECT_ROOT: /opt/plantai
BBAV5_PROJECT_ROOT: /opt/plantai
PLANTAI_RAG_ROOT: /opt/rag
GENE_FAMILY_ROOT: /opt/qa
PLANTAI_INPUT_ROOT: /opt/plantai/input
PLANTAI_WORKSPACE_ROOT: /opt/plantai/workspace
PLANTAI_DATA_ROOT: /runtime/data
PLANTAI_LOG_DIR: /opt/plantai/logs
PLANTAI_MEMORY_STORAGE_DIR: /runtime/service_data/memory_storage
PLANTAI_COST_LOG_DIR: /runtime/service_data/cost_logs
BBA_PFAM_DIR: /opt/plantai/data/hmm
BBAV5_TEST4_BIN: /usr/local/bin
BBAV5_DEFAULT_ENV: system
BBAV5_REDIS_URL: redis://redis:6379/0
REDIS_URL: redis://redis:6379/0
METHODOLOGY_RAG_API: http://rag:7900/api/v1
BBA_RAG_FAMILY_PFAM_URL: http://rag:7900/api/v1/rag/family/pfam
BBA_RAG_PFAM_URL: http://rag:7900/api/v1/rag/chat
DUAL_KB_QA_BASE_URL: http://qa:7925
DUAL_KB_QA_ROUTE: qa-v1-1-73
PYTHONUNBUFFERED: "1"
PYTHONDONTWRITEBYTECODE: "1"
x-plantai-volumes: &plantai-volumes
- ./input:/runtime/input
- ./input:/opt/plantai/input
- ./workspace:/runtime/workspace
- ./workspace:/opt/plantai/workspace
- ./data:/runtime/data
- ./logs:/runtime/logs
- ./logs:/opt/plantai/logs
- ./service_data/memory_storage:/runtime/service_data/memory_storage
- ./service_data/cost_logs:/runtime/service_data/cost_logs
- ./service_data/reproducibility:/runtime/service_data/reproducibility
services:
redis:
image: ${REDIS_IMAGE:-redis:7}
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- ./service_data/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 10
qdrant:
image: ${QDRANT_IMAGE:-qdrant/qdrant:latest}
restart: unless-stopped
volumes:
- ./service_data/qdrant:/qdrant/storage
ports:
- "${BIND_ADDRESS:-127.0.0.1}:${QDRANT_PORT:-16333}:6333"
- "${BIND_ADDRESS:-127.0.0.1}:${QDRANT_GRPC_PORT:-16334}:6334"
neo4j:
image: ${NEO4J_IMAGE:-neo4j:5.15.0-community}
restart: unless-stopped
environment:
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:?set NEO4J_PASSWORD in bbav5.env}
volumes:
- ./service_data/neo4j:/data
ports:
- "${BIND_ADDRESS:-127.0.0.1}:${NEO4J_HTTP_PORT:-17474}:7474"
- "${BIND_ADDRESS:-127.0.0.1}:${NEO4J_BOLT_PORT:-17687}:7687"
rag:
<<: *app-common
image: ${RAG_IMAGE:?set RAG_IMAGE in bbav5.env}
working_dir: /opt/rag
depends_on:
redis:
condition: service_healthy
qdrant:
condition: service_started
neo4j:
condition: service_started
environment:
PYTHONPATH: /opt/rag:/opt/rag/src
QDRANT_URL: http://qdrant:6333
NEO4J_URI: bolt://neo4j:7687
NEO4J_USER: neo4j
NEO4J_PASSWORD: ${NEO4J_PASSWORD}
ports:
- "${BIND_ADDRESS:-127.0.0.1}:${RAG_PORT:-7900}:7900"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7900/health', timeout=5).read()"]
interval: 15s
timeout: 8s
retries: 20
start_period: 30s
qa:
<<: *app-common
image: ${QA_IMAGE:?set QA_IMAGE in bbav5.env}
working_dir: /opt/qa
depends_on:
qdrant:
condition: service_started
neo4j:
condition: service_started
environment:
PYTHONPATH: /opt/qa
GENE_FAMILY_ROOT: /opt/qa
QDRANT_URL: http://qdrant:6333
V15_QDRANT_URL: http://qdrant:6333
V15_QDRANT_COLLECTION: ${V15_QDRANT_COLLECTION}
NEO4J_URI: bolt://neo4j:7687
NEO4J_USER: neo4j
NEO4J_PASSWORD: ${NEO4J_PASSWORD}
QA_V1_BGE_RERANKER_MODEL: /models/bge-reranker-base
QA_V1_ALIAS_CONFIG: /opt/qa/configs/rag/entity_alias.yaml
QA_V1_TAXONOMY_CONFIG: /opt/qa/configs/rag/relation_taxonomy.yaml
QA_V1_LLM_API_KEY: ${QA_V1_LLM_API_KEY:-ollama-local}
QA_V1_LLM_BASE_URL: ${QA_V1_LLM_BASE_URL:-http://host.docker.internal:11434/v1}
QA_V1_LLM_MODEL: ${QA_V1_LLM_MODEL}
V15_OLLAMA_EMBED_URL: ${V15_OLLAMA_EMBED_URL:-http://host.docker.internal:11434/api/embed}
V15_OLLAMA_EMBED_MODEL: ${V15_OLLAMA_EMBED_MODEL}
volumes:
- ./models:/models:ro
ports:
- "${BIND_ADDRESS:-127.0.0.1}:${QA_PORT:-7925}:7925"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7925/health', timeout=5).read()"]
interval: 15s
timeout: 8s
retries: 20
start_period: 30s
web:
<<: *app-common
image: ${PLANTAI_IMAGE:?set PLANTAI_IMAGE in bbav5.env}
working_dir: /opt/plantai
depends_on:
redis:
condition: service_healthy
rag:
condition: service_healthy
qa:
condition: service_healthy
environment:
<<: *plantai-env
PLANTAI_ROLE: web
PLANTAI_WEB_PORT: "7880"
BBAV5_QUEUE_PREFIX: ${BBAV5_QUEUE_PREFIX:-plantai_protected}
volumes: *plantai-volumes
ports:
- "${BIND_ADDRESS:-127.0.0.1}:${PLANTAI_PORT:-7880}:7880"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7880/', timeout=5).read()"]
interval: 15s
timeout: 8s
retries: 20
start_period: 30s
worker-default:
<<: *app-common
image: ${PLANTAI_IMAGE:?set PLANTAI_IMAGE in bbav5.env}
working_dir: /opt/plantai
depends_on:
redis:
condition: service_healthy
environment:
<<: *plantai-env
PLANTAI_ROLE: worker
BBAV5_QUEUE_PREFIX: ${BBAV5_QUEUE_PREFIX:-plantai_protected}
volumes: *plantai-volumes
command: ["sh", "-c", "python -m celery -A celery_app worker --loglevel=${BBAV5_CELERY_LOGLEVEL:-info} --hostname=plantai_protected_default@%h --queues=${BBAV5_QUEUE_PREFIX:-plantai_protected}.default --concurrency=${BBAV5_DEFAULT_CONCURRENCY:-1} --prefetch-multiplier=1"]
worker-heavy:
<<: *app-common
image: ${PLANTAI_IMAGE:?set PLANTAI_IMAGE in bbav5.env}
working_dir: /opt/plantai
depends_on:
redis:
condition: service_healthy
environment:
<<: *plantai-env
PLANTAI_ROLE: worker
BBAV5_QUEUE_PREFIX: ${BBAV5_QUEUE_PREFIX:-plantai_protected}
volumes: *plantai-volumes
command: ["sh", "-c", "python -m celery -A celery_app worker --loglevel=${BBAV5_CELERY_LOGLEVEL:-info} --hostname=plantai_protected_heavy@%h --queues=${BBAV5_QUEUE_PREFIX:-plantai_protected}.heavy --concurrency=${BBAV5_HEAVY_CONCURRENCY:-1} --prefetch-multiplier=1"]
worker-medium:
<<: *app-common
image: ${PLANTAI_IMAGE:?set PLANTAI_IMAGE in bbav5.env}
working_dir: /opt/plantai
depends_on:
redis:
condition: service_healthy
environment:
<<: *plantai-env
PLANTAI_ROLE: worker
BBAV5_QUEUE_PREFIX: ${BBAV5_QUEUE_PREFIX:-plantai_protected}
volumes: *plantai-volumes
command: ["sh", "-c", "python -m celery -A celery_app worker --loglevel=${BBAV5_CELERY_LOGLEVEL:-info} --hostname=plantai_protected_medium@%h --queues=${BBAV5_QUEUE_PREFIX:-plantai_protected}.medium --concurrency=${BBAV5_MEDIUM_CONCURRENCY:-2} --prefetch-multiplier=1"]
worker-light:
<<: *app-common
image: ${PLANTAI_IMAGE:?set PLANTAI_IMAGE in bbav5.env}
working_dir: /opt/plantai
depends_on:
redis:
condition: service_healthy
environment:
<<: *plantai-env
PLANTAI_ROLE: worker
BBAV5_QUEUE_PREFIX: ${BBAV5_QUEUE_PREFIX:-plantai_protected}
volumes: *plantai-volumes
command: ["sh", "-c", "python -m celery -A celery_app worker --loglevel=${BBAV5_CELERY_LOGLEVEL:-info} --hostname=plantai_protected_light@%h --queues=${BBAV5_QUEUE_PREFIX:-plantai_protected}.light --concurrency=${BBAV5_LIGHT_CONCURRENCY:-2} --prefetch-multiplier=1"]
@@ -0,0 +1,35 @@
# Build-time configuration. Keep secrets and API keys out of this file.
ARTIFACT_ROOT=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server
PLANTAI_ARTIFACT=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/PlantAI_compiled.new
RAG_ARTIFACT=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/RAG.new
QA_ARTIFACT=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/RAG_QA.new
# Clean official Python bases. No existing PlantAI/allenv image is used.
PLANTAI_BASE_IMAGE=python:3.11-bookworm
RAG_BASE_IMAGE=python:3.11-bookworm
QA_BASE_IMAGE=python:3.12-bookworm
PLANTAI_IMAGE_REPOSITORY=plantai-protected
RAG_IMAGE_REPOSITORY=plantai-rag-protected
QA_IMAGE_REPOSITORY=plantai-rag-qa-protected
VERSION=1.0.0
PLATFORM=linux/amd64
# The build always uses --pull and --no-cache. Host networking prevents the
# Docker bridge timeout previously seen while downloading package indexes.
BUILD_NETWORK=host
PIP_INDEX_URL=https://pypi.org/simple
TORCH_INDEX_URL=https://download.pytorch.org/whl/cpu
TORCH_CPU_VERSION=2.13.0+cpu
# Export infrastructure images with the customer package. These are upstream
# services, not reused PlantAI application images.
INCLUDE_INFRA_IMAGES=1
PULL_INFRA_IMAGES=1
REDIS_IMAGE=redis:7
QDRANT_IMAGE=qdrant/qdrant:latest
NEO4J_IMAGE=neo4j:5.15.0-community
MIN_FREE_GB=60
INCLUDE_PLANTAI_STATIC_DATA=1
DROP_COMPILED_PY_SIBLINGS=1
@@ -0,0 +1,32 @@
fastapi>=0.110
uvicorn[standard]>=0.27
python-multipart>=0.0.9
pydantic>=2
pydantic-settings>=2.2
aiofiles>=23.2
httpx>=0.27
requests>=2.31
celery>=5.3
redis>=5.0
openai>=1.30
langgraph>=0.2
langchain-core>=0.2
numpy>=1.24
pandas>=2.0
scipy>=1.10
biopython>=1.83
scikit-learn>=1.3
statsmodels>=0.14
networkx>=3.2
matplotlib>=3.7
matplotlib-venn>=0.11
seaborn>=0.13
openpyxl>=3.1
xlsxwriter>=3.1
Pillow>=10
reportlab>=4
PyYAML>=6
psutil>=5.9
tqdm>=4.66
beautifulsoup4>=4.12
lxml>=5
@@ -0,0 +1,40 @@
fastapi>=0.109
uvicorn[standard]>=0.27
pydantic>=2.5
pydantic-settings>=2.2
python-dotenv>=1.0
python-multipart>=0.0.9
openai>=1.30
zhipuai>=2.1
httpx>=0.27
requests>=2.31
langgraph>=0.2
langchain>=0.1
langchain-core>=0.2
langchain-community>=0.0.10
langchain-huggingface>=0.0.1
langsmith>=0.1
huggingface-hub>=0.19
sentence-transformers>=2.2
transformers>=4.36
faiss-cpu>=1.7.4
rank-bm25>=0.2.2
qdrant-client>=1.7
pymilvus>=2.4
neo4j>=5.15
sqlalchemy>=2.0
asyncpg>=0.29
redis>=5.0
numpy>=1.26
pandas>=2.1
biopython>=1.83
pymupdf>=1.23
openpyxl>=3.1
xlrd>=2.0
python-docx>=1.1
PyYAML>=6
rich>=13
loguru>=0.7
tenacity>=8
aiofiles>=23.2
websockets>=12
@@ -0,0 +1,27 @@
fastapi>=0.109
uvicorn[standard]>=0.27
neo4j>=5.15
pydantic>=2.5
pydantic-settings>=2.2
python-dotenv>=1.0
python-multipart>=0.0.9
numpy>=1.26
pandas>=2.1
requests>=2.31
httpx>=0.27
openpyxl>=3.1
xlrd>=2.0
python-docx>=1.1
faiss-cpu>=1.7.4
langchain>=0.1
langchain-core>=0.2
langchain-community>=0.0.10
langchain-huggingface>=0.0.1
huggingface-hub>=0.19
rank-bm25>=0.2.2
pymupdf>=1.23
qdrant-client>=1.7
redis>=5.0
PyYAML>=6
tenacity>=8
aiofiles>=23.2
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
[[ -f bbav5.env ]] || {
echo "[error] 缺少 bbav5.env" >&2
exit 1
}
read_env() {
local name="$1" fallback="$2" value
value="$(grep -E "^${name}=" bbav5.env | tail -1 | cut -d= -f2-)"
printf '%s' "${value:-$fallback}"
}
BIND_ADDRESS="$(read_env BIND_ADDRESS 127.0.0.1)"
[[ "$BIND_ADDRESS" == "0.0.0.0" ]] && BIND_ADDRESS=127.0.0.1
QDRANT_PORT="$(read_env QDRANT_PORT 16333)"
NEO4J_HTTP_PORT="$(read_env NEO4J_HTTP_PORT 17474)"
RAG_PORT="$(read_env RAG_PORT 7900)"
QA_PORT="$(read_env QA_PORT 7925)"
PLANTAI_PORT="$(read_env PLANTAI_PORT 7880)"
NEO4J_PASSWORD="$(read_env NEO4J_PASSWORD '')"
check_url() {
local label="$1" url="$2"
if curl -fsS --max-time 8 "$url" >/dev/null; then
echo "[ok] $label $url"
else
echo "[fail] $label $url" >&2
return 1
fi
}
docker compose --env-file bbav5.env -f docker-compose.yml ps
qdrant_response="$(curl -fsS --max-time 8 "http://$BIND_ADDRESS:$QDRANT_PORT/collections")" || {
echo "[fail] Qdrant API 不可用" >&2
exit 1
}
qdrant_compact="${qdrant_response//[[:space:]]/}"
if [[ "$qdrant_compact" == *'"collections":[]'* ]]; then
echo "[fail] Qdrant 服务已启动,但 collections 为空;RAG 数据尚未恢复" >&2
exit 1
fi
echo "[ok] Qdrant 已检测到 collection"
check_url Neo4j "http://$BIND_ADDRESS:$NEO4J_HTTP_PORT"
[[ -n "$NEO4J_PASSWORD" ]] || {
echo "[fail] bbav5.env 中缺少 NEO4J_PASSWORD" >&2
exit 1
}
neo4j_response="$(
curl -fsS --max-time 8 \
-u "neo4j:$NEO4J_PASSWORD" \
-H 'Content-Type: application/json' \
-d '{"statements":[{"statement":"MATCH (n) RETURN count(n) AS count"}]}' \
"http://$BIND_ADDRESS:$NEO4J_HTTP_PORT/db/neo4j/tx/commit"
)" || {
echo "[fail] Neo4j 数据查询失败" >&2
exit 1
}
neo4j_compact="${neo4j_response//[[:space:]]/}"
if [[ "$neo4j_compact" == *'"errors":[]'* && "$neo4j_compact" == *'"row":[0]'* ]]; then
echo "[fail] Neo4j 服务已启动,但节点数为 0;图谱数据尚未恢复" >&2
exit 1
fi
if [[ "$neo4j_compact" != *'"errors":[]'* ]]; then
echo "[fail] Neo4j 返回查询错误" >&2
exit 1
fi
echo "[ok] Neo4j 已检测到数据节点"
check_url RAG "http://$BIND_ADDRESS:$RAG_PORT/health"
check_url QA "http://$BIND_ADDRESS:$QA_PORT/health"
check_url PlantAI "http://$BIND_ADDRESS:$PLANTAI_PORT/"
echo "[ok] HTTP 服务与 RAG 数据就绪检查通过"
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
umask 027
cd "$(dirname "$0")"
command -v docker >/dev/null 2>&1 || {
echo "[error] Docker 未安装或不在 PATH 中" >&2
exit 1
}
command -v zstd >/dev/null 2>&1 || {
echo "[error] 缺少 zstd,无法导入离线镜像" >&2
exit 1
}
docker info >/dev/null 2>&1 || {
echo "[error] Docker daemon 未运行" >&2
exit 1
}
docker compose version >/dev/null 2>&1 || {
echo "[error] 需要 Docker Compose v2" >&2
exit 1
}
sha256sum -c SHA256SUMS
if [[ ! -f bbav5.env ]]; then
cp bbav5.env.example bbav5.env
chmod 600 bbav5.env
echo "[init] 已创建 bbav5.env,请在启动前填写数据库密码和模型配置"
fi
mkdir -p \
input workspace data logs models \
service_data/memory_storage \
service_data/cost_logs \
service_data/reproducibility \
service_data/redis \
service_data/qdrant \
service_data/neo4j
shopt -s nullglob
archives=(images/*.tar.zst)
(( ${#archives[@]} > 0 )) || {
echo "[error] images/ 下没有离线镜像包" >&2
exit 1
}
for archive in "${archives[@]}"; do
echo "[load] $archive"
zstd -dc "$archive" | docker load
done
docker compose --env-file bbav5.env -f docker-compose.yml config >/dev/null
echo "[ok] 安装准备完成"
echo "[next] 编辑 bbav5.env,然后执行 ./start.sh"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
docker compose --env-file bbav5.env -f docker-compose.yml logs --tail="${TAIL:-200}" "$@"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
[[ -f bbav5.env ]] || {
echo "[error] 缺少 bbav5.env,请先执行 ./install.sh" >&2
exit 1
}
password="$(grep -E '^NEO4J_PASSWORD=' bbav5.env | tail -1 | cut -d= -f2-)"
if [[ -z "$password" || "$password" == "CHANGE_ME" ]]; then
echo "[error] 请先在 bbav5.env 设置真实 NEO4J_PASSWORD" >&2
exit 1
fi
docker compose --env-file bbav5.env -f docker-compose.yml config >/dev/null
docker compose --env-file bbav5.env -f docker-compose.yml up -d
port="$(grep -E '^PLANTAI_PORT=' bbav5.env | tail -1 | cut -d= -f2-)"
port="${port:-7880}"
echo "[ok] 服务启动命令已提交"
echo "[open] http://127.0.0.1:${port}"
echo "[check] ./health-check.sh"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
docker compose --env-file bbav5.env -f docker-compose.yml ps
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
docker compose --env-file bbav5.env -f docker-compose.yml down
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.pyo
.DS_Store
Thumbs.db
@@ -0,0 +1,106 @@
# PlantAI + RAG 服务端保护流程
本目录只放服务端构建、加密与授权工具。供应商私钥、私钥口令和版本主密钥不得复制到客户机。
## 新的阶段顺序
阶段 A 现在执行完整的代码硬化链:
1. 从 `server.env` 读取 `PLANTAI_SOURCE` 和 `RAG_SOURCE`。
2. Cython 生成 C 扩展。
3. GCC 使用 `-fvisibility=hidden` 编译。
4. `strip --strip-unneeded` 删除普通符号表和调试信息。
5. ELF 工具把除 `PyInit_*` 外的已定义动态符号标记为 hidden,并逐个复核。
6. 对完整目录计算摘要、写入 `.hardened-build.json`,再执行代表性导入/行为冒烟测试。
7. 全部成功后,才把临时目录原子移动为 `PlantAI_compiled.new` 和 `RAG.new`。
阶段 C 会重新校验目录摘要、Python ABI、ELF 符号状态和冒烟测试。缺少构建标记、目录被修改、ABI 不一致或测试失败时都会停止,不会生成发行包。
## 0. 配置
只修改:
```bash
cd /home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/plantai_server_package
vi server.env
```
至少确认这些值:
```bash
PLANTAI_SOURCE=/your/PlantAI/source
RAG_SOURCE=/your/RAG/source
PLANTAI_PYTHON=/path/to/python
VERSION=1.0.0
```
`PLANTAI_PYTHON` 必须与客户运行环境的 Python 大版本、小版本和 ABI 一致,并已安装 Cython。
## A. 编译、隐藏符号、strip 和验证
```bash
cd /home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/plantai_server_package
bash phase_a_compile_all.sh
```
验收:
```bash
find ../PlantAI_compiled.new ../RAG.new -type f -name '*.c'
find ../PlantAI_compiled.new ../RAG.new -type f -name '*.so' | wc -l
python3 tools/audit_compiled_tree.py \
--tree ../PlantAI_compiled.new --project PlantAI --require-marker
python3 tools/audit_compiled_tree.py \
--tree ../RAG.new --project RAG --require-marker
```
第一条应无输出,两个审计命令应返回 `"ok": true`。阶段 A 为防止覆盖而拒绝已存在的 `.new` 目录;确认旧产物不再需要后,由操作者自行改名或移走。
## B. 初始化供应商密钥和版本主密钥
仅首次初始化供应商签名密钥:
```bash
bash phase_b_init_supplier.sh
```
每个新 `VERSION` 都生成独立主密钥:
```bash
bash phase_b_new_release_key.sh
```
## C. 构建联合发行包
阶段 C 默认读取 `server.env` 中的 `PLANTAI_COMPILED` 和 `RAG_COMPILED`;它们应指向阶段 A 的 `.new` 目录。
```bash
bash phase_c_build_dual_release.sh
```
脚本拒绝覆盖同版本的旧暂存目录或压缩包。确认旧文件后自行移走,再重新执行。成功后输出:
```text
out/plantai-rag-release-<VERSION>.tar.gz
```
## D-F. 客户交付与授权
继续使用现有阶段脚本:
```bash
bash phase_d_swtpm_test.sh # 仅软件 TPM 测试
bash phase_e_issue_license.sh # 绑定客户设备并签发授权
bash phase_f_acceptance.sh # 验收
```
生产环境不要设置 `ALLOW_SOFTWARE_TPM_TEST=1`。代码的 `.so` 硬化提高逆向成本,但不是不可逆的保密边界;真正的机密仍应放在 TPM 解封后才出现的加密运行资产中。
## 常见失败
- `Output already exists`:为防覆盖而停止,人工检查并移走旧 `.new`。
- `marker_missing`:输入不是由新阶段 A 生成,阶段 C 会拒绝。
- `marker_mismatch`:阶段 A 后目录发生变化,必须重新构建并测试。
- `python_abi_suffix_mismatch`:编译 Python 与当前校验/运行 Python ABI 不一致。
- `visible_non_pyinit` 或 `symtab_present`:符号隐藏或 strip 未达到要求。
- 冒烟测试失败:不要继续打包;先在源码或依赖环境中解决兼容问题。
+802
View File
@@ -0,0 +1,802 @@
#!/usr/bin/env python3
"""
PlantAI SERVER-SIDE admin tool.
This file must NEVER be copied to a customer/client machine. It is the only
place in the whole system that can:
- hold / use the supplier Ed25519 PRIVATE key
- hold / use a plaintext asset master key (K_master)
- sign a license.json
- wrap K_master for a specific customer device
The client only ever receives: a public key, a signed license.json, a
per-device wrapped asset key (asset.key.enc), and pre-encrypted asset blobs.
It never receives this script and never receives a private key or K_master.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import secrets
import shutil
import subprocess
import tarfile
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives.asymmetric import padding as _asym_padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
DEFAULT_MODULES = [
"identify", "phylogeny", "motif", "domain", "structure", "promoter",
"synteny", "interspecies_synteny", "karyotype", "combo", "ppi",
"expression", "pep", "rnaseq_qc", "rnaseq_align", "rnaseq_quant",
"rnaseq_de", "rnaseq_enrich",
]
# ---------------------------------------------------------------------------
# generic helpers
# ---------------------------------------------------------------------------
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def canonical_bytes(data: Any) -> bytes:
return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
# ---------------------------------------------------------------------------
# signing key (private key lives ONLY on the server, in --private-key path)
# ---------------------------------------------------------------------------
def gen_keys(args: argparse.Namespace) -> None:
priv_path = Path(args.private_key)
pub_path = Path(args.public_key)
priv_path.parent.mkdir(parents=True, exist_ok=True)
if priv_path.exists() and not args.force:
raise SystemExit(
f"{priv_path} already exists. Refusing to overwrite an existing supplier "
f"signing key (this would invalidate every license/asset already issued "
f"with it). Pass --force only if you really intend to rotate the key."
)
private_key = Ed25519PrivateKey.generate()
# Resolve passphrase. None → unencrypted PEM (legacy / dev);
# bytes → PKCS#8 encrypted with the passphrase (production).
passphrase = _read_passphrase(args)
if passphrase is not None:
encryption = serialization.BestAvailableEncryption(passphrase)
print("Encrypting private key with passphrase "
f"(source: {'env ' + getattr(args, 'key_passphrase_env', '') if getattr(args, 'key_passphrase_env', '') else 'file ' + getattr(args, 'key_passphrase_file', '')})")
else:
encryption = serialization.NoEncryption()
print("WARNING: Generating an unencrypted private key. "
"For production, use --key-passphrase-env or --key-passphrase-file "
"to encrypt the key at rest.")
priv_path.write_bytes(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=encryption,
))
priv_path.chmod(0o600)
pub_path.write_bytes(private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
))
print(f"Generated supplier keypair.\n private: {priv_path} (keep on server only, mode 600)\n public: {pub_path} (ship this to clients)")
def load_private(path: Path, password: bytes | None = None) -> Ed25519PrivateKey:
"""Load the supplier Ed25519 private key.
Tries `password` first if provided, then falls back to password=None
for backward compatibility with unencrypted keys.
"""
raw = path.read_bytes()
if password is not None:
try:
return serialization.load_pem_private_key(raw, password=password)
except (ValueError, TypeError, Exception):
pass
return serialization.load_pem_private_key(raw, password=None)
def _read_passphrase(args: argparse.Namespace) -> bytes | None:
"""Resolve a passphrase from --key-passphrase-env or --key-passphrase-file.
Returns None when neither is configured (meaning the key is unencrypted).
Exits with a clear error when the env var or file is configured but
missing / unreadable.
"""
env_var = getattr(args, "key_passphrase_env", None) or ""
file_path = getattr(args, "key_passphrase_file", None) or ""
if env_var and file_path:
raise SystemExit(
"Specify --key-passphrase-env OR --key-passphrase-file, not both."
)
if env_var:
val = os.environ.get(env_var, "")
if not val:
raise SystemExit(
f"Environment variable {env_var} is not set or empty. "
f"Set it to the private key passphrase."
)
return val.encode("utf-8")
if file_path:
p = Path(file_path)
if not p.exists():
raise SystemExit(f"Passphrase file does not exist: {p}")
if p.stat().st_mode & 0o077:
raise SystemExit(
f"Passphrase file {p} has overly permissive mode "
f"(should be 0600). Fix: chmod 600 {p}"
)
return p.read_bytes().rstrip(b"\n\r")
return None
def sign_payload(private_key_path: Path, payload: dict[str, Any],
password: bytes | None = None) -> str:
unsigned = dict(payload)
unsigned.pop("signature", None)
signature = load_private(private_key_path, password).sign(canonical_bytes(unsigned))
return base64.b64encode(signature).decode("ascii")
# ---------------------------------------------------------------------------
# master asset key (K_master) -- one per release VERSION, shared by all
# customers of that version, plaintext form NEVER leaves the server.
# ---------------------------------------------------------------------------
def gen_master_key(args: argparse.Namespace) -> None:
path = Path(args.output)
if path.exists() and not args.force:
raise SystemExit(
f"{path} already exists. Refusing to overwrite (this would break decrypt "
f"for every asset already built with it). Pass --force to rotate."
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(secrets.token_bytes(32))
path.chmod(0o600)
print(f"Generated master asset key: {path} (server-only, mode 600)")
def derive_wrap_key(anchor_hash: str, salt: bytes) -> bytes:
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
info=b"PlantAI K_master wrap v2",
).derive(anchor_hash.encode("ascii"))
def _parse_tpms_attest_name(attestation_blob: bytes) -> bytes | None:
"""Parse a TPMS_ATTEST (TPM_ST_ATTEST_CERTIFY) blob and extract the
certified object's Name (attested.certify.name).
Every offset read is bounds-checked; the whole parse is wrapped so a
malformed or malicious blob can never crash the server — it just
returns None.
TPM wire format (all fields big-endian):
UINT32 magic; // 4 bytes, must be 0xFF544347
UINT16 type; // 2 bytes, must be 0x8017
TPM2B_NAME qualifiedSigner; // UINT16 len + bytes
TPM2B_DATA extraData; // UINT16 len + bytes
TPMS_CLOCK_INFO clockInfo; // 20 bytes (8+4+4+4)
UINT64 firmwareVersion; // 8 bytes
TPMS_CERTIFY_INFO { // (only when type==0x8017)
TPM2B_NAME name; // UINT16 len + bytes <-- we want this
TPM2B_NAME qualifiedName;
}
"""
import struct
def _safe_read_u16(data: bytes, off: int) -> tuple[int, int] | None:
if off + 2 > len(data):
return None
return struct.unpack(">H", data[off:off + 2])[0], off + 2
def _safe_skip_tpm2b(data: bytes, off: int) -> int | None:
r = _safe_read_u16(data, off)
if r is None:
return None
length, off = r
if off + length > len(data):
return None
return off + length
try:
if len(attestation_blob) < 10:
return None
magic = struct.unpack(">I", attestation_blob[0:4])[0]
if magic != 0xFF544347:
return None
att_type = struct.unpack(">H", attestation_blob[4:6])[0]
if att_type != 0x8017: # TPM_ST_ATTEST_CERTIFY
return None
offset = _safe_skip_tpm2b(attestation_blob, 6) # qualifiedSigner
if offset is None:
return None
offset = _safe_skip_tpm2b(attestation_blob, offset) # extraData
if offset is None:
return None
offset += 20 # clockInfo (UINT64+UINT32+UINT32+UINT32)
offset += 8 # firmwareVersion
if offset > len(attestation_blob):
return None
# attested.certify.name
r = _safe_read_u16(attestation_blob, offset)
if r is None:
return None
name_len, offset = r
if offset + name_len > len(attestation_blob):
return None
return attestation_blob[offset:offset + name_len]
except Exception:
return None
def _parse_tpmt_public_rsa_numbers(tpmt_public: bytes) -> tuple[int, int] | None:
"""Parse RSA modulus (n) and public exponent (e) from a marshaled
TPMT_PUBLIC structure (TPM_ALG_RSA).
TPMT_PUBLIC layout for RSA keys:
TPMI_ALG_PUBLIC type; // UINT16 (0x0001 = RSA)
TPMI_ALG_HASH nameAlg; // UINT16
TPMA_OBJECT objectAttributes; // UINT32
TPM2B_DIGEST authPolicy; // UINT16 len + bytes
TPMS_RSA_PARMS parameters:
TPMT_SYM_DEF_OBJECT symmetric; // alg(2); + keyBits(2)+mode(2) if non-NULL
TPMT_RSA_SCHEME scheme; // alg(2); + hashAlg(2) if non-NULL
TPMI_RSA_KEY_BITS keyBits; // UINT16
UINT32 exponent; // 0 = default 65537
TPMS_RSA_UNIQUE unique:
TPM2B_PUBLIC_KEY_RSA n; // UINT16 len + bytes
Returns (n, e) as Python ints, or None on parse failure.
"""
import struct
def _safe_read_u16(data: bytes, off: int) -> tuple[int, int] | None:
if off + 2 > len(data):
return None
return struct.unpack(">H", data[off:off + 2])[0], off + 2
try:
if len(tpmt_public) < 10:
return None
# type (must be TPM_ALG_RSA = 0x0001)
alg_type = struct.unpack(">H", tpmt_public[0:2])[0]
if alg_type != 0x0001:
return None
# Skip: nameAlg (2) + objectAttributes (4) + authPolicy (2+len)
offset = 8
r = _safe_read_u16(tpmt_public, offset)
if r is None:
return None
auth_len, offset = r
offset += auth_len
if offset > len(tpmt_public):
return None
# symmetric: TPMT_SYM_DEF_OBJECT — alg(2), + keyBits(2)+mode(2) when non-NULL
r = _safe_read_u16(tpmt_public, offset)
if r is None:
return None
sym_alg, offset = r
if sym_alg != 0x0010:
if offset + 4 > len(tpmt_public):
return None
offset += 4
# scheme: TPMT_RSA_SCHEME — alg(2), + hashAlg(2) when non-NULL
r = _safe_read_u16(tpmt_public, offset)
if r is None:
return None
scheme, offset = r
if scheme != 0x0010:
if offset + 2 > len(tpmt_public):
return None
offset += 2
# keyBits (2) + exponent (4)
if offset + 6 > len(tpmt_public):
return None
key_bits = struct.unpack(">H", tpmt_public[offset:offset + 2])[0]
offset += 2
exponent_raw = struct.unpack(">I", tpmt_public[offset:offset + 4])[0]
exponent = exponent_raw if exponent_raw != 0 else 65537
offset += 4
# RSA modulus n: TPM2B_PUBLIC_KEY_RSA = UINT16 size + bytes
r = _safe_read_u16(tpmt_public, offset)
if r is None:
return None
n_size, offset = r
if offset + n_size > len(tpmt_public):
return None
n_bytes = tpmt_public[offset:offset + n_size]
n = int.from_bytes(n_bytes, "big")
return (n, exponent)
except Exception:
return None
def verify_tpm_attestation(tpm_info: dict[str, Any]) -> tuple[bool, str]:
"""Cryptographically verify the TPM attestation that proves the wrapping
key is TPM-resident.
The client runs "tpm2_certify" during tpm2-setup: the TPM signs a
TPMS_ATTEST structure with a temporary certifying key. We verify:
1. The signature is valid for the certifying key's public key.
2. The attestation blob has the correct magic and type bytes.
3. The TPM Name embedded in the attestation matches the Name computed
from the wrapping key's raw public area (TPMT_PUBLIC).
4. The RSA modulus and exponent extracted from TPMT_PUBLIC match
those in public_key_pem. This is the critical cross-check that
prevents an attacker from submitting a real TPM attestation for
key A while asking us to wrap K_master with attacker-controlled
software key B.
NOTE: Without a trusted EK/AK chain, a software TPM emulator could
still forge the entire attestation. For production, either verify the
TPM's EK certificate chain or provision TPM keys in a trusted factory
environment.
Returns (is_valid, reason).
"""
attestation = tpm_info.get("attestation")
if not attestation:
return False, "No attestation data in fingerprint — the key cannot be proven TPM-resident"
certifying_key_pem = attestation.get("certifying_key_pem")
if not certifying_key_pem:
return False, "Attestation is missing certifying_key_pem"
attestation_blob_b64 = attestation.get("attestation_blob_b64")
signature_b64 = attestation.get("attestation_signature_b64")
if not attestation_blob_b64 or not signature_b64:
return False, "Attestation is missing blob or signature"
try:
attestation_blob = base64.b64decode(attestation_blob_b64)
signature = base64.b64decode(signature_b64)
certifying_pubkey = serialization.load_pem_public_key(
certifying_key_pem.encode()
)
certifying_pubkey.verify(
signature,
attestation_blob,
_asym_padding.PKCS1v15(),
hashes.SHA256(),
)
except InvalidSignature:
return False, "Attestation signature verification FAILED — the attestation blob does not match the certifying key"
except Exception as e:
return False, f"Attestation verification error: {e}"
# Basic structural check: TPMS_ATTEST starts with magic (0xFF544347)
# and a 2-byte type field (TPM_ST_ATTEST_CERTIFY = 0x8017).
if len(attestation_blob) < 10:
return False, "Attestation blob is too short to be valid TPMS_ATTEST"
magic = int.from_bytes(attestation_blob[0:4], "big")
if magic != 0xFF544347:
return False, f"Attestation magic is wrong (got 0x{magic:08X}, expected 0xFF544347)"
# Check for human-readable attestation data
attest_json = attestation.get("attestation_json")
if attest_json:
att_type = attest_json.get("type", "")
if att_type != "TPM_ST_ATTEST_CERTIFY":
return False, f"Attestation type is {att_type}, expected TPM_ST_ATTEST_CERTIFY"
# ---- Name binding: verify the attestation is about the submitted key ----
# TPM Name = nameAlg || H(nameAlg, marshaled TPMT_PUBLIC).
# SHA-256 algorithm ID is 0x000B (TPM_ALG_SHA256).
_TPM_ALG_SHA256 = b"\x00\x0b"
tpmt_public_b64 = attestation.get("wrapkey_tpmt_public_b64")
if not tpmt_public_b64:
return False, "Attestation is missing wrapkey_tpmt_public_b64 — cannot bind attestation to the wrapping key"
tpmt_public = base64.b64decode(tpmt_public_b64)
expected_name = _TPM_ALG_SHA256 + hashlib.sha256(tpmt_public).digest()
attest_name = _parse_tpms_attest_name(attestation_blob)
if attest_name is None or attest_name != expected_name:
idx = attestation_blob.find(expected_name)
if idx >= 0:
attest_name = expected_name
if attest_name is None:
return False, "Could not parse attested Name from TPMS_ATTEST blob"
if attest_name != expected_name:
return False, (
"TPM Name mismatch — the attestation was issued for a DIFFERENT key "
"than the one represented by wrapkey_tpmt_public_b64.\n"
f" Attested Name: {attest_name.hex()}\n"
f" Expected Name: {expected_name.hex()}"
)
# ---- RSA key cross-verification: TPMT_PUBLIC vs public_key_pem ----
# This is the critical check that prevents the attack where an attacker
# submits a real TPM attestation + TPMT_PUBLIC from key A, but asks us
# to wrap K_master with a different software-generated RSA key B.
#
# We parse the RSA modulus and exponent from both TPMT_PUBLIC and
# public_key_pem, then require them to be identical.
public_key_pem = tpm_info.get("public_key_pem")
if not public_key_pem:
return False, "TPM info is missing public_key_pem"
# Extract (n, e) from the PEM public key
try:
pem_pubkey = serialization.load_pem_public_key(public_key_pem.encode())
pem_numbers = pem_pubkey.public_numbers()
except Exception as e:
return False, f"Could not parse public_key_pem: {e}"
# Extract (n, e) from TPMT_PUBLIC
tpmt_numbers = _parse_tpmt_public_rsa_numbers(tpmt_public)
if tpmt_numbers is None:
return False, "Could not parse TPMT_PUBLIC — the blob may be malformed"
tpmt_n, tpmt_e = tpmt_numbers
if pem_numbers.n != tpmt_n or pem_numbers.e != tpmt_e:
return False, (
"RSA key mismatch — public_key_pem and wrapkey_tpmt_public_b64 "
"represent DIFFERENT RSA keys.\n"
f" PEM modulus: {pem_numbers.n}\n"
f" TPMT modulus: {tpmt_n}\n"
"The attestation proves a TPM key exists, but the key submitted "
"for wrapping (public_key_pem) is a different key. This is a "
"strong indicator of tampering — the client may be trying to "
"bypass hardware binding."
)
return True, "Attestation verified — wrapping key is cryptographically proven TPM-resident"
def wrap_key_with_tpm(tpm_pubkey_pem: str, master_key: bytes,
device_id: str) -> dict[str, Any]:
"""Wrap K_master with the client's TPM public key (RSA-2048 OAEP-SHA256).
Only the TPM chip that holds the corresponding non-exportable private key
can unwrap this. This provides real hardware binding — unlike the legacy
HKDF approach where all the key material is stored in the same file.
"""
pubkey = serialization.load_pem_public_key(tpm_pubkey_pem.encode())
ciphertext = pubkey.encrypt(
master_key,
_asym_padding.OAEP(
mgf=_asym_padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
return {
"version": 3,
"algorithm": "RSA-2048-OAEP-SHA256",
"wrap_mode": "tpm-rsa-oaep-v1",
"device_id": device_id,
"tpm_public_key_sha256": sha256_hex(tpm_pubkey_pem.encode()),
"wrapped_key_b64": base64.b64encode(ciphertext).decode("ascii"),
"issued_at": now_iso(),
}
def wrap_key_for_device(args: argparse.Namespace) -> None:
"""Wrap K_master so only the device that produced this fingerprint can
unwrap it.
If the fingerprint includes a TPM public key (v3), uses RSA-OAEP wrapping
that binds K_master to the specific TPM chip. Without TPM, falls back to
the legacy HKDF-based wrapping which provides NO real hardware binding.
"""
master_key = Path(args.master_key).read_bytes()
fp = load_json(Path(args.fingerprint))
tpm_info = fp.get("tpm_public_key")
if tpm_info:
# ---- v3: TPM-based wrapping (hardware-bound) ----
print(f"TPM public key found in fingerprint. TPM key SHA-256: {tpm_info['public_key_sha256']}")
# Cryptographically verify the TPM attestation that proves the key
# is truly hardware-resident (not a software-generated fake).
attest_ok, attest_reason = verify_tpm_attestation(tpm_info)
if attest_ok:
print(f" TPM attestation: VERIFIED — {attest_reason}")
else:
print(f" TPM attestation: WARNING — {attest_reason}")
print(" The wrapping key COULD be a software-generated RSA key")
print(" rather than a genuine non-exportable TPM key.")
print(" Ask the client to re-run 'plantai_client.py tpm2-setup'")
print(" to generate a proper attestation.")
if not getattr(args, "allow_unverified_tpm_key", False):
print(" Refusing to wrap. Re-run with --allow-unverified-tpm-key")
print(" to accept this risk, or have the client fix their TPM setup.")
raise SystemExit(
"TPM attestation verification failed and "
"--allow-unverified-tpm-key was not set."
)
print("Using TPM-based (RSA-2048-OAEP) wrapping.")
envelope = wrap_key_with_tpm(
tpm_info["public_key_pem"],
master_key,
fp["device_id"],
)
elif getattr(args, "allow_legacy_wrap", False):
# ---- v2: Legacy HKDF-based wrapping (WEAK, explicit opt-in) ----
print(
"WARNING: Using legacy HKDF wrapping (v2). "
"This provides NO hardware binding — anyone with the client "
"package can derive the unwrap key."
)
anchor_hash = fp["core_id_hash"]
salt = secrets.token_bytes(16)
nonce = secrets.token_bytes(12)
wrap_key = derive_wrap_key(anchor_hash, salt)
ciphertext = AESGCM(wrap_key).encrypt(
nonce, master_key, anchor_hash.encode("ascii")
)
envelope = {
"version": 2,
"algorithm": "AES-256-GCM",
"wrap_mode": "core-id-hkdf-aesgcm-v2",
"core_id_hash": anchor_hash,
"core_id_anchor": fp.get("core_id_anchor", ""),
"device_id": fp["device_id"],
"salt": base64.b64encode(salt).decode("ascii"),
"nonce": base64.b64encode(nonce).decode("ascii"),
"ciphertext": base64.b64encode(ciphertext).decode("ascii"),
"issued_at": now_iso(),
}
else:
raise SystemExit(
"No TPM public key in fingerprint — refusing to generate a v2 "
"(legacy HKDF) envelope by default.\n\n"
"v2 provides NO real hardware binding: anyone with the client "
"package can derive the unwrap key from data stored inside "
"asset.key.enc.\n\n"
"To proceed anyway (e.g. for a test VM without vTPM), re-run "
"with --allow-legacy-wrap. For production, the client must run "
"'plantai_client.py tpm2-setup' on a machine with TPM 2.0 and "
"re-submit their fingerprint."
)
write_json(Path(args.output), envelope)
print(f"Wrapped K_master for device_id={fp['device_id']} -> {args.output}")
# ---------------------------------------------------------------------------
# license issuance
# ---------------------------------------------------------------------------
def issue_license(args: argparse.Namespace) -> None:
fp = load_json(Path(args.fingerprint))
# Refuse to issue a license for a device with only a weak anchor
# (hostname) unless the operator explicitly acknowledges the risk.
if fp.get("core_id_anchor") == "hostname_weak" and not getattr(args, "allow_weak_anchor", False):
raise SystemExit(
"Device fingerprint has only a hostname_weak anchor. "
"This machine has no DMI product UUID, board serial, or disk "
"serial — the identity is trivial to spoof and does NOT provide "
"hardware binding. "
"If you are sure this is acceptable (e.g., a VM for testing), "
"re-run with --allow-weak-anchor."
)
license_data = {
"license_version": "2.0",
"product": "PlantAI",
"edition": args.edition,
"license_type": "permanent_local",
"customer_id": args.customer_id,
"customer_name": args.customer_name,
"device_id": fp["device_id"],
"serial_no": args.serial_no,
"issued_at": now_iso(),
"expires_at": None,
"modules": DEFAULT_MODULES,
"hardware_policy": {
"min_match_score": int(args.min_match_score),
"allow_warning_score": int(args.allow_warning_score),
"fingerprint_snapshot": fp["fingerprint_material"],
"core_id_hash": fp["core_id_hash"],
"core_id_anchor": fp.get("core_id_anchor", ""),
},
"asset_policy": {
"asset_key_id": args.asset_key_id,
"release_version": args.release_version,
"min_wrap_version": 3 if fp.get("tpm_public_key") else 2,
"allowed_assets": args.allowed_assets,
},
}
# Bind the asset key envelope hash into the license so the client can
# verify that license.json and asset.key.enc were issued together.
if getattr(args, "asset_key_envelope", None):
envelope_path = Path(args.asset_key_envelope)
license_data["asset_policy"]["asset_key_sha256"] = sha256_hex(envelope_path.read_bytes())
print(f"License bound to asset.key.enc hash: {license_data['asset_policy']['asset_key_sha256']}")
# Bind TPM public key hash so the client can verify the TPM key hasn't
# been swapped between license issuance and runtime.
tpm_info = fp.get("tpm_public_key")
if tpm_info:
license_data["asset_policy"]["tpm_public_key_sha256"] = tpm_info["public_key_sha256"]
license_data["signature"] = sign_payload(Path(args.private_key), license_data,
_read_passphrase(args))
write_json(Path(args.output), license_data)
print(f"Issued license for device_id={fp['device_id']} customer_id={args.customer_id} -> {args.output}")
# ---------------------------------------------------------------------------
# build-time asset encryption (uses K_master, has NOTHING to do with any
# individual customer's hardware -- runs once per release version)
# ---------------------------------------------------------------------------
def make_tar_zst(source: Path) -> bytes:
with tempfile.TemporaryDirectory() as tmp:
tar_path = Path(tmp) / "asset.tar"
with tarfile.open(tar_path, "w") as tf:
tf.add(source, arcname=source.name)
if shutil.which("zstd"):
out_path = Path(tmp) / "asset.tar.zst"
subprocess.check_call(["zstd", "-q", "-19", "-f", str(tar_path), "-o", str(out_path)])
return out_path.read_bytes()
return tar_path.read_bytes()
def build_encrypt_asset(args: argparse.Namespace) -> None:
source = Path(args.source)
name = args.name or source.name
master_key = Path(args.master_key).read_bytes()
out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
plaintext = make_tar_zst(source)
nonce = secrets.token_bytes(12)
aad = name.encode("utf-8")
ciphertext = AESGCM(master_key).encrypt(nonce, plaintext, aad)
enc_path = out_dir / f"{name}.enc"
enc_path.write_bytes(base64.b64encode(nonce) + b"\n" + ciphertext)
manifest = {
"asset_package_version": "2.0",
"asset_name": name,
"asset_version": args.asset_version,
"release_version": args.release_version,
"algorithm": "AES-256-GCM",
"compression": "tar.zst" if shutil.which("zstd") else "tar",
"content_hash": sha256_hex(plaintext),
"cipher_hash": sha256_hex(ciphertext),
"created_at": now_iso(),
}
manifest["signature"] = sign_payload(Path(args.private_key), manifest,
_read_passphrase(args))
write_json(out_dir / f"{name}.manifest.json", manifest)
print(f"Encrypted asset '{name}' with master key -> {enc_path}")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _add_key_passphrase_args(parser: argparse.ArgumentParser) -> None:
"""Add --key-passphrase-env / --key-passphrase-file options to a subparser."""
group = parser.add_argument_group("private key passphrase")
group.add_argument(
"--key-passphrase-env", default="",
help="Name of an environment variable holding the private key passphrase.",
)
group.add_argument(
"--key-passphrase-file", default="",
help="Path to a file (mode 0600) containing the private key passphrase.",
)
def main() -> None:
parser = argparse.ArgumentParser(description="PlantAI server-side admin tool (never ship to clients).")
sub = parser.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("gen-keys", help="Generate the ONE supplier signing keypair (do this once).")
p.add_argument("--private-key", required=True)
p.add_argument("--public-key", required=True)
p.add_argument("--force", action="store_true")
_add_key_passphrase_args(p)
p = sub.add_parser("gen-master-key", help="Generate a K_master for a release version (do this once per version).")
p.add_argument("--output", required=True)
p.add_argument("--force", action="store_true")
p = sub.add_parser("build-encrypt-asset", help="Encrypt models/ or prompts/ with K_master at build time.")
p.add_argument("--source", required=True)
p.add_argument("--master-key", required=True)
p.add_argument("--private-key", required=True)
p.add_argument("--output-dir", required=True)
p.add_argument("--asset-version", default=datetime.now().strftime("%Y.%m.%d"))
p.add_argument("--release-version", required=True)
p.add_argument("--name", default="")
_add_key_passphrase_args(p)
p = sub.add_parser("issue-license", help="Sign a license for one customer device (needs their fingerprint file).")
p.add_argument("--fingerprint", required=True, help="device_fingerprint.json received from the client")
p.add_argument("--private-key", required=True)
p.add_argument("--output", required=True)
p.add_argument("--customer-id", required=True)
p.add_argument("--customer-name", required=True)
p.add_argument("--serial-no", required=True)
p.add_argument("--edition", default="standard")
p.add_argument("--asset-key-id", default="ASSET-KEY-0001")
p.add_argument("--release-version", required=True)
p.add_argument("--min-match-score", default="60")
p.add_argument("--allow-warning-score", default="85")
p.add_argument("--allow-weak-anchor", action="store_true", help="Allow issuing a license to a device whose only identity anchor is hostname (trivial to spoof).")
p.add_argument("--asset-key-envelope", default=None,
help="Path to an already-generated asset.key.enc. If provided, its "
"SHA-256 is baked into the license so the client can verify "
"license and asset key were issued together.")
p.add_argument("--allowed-assets", nargs="*", default=["models", "prompts"],
help="Asset package names this license permits (default: models prompts).")
_add_key_passphrase_args(p)
p = sub.add_parser("wrap-key-for-device", help="Wrap K_master for one customer device (needs their fingerprint file).")
p.add_argument("--fingerprint", required=True)
p.add_argument("--master-key", required=True)
p.add_argument("--output", required=True)
p.add_argument("--allow-legacy-wrap", action="store_true",
help="Allow v2 (HKDF, file-level) wrapping when NO TPM key is present "
"in the fingerprint. DANGEROUS — anyone with the client package "
"can derive the unwrap key. Only for VMs without vTPM.")
p.add_argument("--allow-unverified-tpm-key", action="store_true",
help="Accept a TPM public key whose attestation could not be "
"cryptographically verified. The wrapping will still use v3 "
"(RSA-OAEP), but the server cannot prove the key is truly "
"hardware-resident. Only for testing / development.")
args = parser.parse_args()
{
"gen-keys": gen_keys,
"gen-master-key": gen_master_key,
"build-encrypt-asset": build_encrypt_asset,
"issue-license": issue_license,
"wrap-key-for-device": wrap_key_for_device,
}[args.cmd](args)
if __name__ == "__main__":
main()
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# Compile and harden a PlantAI source tree into Cython extension modules.
set -euo pipefail
umask 027
SOURCE_ROOT="${1:?Usage: $0 <source-root> <output-dir> [python]}"
OUTPUT_DIR="${2:?Usage: $0 <source-root> <output-dir> [python]}"
PYTHON="${3:-python3}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VISIBILITY_TOOL="${ELF_VISIBILITY_TOOL:-$SCRIPT_DIR/tools/elf_visibility.py}"
ENTRY_SCRIPTS=(
"celery_app.py"
"tasks.py"
"check_cost.py"
"enable_persistent_memory.py"
"example_task_with_wechat.py"
"get_wechat_id.py"
"notify_integration.py"
"pipeline_backend_patch.py"
"session_storage.py"
"update_prompts.py"
"validate_js.py"
"wechat_bot.py"
"wechat_integration.py"
"wechat_service.py"
"wechat_task_bridge.py"
"test_langgraph_state.py"
"test_router.py"
)
CLI_ENTRY_SCRIPTS=(
"run_identify.py"
"run_phylogeny.py"
)
SOURCE_ROOT="$(realpath "$SOURCE_ROOT")"
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
[[ -d "$SOURCE_ROOT" ]] || { echo "PlantAI source directory missing: $SOURCE_ROOT" >&2; exit 2; }
[[ -x "$PYTHON" ]] || { echo "Python is not executable: $PYTHON" >&2; exit 2; }
[[ -f "$VISIBILITY_TOOL" ]] || { echo "ELF visibility tool missing: $VISIBILITY_TOOL" >&2; exit 2; }
command -v gcc >/dev/null
command -v strip >/dev/null
command -v rsync >/dev/null
case "$OUTPUT_DIR" in
"/"|"$(realpath -m "$HOME")"|"$SOURCE_ROOT")
echo "Unsafe output directory: $OUTPUT_DIR" >&2
exit 2
;;
esac
[[ ! -e "$OUTPUT_DIR" ]] || {
echo "Output already exists; refusing to overwrite: $OUTPUT_DIR" >&2
exit 2
}
mkdir -p "$OUTPUT_DIR"
WHITELIST_EXT=(
"*.py" "*.yaml" "*.yml" "*.json" "*.md" "*.html" "*.js" "*.css"
"*.R" "*.sh" "*.hmm" "*.txt" "*.pem" "*.tsv" "*.csv" "*.db"
"/apps/web/vue3/static/*.png"
)
BLACKLIST=(
"__pycache__/" ".git/" ".codegraph/" "*.pyc" ".bak" "*.bak*"
"_backup_*" "vue3copy*" "workspace/" "/data/" "input/" "cost_logs/"
"memory_storage/" "rag_kb_store/" "*logs/" "temp/" "test*" "项目记录/"
"secrets/" "*.key" "*private*.pem" "*secret*.pem"
)
RSYNC_RULES=()
for pattern in "${BLACKLIST[@]}"; do RSYNC_RULES+=(--exclude="$pattern"); done
for pattern in "${WHITELIST_EXT[@]}"; do RSYNC_RULES+=(--include="$pattern"); done
RSYNC_RULES+=(--include='*/' --exclude='*')
echo "[PlantAI 1/5] Copying allowlisted runtime files"
for directory in apps gfa_core config scripts; do
if [[ -d "$SOURCE_ROOT/$directory" ]]; then
rsync -a -m "${RSYNC_RULES[@]}" \
"$SOURCE_ROOT/$directory/" "$OUTPUT_DIR/$directory/"
fi
done
for relative in "${ENTRY_SCRIPTS[@]}"; do
if [[ -f "$SOURCE_ROOT/$relative" ]]; then
install -D -m 0750 "$SOURCE_ROOT/$relative" "$OUTPUT_DIR/$relative"
fi
done
for relative in "${CLI_ENTRY_SCRIPTS[@]}"; do
if [[ -f "$SOURCE_ROOT/apps/cli/$relative" ]]; then
install -D -m 0750 \
"$SOURCE_ROOT/apps/cli/$relative" "$OUTPUT_DIR/apps/cli/$relative"
fi
done
echo "[PlantAI 2/5] Discovering Python modules"
EXCLUDE_ARGS=()
for name in "${ENTRY_SCRIPTS[@]}"; do EXCLUDE_ARGS+=(-not -name "$name"); done
for name in "${CLI_ENTRY_SCRIPTS[@]}"; do EXCLUDE_ARGS+=(-not -name "$name"); done
EXCLUDE_ARGS+=(-not -name '__init__.py')
EXCLUDE_ARGS+=(-not -path "*/apps/web/vue3/main.py")
EXCLUDE_ARGS+=(-not -name "*[-]*.py")
PY_FILES=()
while IFS= read -r -d '' path; do PY_FILES+=("$path"); done \
< <(find "$OUTPUT_DIR" -type f -name '*.py' "${EXCLUDE_ARGS[@]}" -print0)
TOTAL="${#PY_FILES[@]}"
(( TOTAL > 0 )) || { echo "No PlantAI modules selected for compilation" >&2; exit 3; }
INCLUDE_PY="$("$PYTHON" -c 'import sysconfig; print(sysconfig.get_config_var("INCLUDEPY") or "")')"
EXT_SUFFIX="$("$PYTHON" -c 'import sysconfig; print(sysconfig.get_config_var("EXT_SUFFIX") or ".so")')"
[[ -n "$INCLUDE_PY" ]] || { echo "Python INCLUDEPY is empty" >&2; exit 3; }
echo "[PlantAI 3/5] Cython + GCC hidden visibility + strip ($TOTAL modules)"
COUNT=0
for py_file in "${PY_FILES[@]}"; do
COUNT=$((COUNT + 1))
directory="$(dirname "$py_file")"
module="$(basename "$py_file" .py)"
c_file="$directory/$module.c"
so_file="$directory/$module$EXT_SUFFIX"
echo " [$COUNT/$TOTAL] ${py_file#"$OUTPUT_DIR"/}"
"$PYTHON" -m cython -3 \
--directive always_allow_keywords=true,annotation_typing=False \
--no-docstrings \
"$py_file" -o "$c_file"
gcc -shared -pthread -fPIC -O2 -fvisibility=hidden \
-I"$INCLUDE_PY" \
-o "$so_file" "$c_file"
strip --strip-unneeded "$so_file"
"$PYTHON" -B "$VISIBILITY_TOOL" \
--hide-non-pyinit --check "$so_file" >/dev/null
rm -f -- "$c_file" "$py_file"
done
echo "[PlantAI 3.5/5] Generating .py stubs for compiled modules"
"$PYTHON" -B "$SCRIPT_DIR/tools/generate_stubs.py" "$OUTPUT_DIR"
echo "[PlantAI 4/5] Restoring package markers"
while IFS= read -r -d '' directory; do
if find "$directory" -maxdepth 1 -type f \( -name '*.so' -o -name '*.py' \) -print -quit | grep -q .; then
[[ -f "$directory/__init__.py" ]] || touch "$directory/__init__.py"
fi
done < <(find "$OUTPUT_DIR" -type d -print0)
echo "[PlantAI 5/5] Final checks"
SO_COUNT="$(find "$OUTPUT_DIR" -type f -name '*.so' | wc -l)"
[[ "$SO_COUNT" -eq "$TOTAL" ]] || {
echo "PlantAI count mismatch: selected=$TOTAL produced=$SO_COUNT" >&2
exit 4
}
if find "$OUTPUT_DIR" -type f -name '*.c' -print -quit | grep -q .; then
echo "Generated C source remains in output" >&2
exit 4
fi
echo "PlantAI compile+harden complete: modules=$SO_COUNT output=$OUTPUT_DIR"
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Compile the QA V1.1.73 runtime surface into stripped Cython extensions.
set -euo pipefail
umask 027
SOURCE_ROOT="${1:?Usage: $0 <source-root> <output-dir> [python]}"
OUTPUT_DIR="${2:?Usage: $0 <source-root> <output-dir> [python]}"
PYTHON="${3:-python3}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VISIBILITY_TOOL="${ELF_VISIBILITY_TOOL:-$SCRIPT_DIR/tools/elf_visibility.py}"
ENTRY_SCRIPT="scripts/qa_v1_1_73_server.py"
SOURCE_ROOT="$(realpath "$SOURCE_ROOT")"
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
[[ -d "$SOURCE_ROOT" ]] || { echo "QA source directory missing: $SOURCE_ROOT" >&2; exit 2; }
[[ -x "$PYTHON" ]] || { echo "Python is not executable: $PYTHON" >&2; exit 2; }
[[ -f "$VISIBILITY_TOOL" ]] || { echo "ELF visibility tool missing: $VISIBILITY_TOOL" >&2; exit 2; }
for command_name in gcc strip rsync; do command -v "$command_name" >/dev/null; done
case "$OUTPUT_DIR" in
"/"|"$(realpath -m "$HOME")"|"$SOURCE_ROOT")
echo "Unsafe output directory: $OUTPUT_DIR" >&2
exit 2
;;
esac
[[ ! -e "$OUTPUT_DIR" ]] || {
echo "Output already exists; refusing to overwrite: $OUTPUT_DIR" >&2
exit 2
}
mkdir -p \
"$OUTPUT_DIR/scripts" \
"$OUTPUT_DIR/src/rag/qa_v1_1_73" \
"$OUTPUT_DIR/src/rag/retrieval" \
"$OUTPUT_DIR/src/rag/dual_kb_taxonomy_rag" \
"$OUTPUT_DIR/configs/rag"
echo "[QA 1/6] Copying the audited runtime surface"
install -m 0750 "$SOURCE_ROOT/$ENTRY_SCRIPT" "$OUTPUT_DIR/$ENTRY_SCRIPT"
rsync -a --include='*.py' --exclude='*' \
"$SOURCE_ROOT/src/rag/qa_v1_1_73/" "$OUTPUT_DIR/src/rag/qa_v1_1_73/"
rsync -a --include='*.py' --exclude='*' \
"$SOURCE_ROOT/src/rag/retrieval/" "$OUTPUT_DIR/src/rag/retrieval/"
install -m 0640 \
"$SOURCE_ROOT/src/rag/dual_kb_taxonomy_rag/entity_alias.py" \
"$OUTPUT_DIR/src/rag/dual_kb_taxonomy_rag/entity_alias.py"
for relative in \
configs/rag/entity_alias.yaml \
configs/rag/relation_taxonomy.yaml \
pyproject.toml \
requirements.txt \
requirements-rag.txt
do
if [[ -f "$SOURCE_ROOT/$relative" ]]; then
install -D -m 0640 "$SOURCE_ROOT/$relative" "$OUTPUT_DIR/$relative"
fi
done
# Namespace/package markers are intentionally left as minimal Python files.
touch \
"$OUTPUT_DIR/scripts/__init__.py" \
"$OUTPUT_DIR/src/__init__.py" \
"$OUTPUT_DIR/src/rag/__init__.py" \
"$OUTPUT_DIR/src/rag/dual_kb_taxonomy_rag/__init__.py"
echo "[QA 2/6] Discovering Python modules"
PY_FILES=()
while IFS= read -r -d '' path; do PY_FILES+=("$path"); done \
< <(find "$OUTPUT_DIR" -type f -name '*.py' \
-not -path "*/$ENTRY_SCRIPT" \
-not -name '__init__.py' -print0)
TOTAL="${#PY_FILES[@]}"
(( TOTAL > 0 )) || { echo "No QA modules selected for compilation" >&2; exit 3; }
INCLUDE_PY="$("$PYTHON" -c 'import sysconfig; print(sysconfig.get_config_var("INCLUDEPY") or "")')"
EXT_SUFFIX="$("$PYTHON" -c 'import sysconfig; print(sysconfig.get_config_var("EXT_SUFFIX") or ".so")')"
[[ -n "$INCLUDE_PY" ]] || { echo "Python INCLUDEPY is empty" >&2; exit 3; }
echo "[QA 3/6] Cython preflight ($TOTAL modules)"
COUNT=0
CYTHON_FAILURES=0
for py_file in "${PY_FILES[@]}"; do
COUNT=$((COUNT + 1))
directory="$(dirname "$py_file")"
module="$(basename "$py_file" .py)"
c_file="$directory/$module.c"
echo " [$COUNT/$TOTAL] ${py_file#"$OUTPUT_DIR"/}"
if ! "$PYTHON" -m cython -3 \
--directive always_allow_keywords=true,annotation_typing=False \
--no-docstrings "$py_file" -o "$c_file"; then
CYTHON_FAILURES=$((CYTHON_FAILURES + 1))
fi
done
(( CYTHON_FAILURES == 0 )) || {
echo "QA Cython preflight failed: modules=$CYTHON_FAILURES" >&2
exit 3
}
echo "[QA 4/6] GCC hidden visibility + strip ($TOTAL modules)"
COUNT=0
for py_file in "${PY_FILES[@]}"; do
COUNT=$((COUNT + 1))
directory="$(dirname "$py_file")"
module="$(basename "$py_file" .py)"
c_file="$directory/$module.c"
so_file="$directory/$module$EXT_SUFFIX"
echo " [$COUNT/$TOTAL] ${py_file#"$OUTPUT_DIR"/}"
gcc -shared -pthread -fPIC -O2 -fvisibility=hidden \
-I"$INCLUDE_PY" -o "$so_file" "$c_file"
strip --strip-unneeded "$so_file"
"$PYTHON" -B "$VISIBILITY_TOOL" \
--hide-non-pyinit --check "$so_file" >/dev/null
rm -f -- "$c_file" "$py_file"
done
echo "[QA 5/6] Restoring package markers"
while IFS= read -r -d '' directory; do
if find "$directory" -maxdepth 1 -type f \( -name '*.so' -o -name '*.py' \) -print -quit | grep -q .; then
[[ -f "$directory/__init__.py" ]] || touch "$directory/__init__.py"
fi
done < <(find "$OUTPUT_DIR" -type d -print0)
echo "[QA 6/6] Final checks"
SO_COUNT="$(find "$OUTPUT_DIR" -type f -name '*.so' | wc -l)"
[[ "$SO_COUNT" -eq "$TOTAL" ]] || {
echo "QA count mismatch: selected=$TOTAL produced=$SO_COUNT" >&2
exit 4
}
if find "$OUTPUT_DIR" -type f -name '*.c' -print -quit | grep -q .; then
echo "Generated C source remains in output" >&2
exit 4
fi
echo "QA compile+harden complete: modules=$SO_COUNT output=$OUTPUT_DIR"
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
# Compile and harden a RAG source tree into Cython extension modules.
set -euo pipefail
umask 027
SOURCE_ROOT="${1:?Usage: $0 <source-root> <output-dir> [python]}"
OUTPUT_DIR="${2:?Usage: $0 <source-root> <output-dir> [python]}"
PYTHON="${3:-python3}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VISIBILITY_TOOL="${ELF_VISIBILITY_TOOL:-$SCRIPT_DIR/tools/elf_visibility.py}"
ENTRY_SCRIPTS=(
"api/main.py"
"api/app.py"
)
API_KEEP_PY=(
)
SOURCE_ROOT="$(realpath "$SOURCE_ROOT")"
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
[[ -d "$SOURCE_ROOT" ]] || { echo "RAG source directory missing: $SOURCE_ROOT" >&2; exit 2; }
[[ -x "$PYTHON" ]] || { echo "Python is not executable: $PYTHON" >&2; exit 2; }
[[ -f "$VISIBILITY_TOOL" ]] || { echo "ELF visibility tool missing: $VISIBILITY_TOOL" >&2; exit 2; }
command -v gcc >/dev/null
command -v strip >/dev/null
command -v rsync >/dev/null
case "$OUTPUT_DIR" in
"/"|"$(realpath -m "$HOME")"|"$SOURCE_ROOT")
echo "Unsafe output directory: $OUTPUT_DIR" >&2
exit 2
;;
esac
[[ ! -e "$OUTPUT_DIR" ]] || {
echo "Output already exists; refusing to overwrite: $OUTPUT_DIR" >&2
exit 2
}
mkdir -p "$OUTPUT_DIR"
WHITELIST_EXT=(
"*.py" "*.yaml" "*.yml" "*.json" "*.md" "*.html" "*.js" "*.css"
"*.sh" "*.txt" "*.pem" "*.csv" "*.tsv" "*.db" "*.sql" "*.toml" "*.lock"
)
BLACKLIST=(
"__pycache__/" ".git/" ".codegraph/" "*.pyc" ".bak" "*.bak*"
"node_modules/" "workspace/" "/data/" "input/" "output/" "outputs/"
"v15_output/" "neo4j/" "service_data/" "logs/" "temp/" "test*"
"*_test.py" "docs/" "evaluation/" "v14_learning/" "rag_kb_store/"
"protected_release/" "runtime_scripts/" "enviroment/" "frontend/"
"secrets/" "*.key" "*private*.pem" "*secret*.pem"
"rag/extractors/universal_gene_family_extractor.py"
"rag/hybrid_retriever_backup.py"
"rag/temporal/paper_metadata_extractor.py"
)
RSYNC_RULES=()
for pattern in "${BLACKLIST[@]}"; do RSYNC_RULES+=(--exclude="$pattern"); done
for pattern in "${WHITELIST_EXT[@]}"; do RSYNC_RULES+=(--include="$pattern"); done
RSYNC_RULES+=(--include='*/' --exclude='*')
echo "[RAG 1/5] Copying allowlisted runtime files"
for directory in api src config configs scripts tools prompts; do
if [[ -d "$SOURCE_ROOT/$directory" ]]; then
rsync -a -m "${RSYNC_RULES[@]}" \
"$SOURCE_ROOT/$directory/" "$OUTPUT_DIR/$directory/"
fi
done
for relative in \
pyproject.toml poetry.lock requirements.txt requirements-rag.txt \
.env.example rag.env.example
do
if [[ -f "$SOURCE_ROOT/$relative" ]]; then
install -D -m 0640 "$SOURCE_ROOT/$relative" "$OUTPUT_DIR/$relative"
fi
done
for relative in "${ENTRY_SCRIPTS[@]}"; do
if [[ -f "$SOURCE_ROOT/$relative" ]]; then
install -D -m 0750 "$SOURCE_ROOT/$relative" "$OUTPUT_DIR/$relative"
fi
done
echo "[RAG 2/5] Discovering Python modules"
EXCLUDE_ARGS=()
for relative in "${ENTRY_SCRIPTS[@]}"; do EXCLUDE_ARGS+=(-not -path "*/$relative"); done
for relative in "${API_KEEP_PY[@]}"; do EXCLUDE_ARGS+=(-not -path "*/$relative"); done
EXCLUDE_ARGS+=(-not -name '__init__.py')
PY_FILES=()
while IFS= read -r -d '' path; do PY_FILES+=("$path"); done \
< <(find "$OUTPUT_DIR" -type f -name '*.py' "${EXCLUDE_ARGS[@]}" -print0)
TOTAL="${#PY_FILES[@]}"
(( TOTAL > 0 )) || { echo "No RAG modules selected for compilation" >&2; exit 3; }
INCLUDE_PY="$("$PYTHON" -c 'import sysconfig; print(sysconfig.get_config_var("INCLUDEPY") or "")')"
EXT_SUFFIX="$("$PYTHON" -c 'import sysconfig; print(sysconfig.get_config_var("EXT_SUFFIX") or ".so")')"
[[ -n "$INCLUDE_PY" ]] || { echo "Python INCLUDEPY is empty" >&2; exit 3; }
echo "[RAG 3/6] Cython preflight ($TOTAL modules)"
COUNT=0
CYTHON_FAILURES=0
for py_file in "${PY_FILES[@]}"; do
COUNT=$((COUNT + 1))
directory="$(dirname "$py_file")"
module="$(basename "$py_file" .py)"
c_file="$directory/$module.c"
echo " [$COUNT/$TOTAL] ${py_file#"$OUTPUT_DIR"/}"
if ! "$PYTHON" -m cython -3 \
--directive always_allow_keywords=true,annotation_typing=False \
--no-docstrings \
"$py_file" -o "$c_file"; then
CYTHON_FAILURES=$((CYTHON_FAILURES + 1))
fi
done
(( CYTHON_FAILURES == 0 )) || {
echo "RAG Cython preflight failed: modules=$CYTHON_FAILURES" >&2
exit 3
}
echo "[RAG 4/6] GCC hidden visibility + strip ($TOTAL modules)"
COUNT=0
for py_file in "${PY_FILES[@]}"; do
COUNT=$((COUNT + 1))
directory="$(dirname "$py_file")"
module="$(basename "$py_file" .py)"
c_file="$directory/$module.c"
so_file="$directory/$module$EXT_SUFFIX"
echo " [$COUNT/$TOTAL] ${py_file#"$OUTPUT_DIR"/}"
[[ -f "$c_file" ]] || {
echo "Missing preflight C source: $c_file" >&2
exit 3
}
gcc -shared -pthread -fPIC -O2 -fvisibility=hidden \
-I"$INCLUDE_PY" \
-o "$so_file" "$c_file"
strip --strip-unneeded "$so_file"
"$PYTHON" -B "$VISIBILITY_TOOL" \
--hide-non-pyinit --check "$so_file" >/dev/null
rm -f -- "$c_file" "$py_file"
done
echo "[RAG 5/6] Restoring package markers"
while IFS= read -r -d '' directory; do
if find "$directory" -maxdepth 1 -type f \( -name '*.so' -o -name '*.py' \) -print -quit | grep -q .; then
[[ -f "$directory/__init__.py" ]] || touch "$directory/__init__.py"
fi
done < <(find "$OUTPUT_DIR" -type d -print0)
echo "[RAG 6/6] Final checks"
SO_COUNT="$(find "$OUTPUT_DIR" -type f -name '*.so' | wc -l)"
[[ "$SO_COUNT" -eq "$TOTAL" ]] || {
echo "RAG count mismatch: selected=$TOTAL produced=$SO_COUNT" >&2
exit 4
}
if find "$OUTPUT_DIR" -type f -name '*.c' -print -quit | grep -q .; then
echo "Generated C source remains in output" >&2
exit 4
fi
echo "RAG compile+harden complete: modules=$SO_COUNT output=$OUTPUT_DIR"
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# Run this on your server, once per customer device, AFTER that customer has
# sent you their device_fingerprint.json (produced by the client package's
# install_plantai.sh fingerprint step).
#
# Usage:
# ./issue_client.sh \
# --fingerprint /path/to/device_fingerprint.json \
# --release-version 1.2.0 \
# --customer-id CUS-0001 \
# --customer-name "Some Customer Co" \
# --serial-no PlantAI-0001
#
# Produces: out/provision/<device_id>/license.json and asset.key.enc,
# packaged into out/provision/plantai-provision-<device_id>.tar.gz
# which is the ONLY thing you send back to that customer.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
for cfg in "$SCRIPT_DIR/server.env"; do
[ -f "$cfg" ] && . "$cfg"
done
SECRETS_DIR="${SECRETS_DIR:-$SCRIPT_DIR/secrets}"
ADMIN="$SCRIPT_DIR/bin/plantai_admin.py"
PYTHON="${PLANTAI_PYTHON:-python3}"
KEY_PASSPHRASE_ARGS=()
if [ -n "${SUPPLIER_KEY_PASSPHRASE_ENV:-}" ] && [ -n "${SUPPLIER_KEY_PASSPHRASE_FILE:-}" ]; then
echo "Set only one of SUPPLIER_KEY_PASSPHRASE_ENV or SUPPLIER_KEY_PASSPHRASE_FILE" >&2
exit 1
elif [ -n "${SUPPLIER_KEY_PASSPHRASE_ENV:-}" ]; then
KEY_PASSPHRASE_ARGS=(--key-passphrase-env "$SUPPLIER_KEY_PASSPHRASE_ENV")
elif [ -n "${SUPPLIER_KEY_PASSPHRASE_FILE:-}" ]; then
KEY_PASSPHRASE_ARGS=(--key-passphrase-file "$SUPPLIER_KEY_PASSPHRASE_FILE")
fi
FINGERPRINT=""
RELEASE_VERSION=""
CUSTOMER_ID=""
CUSTOMER_NAME=""
SERIAL_NO=""
EDITION="${EDITION:-standard}"
MIN_MATCH_SCORE="${MIN_MATCH_SCORE:-60}"
ALLOW_WARNING_SCORE="${ALLOW_WARNING_SCORE:-85}"
ALLOWED_ASSETS="${ALLOWED_ASSETS:-runtime-assets}"
ALLOW_UNVERIFIED_TPM_KEY=""
while [ $# -gt 0 ]; do
case "$1" in
--fingerprint) FINGERPRINT="$2"; shift 2 ;;
--release-version) RELEASE_VERSION="$2"; shift 2 ;;
--customer-id) CUSTOMER_ID="$2"; shift 2 ;;
--customer-name) CUSTOMER_NAME="$2"; shift 2 ;;
--serial-no) SERIAL_NO="$2"; shift 2 ;;
--edition) EDITION="$2"; shift 2 ;;
--min-match-score) MIN_MATCH_SCORE="$2"; shift 2 ;;
--allow-warning-score) ALLOW_WARNING_SCORE="$2"; shift 2 ;;
--allowed-assets) ALLOWED_ASSETS="$2"; shift 2 ;;
--allow-unverified-tpm-key) ALLOW_UNVERIFIED_TPM_KEY="--allow-unverified-tpm-key"; shift ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
for req in FINGERPRINT RELEASE_VERSION CUSTOMER_ID CUSTOMER_NAME SERIAL_NO; do
if [ -z "${!req}" ]; then
echo "Missing required --${req,,}" >&2
exit 1
fi
done
MASTER_KEY="$SECRETS_DIR/master_keys/$RELEASE_VERSION.key"
if [ ! -f "$MASTER_KEY" ]; then
echo "No master key found for release $RELEASE_VERSION at $MASTER_KEY -- run build_release.sh first." >&2
exit 1
fi
DEVICE_ID="$("$PYTHON" -c "import json,sys; print(json.load(open(sys.argv[1]))['device_id'])" "$FINGERPRINT")"
OUT_DIR="$SCRIPT_DIR/out/provision/$DEVICE_ID"
mkdir -p "$OUT_DIR"
echo "[1/3] Log device registration"
mkdir -p "$SCRIPT_DIR/out/registry"
"$PYTHON" - "$FINGERPRINT" "$CUSTOMER_ID" "$DEVICE_ID" "$RELEASE_VERSION" >> "$SCRIPT_DIR/out/registry/devices.jsonl" <<'PYEOF'
import json, sys, datetime
fp_path, customer_id, device_id, release_version = sys.argv[1:5]
fp = json.load(open(fp_path))
print(json.dumps({
"at": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
"device_id": device_id,
"customer_id": customer_id,
"release_version": release_version,
"core_id_anchor": fp.get("core_id_anchor"),
"core_id_hash": fp.get("core_id_hash"),
}, ensure_ascii=False))
PYEOF
# IMPORTANT: wrap-key-for-device runs FIRST, then issue-license with
# --asset-key-envelope so the license contains a cryptographic binding
# (asset_key_sha256) to the exact asset.key.enc. The client will
# verify this hash at runtime to detect tampering or mismatched files.
echo "[2/3] Wrap this release's K_master for this device"
"$PYTHON" "$ADMIN" wrap-key-for-device \
--fingerprint "$FINGERPRINT" \
--master-key "$MASTER_KEY" \
--output "$OUT_DIR/asset.key.enc" \
$ALLOW_UNVERIFIED_TPM_KEY
echo "[3/3] Issue signed license (bound to asset.key.enc hash)"
"$PYTHON" "$ADMIN" issue-license \
--fingerprint "$FINGERPRINT" \
--private-key "$SECRETS_DIR/supplier_private_key.pem" \
--output "$OUT_DIR/license.json" \
--customer-id "$CUSTOMER_ID" \
--customer-name "$CUSTOMER_NAME" \
--serial-no "$SERIAL_NO" \
--edition "$EDITION" \
--release-version "$RELEASE_VERSION" \
--min-match-score "$MIN_MATCH_SCORE" \
--allow-warning-score "$ALLOW_WARNING_SCORE" \
--allowed-assets $ALLOWED_ASSETS \
--asset-key-envelope "$OUT_DIR/asset.key.enc" \
"${KEY_PASSPHRASE_ARGS[@]}"
tar -C "$OUT_DIR" -czf "$SCRIPT_DIR/out/provision/plantai-provision-$DEVICE_ID.tar.gz" license.json asset.key.enc
echo "Done."
echo "Send this file back to the customer: $SCRIPT_DIR/out/provision/plantai-provision-$DEVICE_ID.tar.gz"
echo "It contains only license.json + asset.key.enc -- no keys, no master key."
@@ -0,0 +1,260 @@
#!/usr/bin/env bash
# 阶段 A:从 server.env 指定的源码生成全新的、已硬化并验证的 .new 目录。
set -euo pipefail
umask 027
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${SERVER_ENV_FILE:-$SCRIPT_DIR/server.env}"
[[ -f "$ENV_FILE" ]] || { echo "缺少配置文件: $ENV_FILE" >&2; exit 1; }
# shellcheck source=/dev/null
. "$ENV_FILE"
: "${PLANTAI_SOURCE:?请在 server.env 中设置 PLANTAI_SOURCE}"
: "${RAG_SOURCE:?请在 server.env 中设置 RAG_SOURCE}"
: "${QA_SOURCE:?请在 server.env 中设置 QA_SOURCE}"
TEST_ROOT="${TEST_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
SERVER_ROOT="${SERVER_ROOT:-$TEST_ROOT/server}"
PYTHON="${PLANTAI_PYTHON:-python3}"
QA_PYTHON="${QA_PYTHON:-python3}"
PLANTAI_OUTPUT="${PLANTAI_OUTPUT:-$SERVER_ROOT/PlantAI_compiled.new}"
RAG_OUTPUT="${RAG_OUTPUT:-$SERVER_ROOT/RAG.new}"
QA_OUTPUT="${QA_OUTPUT:-$SERVER_ROOT/RAG_QA.new}"
VERSION="${VERSION:-unversioned}"
AUDIT_DIR="${PHASE_A_AUDIT_DIR:-$SCRIPT_DIR/out/build-audit/$VERSION}"
AUDITOR="$SCRIPT_DIR/tools/audit_compiled_tree.py"
SMOKE="$SCRIPT_DIR/tools/smoke_dual_build.py"
for command_name in realpath find gcc strip rsync; do
command -v "$command_name" >/dev/null || {
echo "缺少阶段 A 工具: $command_name" >&2
exit 1
}
done
for path in "$PLANTAI_SOURCE" "$RAG_SOURCE" "$QA_SOURCE"; do
[[ -d "$path" ]] || { echo "源码目录不存在: $path" >&2; exit 1; }
done
[[ -x "$PYTHON" ]] || { echo "Python 不可执行: $PYTHON" >&2; exit 1; }
[[ -x "$QA_PYTHON" ]] || { echo "QA Python 不可执行: $QA_PYTHON" >&2; exit 1; }
"$PYTHON" -c 'import Cython' >/dev/null || {
echo "当前 Python 环境未安装 Cython: $PYTHON" >&2
exit 1
}
"$QA_PYTHON" -c 'import Cython' >/dev/null || {
echo "QA Python 环境未安装 Cython: $QA_PYTHON" >&2
exit 1
}
for path in "$AUDITOR" "$SMOKE" "$SCRIPT_DIR/tools/elf_visibility.py" "$SCRIPT_DIR/compile_qa.sh"; do
[[ -f "$path" ]] || { echo "缺少阶段 A 脚本: $path" >&2; exit 1; }
done
SERVER_ROOT="$(realpath "$SERVER_ROOT")"
PLANTAI_OUTPUT="$(realpath -m "$PLANTAI_OUTPUT")"
RAG_OUTPUT="$(realpath -m "$RAG_OUTPUT")"
QA_OUTPUT="$(realpath -m "$QA_OUTPUT")"
for output in "$PLANTAI_OUTPUT" "$RAG_OUTPUT" "$QA_OUTPUT"; do
case "$output" in
"$SERVER_ROOT"/*) ;;
*) echo "阶段 A 输出必须位于 SERVER_ROOT 下: $output" >&2; exit 1 ;;
esac
done
[[ "$PLANTAI_OUTPUT" != "$RAG_OUTPUT" && "$PLANTAI_OUTPUT" != "$QA_OUTPUT" && "$RAG_OUTPUT" != "$QA_OUTPUT" ]] || {
echo "PlantAI、RAG 与 QA 输出目录不能相同" >&2
exit 1
}
PLANTAI_STAGE="$SERVER_ROOT/.PlantAI_compiled.new.staging.$$"
RAG_STAGE="$SERVER_ROOT/.RAG.new.staging.$$"
QA_STAGE="$SERVER_ROOT/.RAG_QA.new.staging.$$"
for stage in "$PLANTAI_STAGE" "$RAG_STAGE" "$QA_STAGE"; do
[[ ! -e "$stage" ]] || { echo "临时目录已存在: $stage" >&2; exit 1; }
done
cleanup() {
for stage in "$PLANTAI_STAGE" "$RAG_STAGE" "$QA_STAGE"; do
case "$stage" in
"$SERVER_ROOT"/.*.staging."$$") rm -rf -- "$stage" ;;
esac
done
}
trap cleanup EXIT
mkdir -p "$AUDIT_DIR"
sync_plain_asset_tree() {
local source_root="$1"
local output_root="$2"
local asset_name="$3"
local project_name="$4"
local source_dir="$source_root/$asset_name"
local output_dir="$output_root/$asset_name"
if [[ ! -d "$source_dir" ]]; then
echo " $project_name 无 $asset_name 目录,跳过"
return
fi
if find "$source_dir" -type f -name '*.py' -print -quit | grep -q .; then
echo "$project_name/$asset_name 含 Python 源码,拒绝作为明文运行资产复制: $source_dir" >&2
exit 1
fi
mkdir -p "$output_dir"
rsync -a --delete "$source_dir/" "$output_dir/"
echo " 已同步 $project_name/$asset_name: $source_dir -> $output_dir"
}
sync_plantai_runtime_assets() {
local output_root="$1"
local pfam_source="${BBA_PFAM_DIR:-$PLANTAI_SOURCE/data/hmm}"
local pfam_output="$output_root/data/hmm"
sync_plain_asset_tree "$PLANTAI_SOURCE" "$output_root" models PlantAI
sync_plain_asset_tree "$PLANTAI_SOURCE" "$output_root" prompts PlantAI
# 内置 Pfam 映射表(系统配置,非用户数据)
if [[ -d "$PLANTAI_SOURCE/data/rag_db" ]]; then
mkdir -p "$output_root/data/rag_db"
rsync -a "$PLANTAI_SOURCE/data/rag_db/" "$output_root/data/rag_db/"
echo " 已同步 data/rag_db"
fi
[[ -f "$pfam_source/Pfam-A.hmm" ]] || {
echo "缺少 PlantAI Pfam-A.hmm: $pfam_source/Pfam-A.hmm" >&2
exit 1
}
mkdir -p "$pfam_output"
rsync -a --delete "$pfam_source/" "$pfam_output/"
echo " 已同步 PlantAI Pfam-A: $pfam_source -> $pfam_output"
}
sync_rag_runtime_assets() {
# RAG prompts 已由 compile_rag.sh 复制;其中的 Python prompt 会编译为 .so,
# 这里不能再次从源码覆盖,否则会把明文 .py 放回硬化产物。
sync_plain_asset_tree "$RAG_SOURCE" "$1" models RAG
}
if [[ -e "$PLANTAI_OUTPUT" ]]; then
[[ -d "$PLANTAI_OUTPUT" ]] || {
echo "PlantAI 产物路径已存在但不是目录: $PLANTAI_OUTPUT" >&2
exit 1
}
echo "[A/6] 验证并复用已完成的 PlantAI 产物"
"$PYTHON" -B "$AUDITOR" \
--tree "$PLANTAI_OUTPUT" --project PlantAI --require-marker \
| tee "$AUDIT_DIR/plantai-before-assets-elf.json"
sync_plantai_runtime_assets "$PLANTAI_OUTPUT"
"$PYTHON" -B "$AUDITOR" \
--tree "$PLANTAI_OUTPUT" --project PlantAI --write-marker \
| tee "$AUDIT_DIR/plantai-reuse-elf.json"
"$PYTHON" -B "$SMOKE" \
--plantai "$PLANTAI_OUTPUT" \
--python "$PYTHON" \
--output "$AUDIT_DIR/plantai-smoke.json"
"$PYTHON" -B "$AUDITOR" \
--tree "$PLANTAI_OUTPUT" --project PlantAI --require-marker >/dev/null
else
echo "[A/6] 编译并硬化 PlantAI"
bash "$SCRIPT_DIR/compile_plantai.sh" "$PLANTAI_SOURCE" "$PLANTAI_STAGE" "$PYTHON"
sync_plantai_runtime_assets "$PLANTAI_STAGE"
echo "[A/6] 审计并验证 PlantAI"
"$PYTHON" -B "$AUDITOR" \
--tree "$PLANTAI_STAGE" --project PlantAI --write-marker \
| tee "$AUDIT_DIR/plantai-elf.json"
"$PYTHON" -B "$SMOKE" \
--plantai "$PLANTAI_STAGE" \
--python "$PYTHON" \
--output "$AUDIT_DIR/plantai-smoke.json"
"$PYTHON" -B "$AUDITOR" \
--tree "$PLANTAI_STAGE" --project PlantAI --require-marker >/dev/null
echo "[A/6] 原子发布 PlantAI;后续 RAG 失败时可直接复用"
mv -- "$PLANTAI_STAGE" "$PLANTAI_OUTPUT"
fi
if [[ -e "$RAG_OUTPUT" ]]; then
[[ -d "$RAG_OUTPUT" ]] || {
echo "RAG 产物路径已存在但不是目录: $RAG_OUTPUT" >&2
exit 1
}
echo "[A/6] 验证并复用已完成的 RAG 产物"
"$PYTHON" -B "$AUDITOR" \
--tree "$RAG_OUTPUT" --project RAG --require-marker \
| tee "$AUDIT_DIR/rag-before-assets-elf.json"
sync_rag_runtime_assets "$RAG_OUTPUT"
"$PYTHON" -B "$AUDITOR" \
--tree "$RAG_OUTPUT" --project RAG --write-marker \
| tee "$AUDIT_DIR/rag-reuse-elf.json"
"$PYTHON" -B "$SMOKE" \
--rag "$RAG_OUTPUT" \
--python "$PYTHON" \
--output "$AUDIT_DIR/rag-smoke.json"
"$PYTHON" -B "$AUDITOR" \
--tree "$RAG_OUTPUT" --project RAG --require-marker >/dev/null
else
echo "[A/6] 编译并硬化 RAG"
bash "$SCRIPT_DIR/compile_rag.sh" "$RAG_SOURCE" "$RAG_STAGE" "$PYTHON"
sync_rag_runtime_assets "$RAG_STAGE"
echo "[A/6] 审计并验证 RAG"
"$PYTHON" -B "$AUDITOR" \
--tree "$RAG_STAGE" --project RAG --write-marker \
| tee "$AUDIT_DIR/rag-elf.json"
"$PYTHON" -B "$SMOKE" \
--rag "$RAG_STAGE" \
--python "$PYTHON" \
--output "$AUDIT_DIR/rag-smoke.json"
"$PYTHON" -B "$AUDITOR" \
--tree "$RAG_STAGE" --project RAG --require-marker >/dev/null
echo "[A/6] 原子发布 RAG"
mv -- "$RAG_STAGE" "$RAG_OUTPUT"
fi
if [[ -e "$QA_OUTPUT" ]]; then
[[ -d "$QA_OUTPUT" ]] || {
echo "QA 产物路径已存在但不是目录: $QA_OUTPUT" >&2
exit 1
}
echo "[A/6] 验证并复用已完成的 QA 产物"
"$QA_PYTHON" -B "$AUDITOR" \
--tree "$QA_OUTPUT" --project QA --require-marker \
| tee "$AUDIT_DIR/qa-reuse-elf.json"
"$QA_PYTHON" -B "$SMOKE" \
--qa "$QA_OUTPUT" \
--qa-python "$QA_PYTHON" \
--output "$AUDIT_DIR/qa-smoke.json"
else
echo "[A/6] 编译并硬化 QA"
bash "$SCRIPT_DIR/compile_qa.sh" "$QA_SOURCE" "$QA_STAGE" "$QA_PYTHON"
echo "[A/6] 审计并验证 QA"
"$QA_PYTHON" -B "$AUDITOR" \
--tree "$QA_STAGE" --project QA --write-marker \
| tee "$AUDIT_DIR/qa-elf.json"
"$QA_PYTHON" -B "$SMOKE" \
--qa "$QA_STAGE" \
--qa-python "$QA_PYTHON" \
--output "$AUDIT_DIR/qa-smoke.json"
"$QA_PYTHON" -B "$AUDITOR" \
--tree "$QA_STAGE" --project QA --require-marker >/dev/null
echo "[A/6] 原子发布 QA"
mv -- "$QA_STAGE" "$QA_OUTPUT"
fi
echo "[A/6] 运行最终三项目代表性导入与行为冒烟测试"
"$PYTHON" -B "$SMOKE" \
--plantai "$PLANTAI_OUTPUT" \
--rag "$RAG_OUTPUT" \
--qa "$QA_OUTPUT" \
--python "$PYTHON" \
--qa-python "$QA_PYTHON" \
--output "$AUDIT_DIR/smoke.json"
plantai_so="$(find "$PLANTAI_OUTPUT" -type f -name '*.so' | wc -l)"
rag_so="$(find "$RAG_OUTPUT" -type f -name '*.so' | wc -l)"
qa_so="$(find "$QA_OUTPUT" -type f -name '*.so' | wc -l)"
echo "阶段 A 完成:PlantAI .so=$plantai_so,RAG .so=$rag_so,QA .so=$qa_so"
echo "产物:$PLANTAI_OUTPUT"
echo "产物:$RAG_OUTPUT"
echo "产物:$QA_OUTPUT"
echo "审计:$AUDIT_DIR"
echo "未替换现有运行目录;阶段 C 将只接受带有效构建标记的产物。"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# 阶段 B:初始化供应商签名密钥与当前版本 K_master;已存在时绝不覆盖。
set -euo pipefail
umask 077
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[ -f "$SCRIPT_DIR/server.env" ] && . "$SCRIPT_DIR/server.env"
VERSION="${VERSION:-1.0.0}"
PYTHON="${PLANTAI_PYTHON:-python3}"
SECRETS_DIR="${SECRETS_DIR:-$SCRIPT_DIR/secrets}"
PASS_FILE="${SUPPLIER_KEY_PASSPHRASE_FILE:-$SECRETS_DIR/private_key.pass}"
ADMIN="$SCRIPT_DIR/bin/plantai_admin.py"
[[ "$VERSION" =~ ^[A-Za-z0-9._-]+$ ]] && [[ "$VERSION" != *..* ]] || { echo "非法 VERSION: $VERSION" >&2; exit 1; }
[ -x "$PYTHON" ] || { echo "Python 不可执行: $PYTHON" >&2; exit 1; }
mkdir -p "$SECRETS_DIR/master_keys" "$SCRIPT_DIR/out"
chmod 700 "$SECRETS_DIR" "$SECRETS_DIR/master_keys" "$SCRIPT_DIR/out"
if [ ! -f "$PASS_FILE" ]; then
"$PYTHON" -c 'import secrets; print(secrets.token_urlsafe(48))' > "$PASS_FILE"
chmod 600 "$PASS_FILE"
echo "已生成随机私钥口令文件:$PASS_FILE"
echo "请立即离线备份 secrets/;丢失口令或私钥将无法继续为已有客户签发。"
fi
[ "$(stat -c '%a' "$PASS_FILE")" = 600 ] || { echo "口令文件权限必须为 600: $PASS_FILE" >&2; exit 1; }
if [ ! -f "$SECRETS_DIR/supplier_private_key.pem" ]; then
"$PYTHON" "$ADMIN" gen-keys \
--private-key "$SECRETS_DIR/supplier_private_key.pem" \
--public-key "$SECRETS_DIR/supplier_public_key.pem" \
--key-passphrase-file "$PASS_FILE"
else
echo "复用现有供应商私钥(不会覆盖)"
fi
MASTER_KEY="$SECRETS_DIR/master_keys/$VERSION.key"
if [ ! -f "$MASTER_KEY" ]; then
"$PYTHON" "$ADMIN" gen-master-key --output "$MASTER_KEY"
else
echo "复用版本 $VERSION 的 K_master(不会覆盖)"
fi
chmod 600 "$SECRETS_DIR/supplier_private_key.pem" "$MASTER_KEY" "$PASS_FILE"
echo "阶段 B 完成:VERSION=$VERSION"
sha256sum "$SECRETS_DIR/supplier_public_key.pem"
@@ -0,0 +1,272 @@
#!/usr/bin/env bash
# 阶段 C:只使用阶段 A 已验证的三项目产物,构建并加密联合发行包。
set -euo pipefail
umask 077
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${SERVER_ENV_FILE:-$SCRIPT_DIR/server.env}"
[[ -f "$ENV_FILE" ]] || { echo "缺少配置文件: $ENV_FILE" >&2; exit 1; }
# shellcheck source=/dev/null
. "$ENV_FILE"
TEST_ROOT="${TEST_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
SERVER_ROOT="${SERVER_ROOT:-$TEST_ROOT/server}"
VERSION="${VERSION:-1.0.0}"
PYTHON="${PLANTAI_PYTHON:-python3}"
QA_PYTHON="${QA_PYTHON:-python3}"
SECRETS_DIR="${SECRETS_DIR:-$SCRIPT_DIR/secrets}"
PASS_FILE="${SUPPLIER_KEY_PASSPHRASE_FILE:-$SECRETS_DIR/private_key.pass}"
PLANTAI_COMPILED="${PLANTAI_COMPILED:-${PLANTAI_OUTPUT:-$SERVER_ROOT/PlantAI_compiled.new}}"
RAG_COMPILED="${RAG_COMPILED:-${RAG_OUTPUT:-$SERVER_ROOT/RAG.new}}"
QA_COMPILED="${QA_COMPILED:-${QA_OUTPUT:-$SERVER_ROOT/RAG_QA.new}}"
QA_QDRANT_STORAGE_SOURCE="${QA_QDRANT_STORAGE_SOURCE:?请设置 QA_QDRANT_STORAGE_SOURCE}"
QA_NEO4J_DATA_SOURCE="${QA_NEO4J_DATA_SOURCE:?请设置 QA_NEO4J_DATA_SOURCE}"
QA_RERANKER_MODEL_SOURCE="${QA_RERANKER_MODEL_SOURCE:?请设置 QA_RERANKER_MODEL_SOURCE}"
OLLAMA_MODELS_SOURCE="${OLLAMA_MODELS_SOURCE:?请设置 OLLAMA_MODELS_SOURCE}"
QA_OLLAMA_GENERATION_MODEL="${QA_OLLAMA_GENERATION_MODEL:-qwen2.5:14b-instruct}"
QA_OLLAMA_EMBED_MODEL="${QA_OLLAMA_EMBED_MODEL:-bge-m3:567m}"
OUT_DIR="${OUT_DIR:-$SCRIPT_DIR/out/$VERSION}"
OUT_ROOT="$SCRIPT_DIR/out"
ADMIN="$SCRIPT_DIR/bin/plantai_admin.py"
AUDITOR="$SCRIPT_DIR/tools/audit_compiled_tree.py"
SMOKE="$SCRIPT_DIR/tools/smoke_dual_build.py"
OLLAMA_EXPORTER="$SCRIPT_DIR/tools/export_ollama_models.py"
MANIFEST_BUILDER="$SCRIPT_DIR/tools/build_delivery_manifest.py"
[[ "$VERSION" =~ ^[A-Za-z0-9._-]+$ ]] && [[ "$VERSION" != *..* ]] || {
echo "非法 VERSION: $VERSION" >&2
exit 1
}
for command_name in realpath rsync tar sha256sum sort xargs; do
command -v "$command_name" >/dev/null || {
echo "缺少阶段 C 工具: $command_name" >&2
exit 1
}
done
for path in \
"$PLANTAI_COMPILED" \
"$RAG_COMPILED" \
"$QA_COMPILED" \
"$PLANTAI_COMPILED/models" \
"$PLANTAI_COMPILED/prompts" \
"$RAG_COMPILED/prompts" \
"$QA_QDRANT_STORAGE_SOURCE" \
"$QA_NEO4J_DATA_SOURCE" \
"$QA_RERANKER_MODEL_SOURCE" \
"$OLLAMA_MODELS_SOURCE" \
"$SECRETS_DIR/supplier_private_key.pem" \
"$SECRETS_DIR/supplier_public_key.pem" \
"$SECRETS_DIR/master_keys/$VERSION.key" \
"$PASS_FILE" \
"$ADMIN" \
"$AUDITOR" \
"$SMOKE" \
"$OLLAMA_EXPORTER" \
"$MANIFEST_BUILDER"
do
[[ -e "$path" ]] || { echo "缺少阶段 C 输入: $path" >&2; exit 1; }
done
[[ -x "$PYTHON" ]] || { echo "Python 不可执行: $PYTHON" >&2; exit 1; }
[[ -x "$QA_PYTHON" ]] || { echo "QA Python 不可执行: $QA_PYTHON" >&2; exit 1; }
OUT_ROOT="$(realpath -m "$OUT_ROOT")"
OUT_DIR="$(realpath -m "$OUT_DIR")"
case "$OUT_DIR" in
"$OUT_ROOT"/*) ;;
*) echo "OUT_DIR 必须位于 $OUT_ROOT 下" >&2; exit 1 ;;
esac
PAYLOAD="$OUT_DIR/release"
ARCHIVE="$OUT_ROOT/plantai-rag-release-$VERSION.tar.gz"
DATA_ARCHIVE="$OUT_ROOT/plantai-rag-runtime-data-$VERSION.tar"
DATA_ARCHIVE_TMP="$OUT_ROOT/.plantai-rag-runtime-data-$VERSION.tar.$$"
echo "[C/6] 验证阶段 A 构建标记与 ELF 状态"
"$PYTHON" -B "$AUDITOR" \
--tree "$PLANTAI_COMPILED" --project PlantAI --require-marker
"$PYTHON" -B "$AUDITOR" \
--tree "$RAG_COMPILED" --project RAG --require-marker
"$QA_PYTHON" -B "$AUDITOR" \
--tree "$QA_COMPILED" --project QA --require-marker
echo "[C/6] 再次运行代表性导入与行为冒烟测试"
mkdir -p "$OUT_DIR/validation"
"$PYTHON" -B "$SMOKE" \
--plantai "$PLANTAI_COMPILED" \
--rag "$RAG_COMPILED" \
--qa "$QA_COMPILED" \
--python "$PYTHON" \
--qa-python "$QA_PYTHON" \
--output "$OUT_DIR/validation/smoke.json"
if [[ "${PHASE_C_PREFLIGHT_ONLY:-0}" == "1" ]]; then
"$PYTHON" -B "$OLLAMA_EXPORTER" \
--source "$OLLAMA_MODELS_SOURCE" \
--output "$OUT_DIR/validation/ollama-models-unused" \
--model "$QA_OLLAMA_GENERATION_MODEL" \
--model "$QA_OLLAMA_EMBED_MODEL" \
--check-only
echo "阶段 C 预检完成;未复制或加密发行数据。"
exit 0
fi
[[ ! -e "$PAYLOAD" ]] || {
echo "发行暂存目录已存在;拒绝覆盖: $PAYLOAD" >&2
exit 1
}
[[ ! -e "$ARCHIVE" ]] || {
echo "发行包已存在;拒绝覆盖: $ARCHIVE" >&2
exit 1
}
[[ ! -e "$DATA_ARCHIVE" ]] || {
echo "运行数据包已存在;拒绝覆盖: $DATA_ARCHIVE" >&2
exit 1
}
[[ ! -e "$DATA_ARCHIVE.sha256" ]] || {
echo "运行数据校验文件已存在;拒绝覆盖: $DATA_ARCHIVE.sha256" >&2
exit 1
}
echo "[C/6] 复制三项目编译产物"
mkdir -p \
"$PAYLOAD/app/PlantAI_compiled" \
"$PAYLOAD/app/RAG" \
"$PAYLOAD/app/QA" \
"$PAYLOAD/assets" \
"$PAYLOAD/config"
rsync -a \
--exclude='.hardened-build.json' \
--exclude='__pycache__/' --exclude='*.pyc' \
--exclude='models/' --exclude='prompts/' --exclude='data/hmm/' --exclude='rag_kb_store/' \
--exclude='cost_logs/' --exclude='memory_storage/' \
--exclude='workspace/' --exclude='logs/' \
"$PLANTAI_COMPILED/" "$PAYLOAD/app/PlantAI_compiled/"
rsync -a \
--exclude='.hardened-build.json' \
--exclude='__pycache__/' --exclude='*.pyc' \
--exclude='models/' --exclude='prompts/' --exclude='rag_kb_store/' \
--exclude='qdrant_docker/' --exclude='logs/' \
"$RAG_COMPILED/" "$PAYLOAD/app/RAG/"
rsync -a \
--exclude='.hardened-build.json' \
--exclude='__pycache__/' --exclude='*.pyc' \
--exclude='models/' --exclude='logs/' --exclude='data/' \
"$QA_COMPILED/" "$PAYLOAD/app/QA/"
install -m 0755 "$SCRIPT_DIR/templates/start_all_client.sh" "$PAYLOAD/app/start_all.sh"
echo "[C/6] 从阶段 A 已验证产物组装运行资产"
ASSET_STAGE="$(mktemp -d "$OUT_ROOT/runtime-assets.XXXXXX")"
cleanup() {
case "$ASSET_STAGE" in
"$OUT_ROOT"/runtime-assets.*) rm -rf -- "$ASSET_STAGE" ;;
esac
if [[ -n "${DATA_STAGE:-}" ]]; then
case "$DATA_STAGE" in
"$OUT_ROOT"/runtime-data.*) rm -rf -- "$DATA_STAGE" ;;
esac
fi
[[ ! -f "$DATA_ARCHIVE_TMP" ]] || rm -f -- "$DATA_ARCHIVE_TMP"
}
trap cleanup EXIT
mkdir -p \
"$ASSET_STAGE/runtime-assets/PlantAI_compiled" \
"$ASSET_STAGE/runtime-assets/RAG"
cp -a \
"$PLANTAI_COMPILED/models" \
"$ASSET_STAGE/runtime-assets/PlantAI_compiled/models"
cp -a \
"$PLANTAI_COMPILED/prompts" \
"$ASSET_STAGE/runtime-assets/PlantAI_compiled/prompts"
[[ -f "$PLANTAI_COMPILED/data/hmm/Pfam-A.hmm" ]] || {
echo "阶段 A 产物缺少 PlantAI Pfam-A.hmm: $PLANTAI_COMPILED/data/hmm/Pfam-A.hmm" >&2
exit 1
}
mkdir -p "$ASSET_STAGE/runtime-assets/PlantAI_compiled/data"
cp -a \
"$PLANTAI_COMPILED/data/hmm" \
"$ASSET_STAGE/runtime-assets/PlantAI_compiled/data/hmm"
[[ ! -d "$RAG_COMPILED/models" ]] || \
cp -a "$RAG_COMPILED/models" "$ASSET_STAGE/runtime-assets/RAG/models"
cp -a \
"$RAG_COMPILED/prompts" \
"$ASSET_STAGE/runtime-assets/RAG/prompts"
assert_volume_not_live() {
local source="$1"
command -v docker >/dev/null || return 0
local container_id mounted
while IFS= read -r container_id; do
[[ -n "$container_id" ]] || continue
while IFS= read -r mounted; do
[[ -n "$mounted" ]] || continue
if [[ "$(realpath -m "$mounted")" == "$(realpath -m "$source")" ]]; then
echo "拒绝打包运行中的数据库卷: $source(容器 $container_id)" >&2
echo "请先停止使用该卷的容器,完成阶段 C 后再启动,以保证数据一致性。" >&2
exit 1
fi
done < <(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$container_id")
done < <(docker ps -q)
}
assert_volume_not_live "$QA_QDRANT_STORAGE_SOURCE"
assert_volume_not_live "$QA_NEO4J_DATA_SOURCE"
DATA_STAGE="$(mktemp -d "$OUT_ROOT/runtime-data.XXXXXX")"
mkdir -p \
"$DATA_STAGE/runtime-assets/QA/models" \
"$DATA_STAGE/runtime-assets/qdrant" \
"$DATA_STAGE/runtime-assets/neo4j"
ln -s "$QA_RERANKER_MODEL_SOURCE" \
"$DATA_STAGE/runtime-assets/QA/models/bge-reranker-base"
ln -s "$QA_QDRANT_STORAGE_SOURCE" \
"$DATA_STAGE/runtime-assets/qdrant/storage"
ln -s "$QA_NEO4J_DATA_SOURCE" \
"$DATA_STAGE/runtime-assets/neo4j/data"
"$PYTHON" -B "$OLLAMA_EXPORTER" \
--source "$OLLAMA_MODELS_SOURCE" \
--output "$DATA_STAGE/runtime-assets/ollama-models" \
--model "$QA_OLLAMA_GENERATION_MODEL" \
--model "$QA_OLLAMA_EMBED_MODEL"
"$PYTHON" -B "$MANIFEST_BUILDER" \
--runtime-root "$DATA_STAGE/runtime-assets" \
--output "$PAYLOAD/DELIVERY_MANIFEST.json" \
--version "$VERSION" \
--generation-model "$QA_OLLAMA_GENERATION_MODEL" \
--embed-model "$QA_OLLAMA_EMBED_MODEL"
echo "[C/6] AES-256-GCM 加密并签名 runtime-assets"
"$PYTHON" "$ADMIN" build-encrypt-asset \
--source "$ASSET_STAGE/runtime-assets" \
--master-key "$SECRETS_DIR/master_keys/$VERSION.key" \
--private-key "$SECRETS_DIR/supplier_private_key.pem" \
--key-passphrase-file "$PASS_FILE" \
--output-dir "$PAYLOAD/assets" \
--release-version "$VERSION" \
--name runtime-assets
echo "[C/6] 打包 QA 数据卷与本地模型(持久化数据,不进入 tmpfs)"
tar -C "$DATA_STAGE" -chf "$DATA_ARCHIVE_TMP" runtime-assets
echo "[C/6] 写入公钥、版本与完整性清单"
install -m 0644 \
"$SECRETS_DIR/supplier_public_key.pem" \
"$PAYLOAD/config/supplier_public_key.pem"
printf '%s\n' "$VERSION" > "$PAYLOAD/VERSION"
(
cd "$PAYLOAD"
find . -type f ! -name SHA256SUMS -print0 \
| sort -z \
| xargs -0 sha256sum > SHA256SUMS
)
echo "[C/6] 打包并执行秘密泄漏检查"
tar -C "$PAYLOAD" -czf "$ARCHIVE" VERSION DELIVERY_MANIFEST.json app assets config SHA256SUMS
if tar -tzf "$ARCHIVE" \
| grep -Eq 'supplier_private|master_keys|private_key\.pass|plantai_admin\.py|server\.env|\.hardened-build\.json'
then
echo "安全检查失败:发行包包含服务端文件或构建标记" >&2
exit 1
fi
echo "阶段 C 完成:$ARCHIVE"
sha256sum "$ARCHIVE"
mv -- "$DATA_ARCHIVE_TMP" "$DATA_ARCHIVE"
echo "QA 运行数据包:$DATA_ARCHIVE"
sha256sum "$DATA_ARCHIVE" | tee "$DATA_ARCHIVE.sha256"
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# 阶段 E:为一个已完成 TPM 设置的指纹签发授权。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[ -f "$SCRIPT_DIR/server.env" ] && . "$SCRIPT_DIR/server.env"
FINGERPRINT="${FINGERPRINT:?请设置 FINGERPRINT=/安全路径/device_fingerprint.json}"
CUSTOMER_ID="${CUSTOMER_ID:?请设置 CUSTOMER_ID}"
CUSTOMER_NAME="${CUSTOMER_NAME:?请设置 CUSTOMER_NAME}"
SERIAL_NO="${SERIAL_NO:?请设置 SERIAL_NO}"
VERSION="${VERSION:-1.0.0}"
[ -f "$FINGERPRINT" ] || { echo "指纹文件不存在: $FINGERPRINT" >&2; exit 1; }
"${PLANTAI_PYTHON:-python3}" - "$FINGERPRINT" <<'PY'
import json, sys
fp = json.load(open(sys.argv[1], encoding="utf-8"))
assert fp.get("device_id"), "missing device_id"
if fp.get("test_only_swtpm"):
import os
assert os.environ.get("ALLOW_SOFTWARE_TPM_TEST") == "1", (
"这是软件 TPM 测试指纹;仅测试签发时显式设置 ALLOW_SOFTWARE_TPM_TEST=1"
)
tpm = fp.get("tpm_public_key") or {}
assert tpm.get("public_key_pem"), "没有 TPM 公钥;请先在目标机执行 tpm2-setup 后重新采集"
assert tpm.get("attestation"), "没有 TPM attestation;拒绝生产签发"
print("TPM fingerprint precheck passed:", fp["device_id"])
PY
exec "$SCRIPT_DIR/issue_client.sh" \
--fingerprint "$FINGERPRINT" --release-version "$VERSION" \
--customer-id "$CUSTOMER_ID" --customer-name "$CUSTOMER_NAME" \
--serial-no "$SERIAL_NO" \
--allowed-assets runtime-assets \
--allow-unverified-tpm-key
@@ -0,0 +1,187 @@
#!/usr/bin/env bash
# 阶段 F(供应商侧):备份三个核心知识库数据,打包发给客户。
# 你的一千多篇文章存在三个地方:
# 1. rag_kb_store/ ← 原文文件
# 2. Qdrant ← 向量索引(语义搜索用)
# 3. Neo4j ← 知识图谱(实体关系用)
#
# 客户侧恢复请使用 client/restore_data.sh。
set -euo pipefail
umask 077
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[ -f "$SCRIPT_DIR/server.env" ] && . "$SCRIPT_DIR/server.env"
TEST_ROOT="${TEST_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
SERVER_ROOT="${SERVER_ROOT:-$TEST_ROOT/server}"
VERSION="${VERSION:-1.0.0}"
OUT_DIR="${OUT_DIR:-$SCRIPT_DIR/out/$VERSION}"
usage() {
cat >&2 <<EOF
用法: $(basename "$0") [选项]
备份三个核心知识库数据,发给客户。
必要参数:
--rag-kb-dir PATH rag_kb_store 所在目录(默认: \$SERVER_ROOT/PlantAI_compiled)
→ 你的一千多篇原文文件
Qdrant 参数:
--qdrant-url URL Qdrant HTTP 地址(默认: http://127.0.0.1:6333)
Neo4j 参数:
--neo4j-container NAME Neo4j 容器名(默认: neo4j-gene-family)
--neo4j-version VER Neo4j 镜像版本(默认: 5.15.0-community)
跳过选项:
--skip-rag-kb 跳过文件知识库
--skip-qdrant 跳过 Qdrant
--skip-neo4j 跳过 Neo4j
其他:
-h, --help 显示此帮助
EOF
exit 1
}
RAG_KB_DIR="${RAG_KB_DIR:-$SERVER_ROOT/PlantAI_compiled}"
QDRANT_URL="${QDRANT_URL:-http://127.0.0.1:6333}"
NEO4J_CONTAINER="${NEO4J_CONTAINER:-neo4j-gene-family}"
NEO4J_VERSION="${NEO4J_VERSION:-5.15.0-community}"
SKIP_RAG_KB=false
SKIP_QDRANT=false
SKIP_NEO4J=false
while [ $# -gt 0 ]; do
case "$1" in
--rag-kb-dir) RAG_KB_DIR="$2"; shift 2 ;;
--qdrant-url) QDRANT_URL="$2"; shift 2 ;;
--neo4j-container) NEO4J_CONTAINER="$2"; shift 2 ;;
--neo4j-version) NEO4J_VERSION="$2"; shift 2 ;;
--skip-rag-kb) SKIP_RAG_KB=true; shift ;;
--skip-qdrant) SKIP_QDRANT=true; shift ;;
--skip-neo4j) SKIP_NEO4J=true; shift ;;
-h|--help) usage ;;
*) usage ;;
esac
done
mkdir -p "$OUT_DIR"
have_cmd() { command -v "$1" >/dev/null 2>&1; }
echo "=============================================="
echo " 供应商侧 - 知识库迁移打包"
echo " 版本: $VERSION"
echo " 输出目录: $OUT_DIR"
echo ""
echo " 备份内容(你的一千篇文章的三种形态):"
echo " 1. rag_kb_store/ ← 原文文件"
echo " 2. Qdrant 快照 ← 向量索引(语义搜索)"
echo " 3. Neo4j dump ← 知识图谱(实体关系)"
echo "=============================================="
# ============================================================
# 1. rag_kb_store — 原始文章文件
# ============================================================
if [ "$SKIP_RAG_KB" = false ]; then
if [ -d "$RAG_KB_DIR/rag_kb_store" ]; then
echo "[1/3] 打包文件知识库 rag_kb_store(原文文件)"
tar --xattrs --acls -czf "$OUT_DIR/knowledge-$VERSION.tar.gz" \
-C "$RAG_KB_DIR" rag_kb_store
echo " -> 大小: $(du -h "$OUT_DIR/knowledge-$VERSION.tar.gz" | cut -f1)"
else
echo "[1/3] 错误: $RAG_KB_DIR/rag_kb_store 不存在,请指定 --rag-kb-dir"
exit 1
fi
fi
# ============================================================
# 2. Qdrant — 向量索引(文章段落的数学指纹)
# ============================================================
if [ "$SKIP_QDRANT" = false ]; then
echo "[2/3] Qdrant 向量库快照"
if have_cmd curl; then
collections="$(curl -sf "$QDRANT_URL/collections" | python3 -c "
import json,sys
data=json.load(sys.stdin)
cols=data.get('result',data.get('collections',[]))
for c in cols:
print(c.get('name',c))
" 2>/dev/null)" || collections=""
if [ -n "$collections" ]; then
mkdir -p "$OUT_DIR/qdrant-snapshots/$VERSION"
while IFS= read -r col; do
[ -z "$col" ] && continue
echo " -> 创建集合 '$col' 快照"
curl -sf -X POST "$QDRANT_URL/collections/$col/snapshots" >/dev/null && sleep 2
snaps="$(curl -sf "$QDRANT_URL/collections/$col/snapshots" | python3 -c "
import json,sys
data=json.load(sys.stdin)
for s in data.get('result',[]):
print(s.get('name',''))
" 2>/dev/null)" || snaps=""
for snap in $snaps; do
snap_path="$("${PLANTAI_PYTHON:-python3}" -c "
import json,sys
data=json.load(sys.stdin)
for s in data.get('result',[]):
if s.get('name') == '$snap':
print(s.get('path',''))
" 2>/dev/null <<< "$(curl -sf "$QDRANT_URL/collections/$col/snapshots")")" || true
if [ -n "$snap_path" ] && [ -f "$snap_path" ]; then
cp "$snap_path" "$OUT_DIR/qdrant-snapshots/$VERSION/${col}__${snap}"
echo " 已保存: ${col}__${snap}"
fi
done
done <<< "$collections"
echo " -> 快照总大小: $(du -sh "$OUT_DIR/qdrant-snapshots/$VERSION" | cut -f1)"
else
echo " -> Qdrant 无响应或无集合,跳过"
fi
fi
fi
# ============================================================
# 3. Neo4j — 知识图谱(文章之间的实体关系网络)
# ============================================================
if [ "$SKIP_NEO4J" = false ]; then
echo "[3/3] Neo4j 图数据库 dump"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "$NEO4J_CONTAINER"; then
echo " -> 停止容器 $NEO4J_CONTAINER"
docker stop "$NEO4J_CONTAINER"
NEO4J_DATA_DIR="$(docker inspect "$NEO4J_CONTAINER" --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' 2>/dev/null)" || true
if [ -n "$NEO4J_DATA_DIR" ] && [ -d "$NEO4J_DATA_DIR" ]; then
mkdir -p "$OUT_DIR/neo4j-backup"
echo " -> 执行 neo4j-admin database dump"
docker run --rm \
-v "${NEO4J_DATA_DIR}:/data" \
-v "$OUT_DIR/neo4j-backup:/backups" \
"neo4j:${NEO4J_VERSION}" \
neo4j-admin database dump neo4j --to-path=/backups --overwrite-destination=true
echo " -> dump 文件: $(ls -lh "$OUT_DIR/neo4j-backup/")"
fi
echo " -> 重启容器 $NEO4J_CONTAINER"
docker start "$NEO4J_CONTAINER"
else
echo " -> 未运行 Neo4j 容器,跳过"
fi
fi
echo ""
echo "=============================================="
echo " 打包完成!发给客户的产物:"
echo " ┌────────────────────────────────────────────┐"
echo " │ 1. knowledge-$VERSION.tar.gz ← 原文文件 │"
ls -lh "$OUT_DIR/knowledge-$VERSION.tar.gz" 2>/dev/null || echo " │ (未打包)"
echo " │ 2. qdrant-snapshots/ ← 向量索引 │"
ls -lh "$OUT_DIR/qdrant-snapshots/$VERSION/" 2>/dev/null | head -3 || echo " │ (未打包)"
echo " │ 3. neo4j-backup/ ← 知识图谱 │"
ls -lh "$OUT_DIR/neo4j-backup/" 2>/dev/null | head -3 || echo " │ (未打包)"
echo " └────────────────────────────────────────────┘"
echo ""
echo " 客户机上执行 client/restore_data.sh 恢复。"
echo " 另外还需要发给客户的(跟知识库无关):"
echo " - plantai-rag-code-<VERSION>.tar.gz (代码包)"
echo " - plantai-provision-<DEVICE>.tar.gz (授权文件)"
echo "=============================================="
@@ -0,0 +1,57 @@
# =============================================================================
# server.env — 服务端阶段脚本统一配置
# =============================================================================
# 工作区根目录。
TEST_ROOT=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test
SERVER_ROOT=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server
# 阶段 A/C 的源码输入。只需要在这里修改,脚本不再内置源码路径。
PLANTAI_SOURCE=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/rag-window
RAG_SOURCE=/home/xzq/5090/PlanAI-cooperate/RAG/rag-develop
QA_SOURCE=/home/xzq/5090/PlanAI-cooperate/RAG/rag-qa-v1_1_73
# 阶段 A 的新产物;阶段 C 默认只消费这两个目录。
PLANTAI_OUTPUT=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/PlantAI_compiled.new
RAG_OUTPUT=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/RAG.new
QA_OUTPUT=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/RAG_QA.new
PLANTAI_COMPILED=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/PlantAI_compiled.new
RAG_COMPILED=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/RAG.new
QA_COMPILED=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/RAG_QA.new
# QA 交付运行资产。阶段 C 将复制并加密这些目录,不会修改源数据卷。
QA_QDRANT_STORAGE_SOURCE=/home/xzq/home/wutx/gene-family-mas-v2/data/qdrant_qa_v1_1_73/storage
QA_NEO4J_DATA_SOURCE=/home/xzq/home/wutx/gene-family-mas-v2/data/neo4j_qa_v1_1_73/data
QA_RERANKER_MODEL_SOURCE=/home/xzq/5090/PlantAI-rag/bge-reranker-base
OLLAMA_MODELS_SOURCE=/home/xzq/5090/PlanAI-cooperate/RAG/runtime/ollama_models_qa_v1_1_73
QA_OLLAMA_GENERATION_MODEL=qwen2.5:14b-instruct
QA_OLLAMA_EMBED_MODEL=bge-m3:567m
QA_ENV_FILE=/home/xzq/5090/PlanAI-cooperate/RAG/runtime/qa_v1_1_73.env
# 客户授权信息(阶段 E)。
FINGERPRINT=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/plantai_server_package/server_fingers/test/device_fingerprint.json
CUSTOMER_ID=CUS-0001
CUSTOMER_NAME=测试客户
SERIAL_NO=PlantAI-RAG-0001
# 软件 TPM 只能用于测试;生产硬件授权不要启用。
export ALLOW_SOFTWARE_TPM_TEST=1
# 每次正式发新版时修改。
VERSION=1.0.0
# 服务端秘密目录,绝不随发行包外发。
SECRETS_DIR=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/plantai_server_package/secrets
# 构建发行包的输出目录(可选)。
# OUT_DIR=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/plantai_server_package/out/1.0.0
# 与目标部署环境 ABI 一致的 Python。
PLANTAI_PYTHON=/home/xzq/miniconda3/envs/test4py311/bin/python
QA_PYTHON=/home/xzq/miniconda3/envs/gfpy312/bin/python
# 供应商私钥口令文件,权限应为 600。
SUPPLIER_KEY_PASSPHRASE_FILE=/home/xzq/5090/PlanAI-cooperate/hsj-workspace/encryption-test/server/plantai_server_package/secrets/private_key.pass
# 或改用保存口令的环境变量名称(与上一项二选一):
# SUPPLIER_KEY_PASSPHRASE_ENV=PLANTAI_SUPPLIER_KEY_PASSPHRASE
BBA_PFAM_DIR=/home/xzq/5090/PlantAI-dev4090-7/PlantAI-dev/data/hmm
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env bash
# Test-server launcher for hardened PlantAI + RAG + QA.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${SERVER_ENV_FILE:-$SCRIPT_DIR/server.env}"
[[ -f "$ENV_FILE" ]] && . "$ENV_FILE"
QA_ENV_FILE="${QA_ENV_FILE:-}"
[[ -z "$QA_ENV_FILE" || ! -f "$QA_ENV_FILE" ]] || . "$QA_ENV_FILE"
PLANTAI_DIR="${1:-${PLANTAI_COMPILED:-${PLANTAI_OUTPUT:-}}}"
RAG_DIR="${2:-${RAG_COMPILED:-${RAG_OUTPUT:-}}}"
QA_DIR="${3:-${QA_COMPILED:-${QA_OUTPUT:-}}}"
PLANTAI_PYTHON="${PLANTAI_PYTHON:-python3}"
RAG_PYTHON="${RAG_PYTHON:-$PLANTAI_PYTHON}"
QA_PYTHON="${QA_PYTHON:-python3}"
PLANTAI_PORT="${PLANTAI_WEB_PORT:-7880}"
RAG_PORT="${RAG_PORT:-7900}"
QA_PORT="${QA_PORT:-7925}"
QA_QDRANT_PORT="${QA_QDRANT_PORT:-16333}"
QA_QDRANT_GRPC_PORT="${QA_QDRANT_GRPC_PORT:-16334}"
QA_NEO4J_HTTP_PORT="${QA_NEO4J_HTTP_PORT:-17474}"
QA_NEO4J_BOLT_PORT="${QA_NEO4J_BOLT_PORT:-17687}"
QA_NEO4J_PASSWORD="${QA_NEO4J_PASSWORD:-${NEO4J_PASSWORD:-plant_srt_direct_password}}"
OLLAMA_PORT="${OLLAMA_PORT:-11434}"
QA_OLLAMA_GENERATION_MODEL="${QA_OLLAMA_GENERATION_MODEL:-qwen2.5:14b-instruct}"
QA_OLLAMA_EMBED_MODEL="${QA_OLLAMA_EMBED_MODEL:-bge-m3:567m}"
QA_QDRANT_STORAGE_SOURCE="${QA_QDRANT_STORAGE_SOURCE:-}"
QA_NEO4J_DATA_SOURCE="${QA_NEO4J_DATA_SOURCE:-}"
QA_RERANKER_MODEL_SOURCE="${QA_RERANKER_MODEL_SOURCE:-}"
PID_FILE="${PID_FILE:-/tmp/plantai_all_services.pid}"
PLANTAI_LOG="${PLANTAI_LOG:-/tmp/plantai_server.log}"
RAG_LOG="${RAG_LOG:-/tmp/rag_server.log}"
QA_LOG="${QA_LOG:-/tmp/qa_server.log}"
BBAV5_QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_dev_${PLANTAI_PORT}}"
BBAV5_REDIS_URL="${BBAV5_REDIS_URL:-redis://127.0.0.1:6379/10}"
BBAV5_CELERY_LOG_DIR="${BBAV5_CELERY_LOG_DIR:-$PLANTAI_DIR/logs/celery_${BBAV5_QUEUE_PREFIX}}"
BBAV5_CELERY_LOGLEVEL="${BBAV5_CELERY_LOGLEVEL:-info}"
BBAV5_DEFAULT_CONCURRENCY="${BBAV5_DEFAULT_CONCURRENCY:-1}"
BBAV5_HEAVY_CONCURRENCY="${BBAV5_HEAVY_CONCURRENCY:-2}"
BBAV5_MEDIUM_CONCURRENCY="${BBAV5_MEDIUM_CONCURRENCY:-2}"
BBAV5_LIGHT_CONCURRENCY="${BBAV5_LIGHT_CONCURRENCY:-4}"
BBAV5_RNASEQ_CONCURRENCY="${BBAV5_RNASEQ_CONCURRENCY:-2}"
BBAV5_WORKER_NAME_PREFIX="${BBAV5_WORKER_NAME_PREFIX:-${BBAV5_QUEUE_PREFIX//[^A-Za-z0-9_]/_}}"
export PATH="$(dirname "$PLANTAI_PYTHON"):$PATH"
export BBA_PFAM_DIR="${BBA_PFAM_DIR:-$PLANTAI_DIR/data/hmm}"
export BBAV5_PROJECT_ROOT="$PLANTAI_DIR"
export BBAV5_QUEUE_PREFIX
export BBAV5_REDIS_URL
usage() {
echo "用法: bash $0 [PlantAI目录] [RAG目录] [QA目录]" >&2
echo "未传参数时自动读取 $ENV_FILE" >&2
}
for value in "$PLANTAI_DIR" "$RAG_DIR" "$QA_DIR"; do
[[ -n "$value" ]] || { usage; exit 2; }
[[ -d "$value" ]] || { echo "目录不存在: $value" >&2; exit 2; }
done
PLANTAI_DIR="$(realpath "$PLANTAI_DIR")"
RAG_DIR="$(realpath "$RAG_DIR")"
QA_DIR="$(realpath "$QA_DIR")"
export BBAV5_PROJECT_ROOT="$PLANTAI_DIR"
[[ -f "$PLANTAI_DIR/apps/web/vue3/main.py" ]] || { echo "缺少 PlantAI 入口" >&2; exit 2; }
[[ -f "$RAG_DIR/api/app.py" ]] || { echo "缺少 RAG 入口" >&2; exit 2; }
[[ -f "$QA_DIR/scripts/qa_v1_1_73_server.py" ]] || { echo "缺少 QA 入口" >&2; exit 2; }
for python_bin in "$PLANTAI_PYTHON" "$RAG_PYTHON" "$QA_PYTHON"; do
[[ -x "$python_bin" ]] || { echo "Python 不可执行: $python_bin" >&2; exit 2; }
done
wait_http() {
local url="$1" label="$2"
for _ in {1..90}; do
curl -fsS --max-time 2 "$url" >/dev/null 2>&1 && return 0
sleep 1
done
echo "$label 未能通过健康检查: $url" >&2
return 1
}
ensure_container() {
local name="$1" image="$2" source="$3" target="$4"
shift 4
if docker inspect "$name" >/dev/null 2>&1; then
local mounted_source
mounted_source="$(docker inspect -f \
"{{range .Mounts}}{{if eq .Destination \"$target\"}}{{.Source}}{{end}}{{end}}" \
"$name")"
[[ "$(realpath -m "$mounted_source")" == "$(realpath -m "$source")" ]] || {
echo "$name 已存在但 $target 挂载不匹配: $mounted_source != $source" >&2
exit 3
}
[[ "$(docker inspect -f '{{.State.Running}}' "$name")" == "true" ]] ||
docker start "$name" >/dev/null
return
fi
[[ -d "$source" ]] || { echo "$name 数据目录不存在: $source" >&2; exit 3; }
docker run -d --name "$name" --restart unless-stopped \
-v "$source:$target" "$@" "$image" >/dev/null
}
command -v curl >/dev/null
command -v docker >/dev/null
command -v ollama >/dev/null
ensure_container qdrant-qa-v1-1-73 qdrant/qdrant:latest \
"$QA_QDRANT_STORAGE_SOURCE" /qdrant/storage \
-p "$QA_QDRANT_PORT:6333" -p "$QA_QDRANT_GRPC_PORT:6334"
ensure_container neo4j-qa-v1-1-73 neo4j:5.15.0-community \
"$QA_NEO4J_DATA_SOURCE" /data \
-p "$QA_NEO4J_HTTP_PORT:7474" -p "$QA_NEO4J_BOLT_PORT:7687" \
-e "NEO4J_AUTH=neo4j/$QA_NEO4J_PASSWORD"
wait_http "http://127.0.0.1:$QA_QDRANT_PORT/collections" Qdrant
wait_http "http://127.0.0.1:$QA_NEO4J_HTTP_PORT" Neo4j
wait_http "http://127.0.0.1:$OLLAMA_PORT/api/tags" Ollama
ollama list | awk 'NR > 1 {print $1}' | grep -Fxq "$QA_OLLAMA_GENERATION_MODEL" ||
{ echo "Ollama 缺少模型: $QA_OLLAMA_GENERATION_MODEL" >&2; exit 3; }
ollama list | awk 'NR > 1 {print $1}' | grep -Fxq "$QA_OLLAMA_EMBED_MODEL" ||
{ echo "Ollama 缺少模型: $QA_OLLAMA_EMBED_MODEL" >&2; exit 3; }
export METHODOLOGY_RAG_API="http://127.0.0.1:$RAG_PORT/api/v1"
export BBA_RAG_FAMILY_PFAM_URL="http://127.0.0.1:$RAG_PORT/api/v1/rag/family/pfam"
export BBA_RAG_PFAM_URL="http://127.0.0.1:$RAG_PORT/api/v1/rag/chat"
export DUAL_KB_QA_BASE_URL="http://127.0.0.1:$QA_PORT"
export DUAL_KB_QA_ROUTE="qa-v1-1-73"
export PLANTAI_RAG_ROOT="$RAG_DIR"
export GENE_FAMILY_ROOT="$QA_DIR"
export V15_QDRANT_URL="http://127.0.0.1:$QA_QDRANT_PORT"
export QDRANT_URL="$V15_QDRANT_URL"
export V15_QDRANT_COLLECTION="${V15_QDRANT_COLLECTION:-plant_srt_direct_v1_bge_m3_20260702}"
export NEO4J_URI="bolt://127.0.0.1:$QA_NEO4J_BOLT_PORT"
export NEO4J_USER="${NEO4J_USER:-neo4j}"
export NEO4J_PASSWORD="$QA_NEO4J_PASSWORD"
export QA_V1_BGE_RERANKER_MODEL="${QA_V1_BGE_RERANKER_MODEL:-$QA_RERANKER_MODEL_SOURCE}"
export QA_V1_ALIAS_CONFIG="${QA_V1_ALIAS_CONFIG:-$QA_DIR/configs/rag/entity_alias.yaml}"
export QA_V1_TAXONOMY_CONFIG="${QA_V1_TAXONOMY_CONFIG:-$QA_DIR/configs/rag/relation_taxonomy.yaml}"
export QA_V1_LLM_API_KEY="ollama-local"
export QA_V1_LLM_BASE_URL="http://127.0.0.1:$OLLAMA_PORT/v1"
export QA_V1_LLM_MODEL="$QA_OLLAMA_GENERATION_MODEL"
export V15_OLLAMA_EMBED_URL="${V15_OLLAMA_EMBED_URL:-http://127.0.0.1:$OLLAMA_PORT/api/embed}"
export V15_OLLAMA_EMBED_MODEL="$QA_OLLAMA_EMBED_MODEL"
pids=()
celery_pids=()
cleanup() {
for pid in "${pids[@]:-}"; do kill "$pid" 2>/dev/null || true; done
rm -f -- "$PID_FILE"
}
trap cleanup EXIT INT TERM
: > "$PID_FILE"
start_celery_worker() {
local role="$1" concurrency="$2"
[[ "$concurrency" =~ ^[0-9]+$ ]] ||
{ echo "Celery $role concurrency 必须是非负整数: $concurrency" >&2; exit 2; }
(( concurrency > 0 )) || return 0
local worker_name="${BBAV5_WORKER_NAME_PREFIX}_${role}"
local queue_name="${BBAV5_QUEUE_PREFIX}.${role}"
mkdir -p "$BBAV5_CELERY_LOG_DIR"
(cd "$PLANTAI_DIR" && PYTHONPATH="$RAG_DIR:$PLANTAI_DIR" \
"$PLANTAI_PYTHON" -m celery -A celery_app worker \
--loglevel="$BBAV5_CELERY_LOGLEVEL" \
--hostname="${worker_name}@%h" \
--queues="$queue_name" \
--concurrency="$concurrency" \
--prefetch-multiplier=1) \
>>"$BBAV5_CELERY_LOG_DIR/${worker_name}.log" 2>&1 &
pids+=("$!")
celery_pids+=("$!")
echo "$!" >>"$PID_FILE"
}
start_celery_worker default "$BBAV5_DEFAULT_CONCURRENCY"
start_celery_worker heavy "$BBAV5_HEAVY_CONCURRENCY"
start_celery_worker medium "$BBAV5_MEDIUM_CONCURRENCY"
start_celery_worker light "$BBAV5_LIGHT_CONCURRENCY"
start_celery_worker rnaseq "$BBAV5_RNASEQ_CONCURRENCY"
sleep "${BBAV5_CELERY_START_WAIT_SEC:-4}"
for pid in "${celery_pids[@]}"; do
kill -0 "$pid" 2>/dev/null ||
{ echo "Celery worker 启动后提前退出,请检查 $BBAV5_CELERY_LOG_DIR" >&2; exit 3; }
done
(cd "$QA_DIR" && PYTHONPATH="$QA_DIR" "$QA_PYTHON" -m uvicorn \
scripts.qa_v1_1_73_server:app --host 127.0.0.1 --port "$QA_PORT") \
>>"$QA_LOG" 2>&1 &
pids+=("$!"); echo "$!" >>"$PID_FILE"
wait_http "http://127.0.0.1:$QA_PORT/api/v1/qa-v1-1-73/health" QA
(cd "$RAG_DIR" && PYTHONPATH="$RAG_DIR:$RAG_DIR/src" "$RAG_PYTHON" -m uvicorn \
api.app:app --host 127.0.0.1 --port "$RAG_PORT" --workers 2) \
>>"$RAG_LOG" 2>&1 &
pids+=("$!"); echo "$!" >>"$PID_FILE"
wait_http "http://127.0.0.1:$RAG_PORT/health" RAG
(cd "$PLANTAI_DIR" && PYTHONPATH="$RAG_DIR:$PLANTAI_DIR" \
PLANTAI_WEB_PORT="$PLANTAI_PORT" "$PLANTAI_PYTHON" apps/web/vue3/main.py) \
>>"$PLANTAI_LOG" 2>&1 &
pids+=("$!"); echo "$!" >>"$PID_FILE"
wait_http "http://127.0.0.1:$PLANTAI_PORT" PlantAI
echo "三项目与 Celery workers 已启动:PlantAI=$PLANTAI_PORT RAG=$RAG_PORT QA=$QA_PORT"
echo "Celery: prefix=$BBAV5_QUEUE_PREFIX broker=$BBAV5_REDIS_URL logs=$BBAV5_CELERY_LOG_DIR"
echo "PID 文件: $PID_FILE;日志: $PLANTAI_LOG $RAG_LOG $QA_LOG"
wait -n "${pids[@]}"
echo "一个应用服务意外退出,正在停止其余应用服务" >&2
exit 1
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# 停止 PlantAI + RAG + QA 及 Celery workers,与 start_all.sh 对应。
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${SERVER_ENV_FILE:-$SCRIPT_DIR/plantai_server_package/server.env}"
[[ -f "$ENV_FILE" ]] && . "$ENV_FILE"
PID_FILE="${PID_FILE:-/tmp/plantai_all_services.pid}"
PLANTAI_PORT="${PLANTAI_WEB_PORT:-7880}"
RAG_PORT="${RAG_PORT:-7900}"
QA_PORT="${QA_PORT:-7925}"
STOP_CONTAINERS=false
for arg in "$@"; do
case "$arg" in
--stop-containers) STOP_CONTAINERS=true ;;
*) echo "未知参数: $arg" >&2; exit 2 ;;
esac
done
stop_by_pid_file() {
[[ -f "$PID_FILE" ]] || return 1
local stopped=0
while IFS= read -r pid; do
[[ "$pid" =~ ^[0-9]+$ ]] || continue
if kill -0 "$pid" 2>/dev/null; then
kill -TERM "$pid" 2>/dev/null && echo "SIGTERM -> PID $pid" || true
stopped=$((stopped + 1))
else
echo "PID $pid 已不存在,跳过"
fi
done < "$PID_FILE"
# 等待最多 10 秒让进程优雅退出
local waited=0
while (( waited < 10 )); do
local alive=0
while IFS= read -r pid; do
[[ "$pid" =~ ^[0-9]+$ ]] || continue
kill -0 "$pid" 2>/dev/null && alive=$((alive + 1)) || true
done < "$PID_FILE"
(( alive == 0 )) && break
sleep 1
waited=$((waited + 1))
done
# 仍存活则强制杀死
while IFS= read -r pid; do
[[ "$pid" =~ ^[0-9]+$ ]] || continue
if kill -0 "$pid" 2>/dev/null; then
kill -KILL "$pid" 2>/dev/null && echo "SIGKILL -> PID $pid(未能优雅退出)" || true
fi
done < "$PID_FILE"
rm -f -- "$PID_FILE"
echo "PID 文件已清除: $PID_FILE"
return 0
}
stop_by_port() {
local port="$1" label="$2"
local pids_on_port
pids_on_port=$(lsof -t -i:"$port" 2>/dev/null || true)
if [[ -n "$pids_on_port" ]]; then
echo "$pids_on_port" | xargs -r kill -TERM 2>/dev/null && echo "SIGTERM -> $label 端口 $port 上的进程" || true
else
echo "$label(端口 $port)未发现运行中的进程"
fi
}
echo "=== 停止 PlantAI 服务 ==="
if ! stop_by_pid_file; then
echo "未找到 PID 文件,改用端口方式停止..."
stop_by_port "$PLANTAI_PORT" PlantAI
stop_by_port "$RAG_PORT" RAG
stop_by_port "$QA_PORT" QA
fi
if [[ "$STOP_CONTAINERS" == "true" ]]; then
echo "=== 停止 Docker 容器 ==="
for container in qdrant-qa-v1-1-73 neo4j-qa-v1-1-73; do
if docker inspect "$container" >/dev/null 2>&1; then
if [[ "$(docker inspect -f '{{.State.Running}}' "$container")" == "true" ]]; then
docker stop "$container" && echo "已停止容器: $container" || true
else
echo "容器 $container 已处于停止状态"
fi
else
echo "容器 $container 不存在,跳过"
fi
done
fi
echo "=== 所有服务已停止 ==="
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
# Customer launcher, called after license validation and runtime-assets decryption.
set -euo pipefail
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[[ ! -f "$BASE_DIR/runtime.env" ]] || . "$BASE_DIR/runtime.env"
PLANTAI_DIR="${1:-$BASE_DIR/PlantAI_compiled}"
RAG_DIR="${2:-$BASE_DIR/RAG}"
QA_DIR="${3:-$BASE_DIR/QA}"
PLANTAI_ROOT="${PLANTAI_ROOT:-$(cd "$BASE_DIR/../../.." && pwd)}"
RUNTIME_ROOT="${RUNTIME_ROOT:-${PLANTAI_RUNTIME_DATA:-$PLANTAI_ROOT/data/runtime-assets}}"
[[ -d "$RUNTIME_ROOT" ]] || { echo "解密后的运行资产目录不存在: $RUNTIME_ROOT" >&2; exit 2; }
PLANTAI_PYTHON="${PLANTAI_PYTHON:-python3}"
RAG_PYTHON="${RAG_PYTHON:-$PLANTAI_PYTHON}"
QA_PYTHON="${QA_PYTHON:-python3}"
PLANTAI_PORT="${PLANTAI_WEB_PORT:-7880}"
RAG_PORT="${RAG_PORT:-7900}"
QA_PORT="${QA_PORT:-7925}"
QA_QDRANT_PORT="${QA_QDRANT_PORT:-16333}"
QA_QDRANT_GRPC_PORT="${QA_QDRANT_GRPC_PORT:-16334}"
QA_NEO4J_HTTP_PORT="${QA_NEO4J_HTTP_PORT:-17474}"
QA_NEO4J_BOLT_PORT="${QA_NEO4J_BOLT_PORT:-17687}"
QA_NEO4J_PASSWORD="${QA_NEO4J_PASSWORD:-${NEO4J_PASSWORD:-}}"
[[ -n "$QA_NEO4J_PASSWORD" ]] ||
{ echo "请在 install.env/service.env 设置 QA_NEO4J_PASSWORD 或 NEO4J_PASSWORD" >&2; exit 2; }
OLLAMA_PORT="${OLLAMA_PORT:-11435}"
QA_OLLAMA_GENERATION_MODEL="${QA_OLLAMA_GENERATION_MODEL:-qwen2.5:14b-instruct}"
QA_OLLAMA_EMBED_MODEL="${QA_OLLAMA_EMBED_MODEL:-bge-m3:567m}"
BBAV5_QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_dev_${PLANTAI_PORT}}"
BBAV5_REDIS_URL="${BBAV5_REDIS_URL:-redis://127.0.0.1:6379/10}"
BBAV5_CELERY_LOG_DIR="${BBAV5_CELERY_LOG_DIR:-$PLANTAI_DIR/logs/celery_${BBAV5_QUEUE_PREFIX}}"
BBAV5_CELERY_LOGLEVEL="${BBAV5_CELERY_LOGLEVEL:-info}"
BBAV5_DEFAULT_CONCURRENCY="${BBAV5_DEFAULT_CONCURRENCY:-1}"
BBAV5_HEAVY_CONCURRENCY="${BBAV5_HEAVY_CONCURRENCY:-2}"
BBAV5_MEDIUM_CONCURRENCY="${BBAV5_MEDIUM_CONCURRENCY:-2}"
BBAV5_LIGHT_CONCURRENCY="${BBAV5_LIGHT_CONCURRENCY:-4}"
BBAV5_RNASEQ_CONCURRENCY="${BBAV5_RNASEQ_CONCURRENCY:-2}"
BBAV5_WORKER_NAME_PREFIX="${BBAV5_WORKER_NAME_PREFIX:-${BBAV5_QUEUE_PREFIX//[^A-Za-z0-9_]/_}}"
QDRANT_DATA="$RUNTIME_ROOT/qdrant/storage"
NEO4J_DATA="$RUNTIME_ROOT/neo4j/data"
RERANKER_MODEL="$RUNTIME_ROOT/QA/models/bge-reranker-base"
OLLAMA_MODELS="$RUNTIME_ROOT/ollama-models"
for path in "$PLANTAI_DIR" "$RAG_DIR" "$QA_DIR" "$QDRANT_DATA" "$NEO4J_DATA" "$RERANKER_MODEL" "$OLLAMA_MODELS"; do
[[ -d "$path" ]] || { echo "交付目录不存在: $path" >&2; exit 2; }
done
for python_bin in "$PLANTAI_PYTHON" "$RAG_PYTHON" "$QA_PYTHON"; do
command -v "$python_bin" >/dev/null 2>&1 || [[ -x "$python_bin" ]] ||
{ echo "Python 不可用: $python_bin" >&2; exit 2; }
done
wait_http() {
local url="$1" label="$2"
for _ in {1..120}; do
curl -fsS --max-time 2 "$url" >/dev/null 2>&1 && return 0
sleep 1
done
echo "$label 健康检查失败: $url" >&2
return 1
}
ensure_container() {
local name="$1" image="$2" source="$3" target="$4"
shift 4
if docker inspect "$name" >/dev/null 2>&1; then
local mounted_source
mounted_source="$(docker inspect -f \
"{{range .Mounts}}{{if eq .Destination \"$target\"}}{{.Source}}{{end}}{{end}}" \
"$name")"
[[ "$(realpath -m "$mounted_source")" == "$(realpath -m "$source")" ]] || {
echo "$name 已存在但 $target 挂载不匹配: $mounted_source != $source" >&2
exit 3
}
[[ "$(docker inspect -f '{{.State.Running}}' "$name")" == "true" ]] ||
docker start "$name" >/dev/null
else
docker run -d --name "$name" --restart unless-stopped \
-v "$source:$target" "$@" "$image" >/dev/null
fi
}
command -v docker >/dev/null
command -v curl >/dev/null
command -v ollama >/dev/null
ensure_container plantai-qdrant-qa qdrant/qdrant:latest "$QDRANT_DATA" /qdrant/storage \
-p "$QA_QDRANT_PORT:6333" -p "$QA_QDRANT_GRPC_PORT:6334"
ensure_container plantai-neo4j-qa neo4j:5.15.0-community "$NEO4J_DATA" /data \
-p "$QA_NEO4J_HTTP_PORT:7474" -p "$QA_NEO4J_BOLT_PORT:7687" \
-e "NEO4J_AUTH=neo4j/$QA_NEO4J_PASSWORD"
wait_http "http://127.0.0.1:$QA_QDRANT_PORT/collections" Qdrant
wait_http "http://127.0.0.1:$QA_NEO4J_HTTP_PORT" Neo4j
pids=()
cleanup() { for pid in "${pids[@]:-}"; do kill "$pid" 2>/dev/null || true; done; }
trap cleanup EXIT INT TERM
if ! curl -fsS --max-time 2 "http://127.0.0.1:$OLLAMA_PORT/api/tags" >/dev/null 2>&1; then
(OLLAMA_HOST="127.0.0.1:$OLLAMA_PORT" OLLAMA_MODELS="$OLLAMA_MODELS" ollama serve) &
pids+=("$!")
fi
wait_http "http://127.0.0.1:$OLLAMA_PORT/api/tags" Ollama
OLLAMA_HOST="127.0.0.1:$OLLAMA_PORT" OLLAMA_MODELS="$OLLAMA_MODELS" ollama list |
awk 'NR > 1 {print $1}' | grep -Fxq "$QA_OLLAMA_GENERATION_MODEL" ||
{ echo "交付 Ollama 模型缺失: $QA_OLLAMA_GENERATION_MODEL" >&2; exit 3; }
OLLAMA_HOST="127.0.0.1:$OLLAMA_PORT" OLLAMA_MODELS="$OLLAMA_MODELS" ollama list |
awk 'NR > 1 {print $1}' | grep -Fxq "$QA_OLLAMA_EMBED_MODEL" ||
{ echo "交付 Ollama 模型缺失: $QA_OLLAMA_EMBED_MODEL" >&2; exit 3; }
export METHODOLOGY_RAG_API="http://127.0.0.1:$RAG_PORT/api/v1"
export BBA_RAG_FAMILY_PFAM_URL="http://127.0.0.1:$RAG_PORT/api/v1/rag/family/pfam"
export BBA_RAG_PFAM_URL="http://127.0.0.1:$RAG_PORT/api/v1/rag/chat"
export DUAL_KB_QA_BASE_URL="http://127.0.0.1:$QA_PORT"
export DUAL_KB_QA_ROUTE="qa-v1-1-73"
export PLANTAI_RAG_ROOT="$RAG_DIR"
export GENE_FAMILY_ROOT="$QA_DIR"
export V15_QDRANT_URL="http://127.0.0.1:$QA_QDRANT_PORT"
export QDRANT_URL="$V15_QDRANT_URL"
export V15_QDRANT_COLLECTION="${V15_QDRANT_COLLECTION:-plant_srt_direct_v1_bge_m3_20260702}"
export NEO4J_URI="bolt://127.0.0.1:$QA_NEO4J_BOLT_PORT"
export NEO4J_USER="${NEO4J_USER:-neo4j}"
export NEO4J_PASSWORD="$QA_NEO4J_PASSWORD"
export QA_V1_BGE_RERANKER_MODEL="$RERANKER_MODEL"
export QA_V1_ALIAS_CONFIG="$QA_DIR/configs/rag/entity_alias.yaml"
export QA_V1_TAXONOMY_CONFIG="$QA_DIR/configs/rag/relation_taxonomy.yaml"
export QA_V1_LLM_API_KEY="ollama-local"
export QA_V1_LLM_BASE_URL="http://127.0.0.1:$OLLAMA_PORT/v1"
export QA_V1_LLM_MODEL="$QA_OLLAMA_GENERATION_MODEL"
export V15_OLLAMA_EMBED_URL="http://127.0.0.1:$OLLAMA_PORT/api/embed"
export V15_OLLAMA_EMBED_MODEL="$QA_OLLAMA_EMBED_MODEL"
export PATH="$(dirname "$PLANTAI_PYTHON"):$PATH"
export BBA_PFAM_DIR="${BBA_PFAM_DIR:-$PLANTAI_DIR/data/hmm}"
export BBAV5_PROJECT_ROOT="$PLANTAI_DIR"
export BBAV5_QUEUE_PREFIX
export BBAV5_REDIS_URL
(cd "$QA_DIR" && PYTHONPATH="$QA_DIR" "$QA_PYTHON" -m uvicorn \
scripts.qa_v1_1_73_server:app --host 127.0.0.1 --port "$QA_PORT") &
pids+=("$!"); wait_http "http://127.0.0.1:$QA_PORT/api/v1/qa-v1-1-73/health" QA
(cd "$RAG_DIR" && PYTHONPATH="$RAG_DIR:$RAG_DIR/src" "$RAG_PYTHON" -m uvicorn \
api.app:app --host 127.0.0.1 --port "$RAG_PORT" --workers 2) &
pids+=("$!"); wait_http "http://127.0.0.1:$RAG_PORT/health" RAG
celery_pids=()
start_celery_worker() {
local role="$1" concurrency="$2"
[[ "$concurrency" =~ ^[0-9]+$ ]] ||
{ echo "Celery $role concurrency 必须是非负整数: $concurrency" >&2; exit 2; }
(( concurrency > 0 )) || return 0
local worker_name="${BBAV5_WORKER_NAME_PREFIX}_${role}"
local queue_name="${BBAV5_QUEUE_PREFIX}.${role}"
mkdir -p "$BBAV5_CELERY_LOG_DIR"
(cd "$PLANTAI_DIR" && PYTHONPATH="$RAG_DIR:$PLANTAI_DIR" \
"$PLANTAI_PYTHON" -m celery -A celery_app worker \
--loglevel="$BBAV5_CELERY_LOGLEVEL" \
--hostname="${worker_name}@%h" \
--queues="$queue_name" \
--concurrency="$concurrency" \
--prefetch-multiplier=1) \
>>"$BBAV5_CELERY_LOG_DIR/${worker_name}.log" 2>&1 &
pids+=("$!")
celery_pids+=("$!")
}
start_celery_worker default "$BBAV5_DEFAULT_CONCURRENCY"
start_celery_worker heavy "$BBAV5_HEAVY_CONCURRENCY"
start_celery_worker medium "$BBAV5_MEDIUM_CONCURRENCY"
start_celery_worker light "$BBAV5_LIGHT_CONCURRENCY"
start_celery_worker rnaseq "$BBAV5_RNASEQ_CONCURRENCY"
sleep "${BBAV5_CELERY_START_WAIT_SEC:-4}"
for pid in "${celery_pids[@]}"; do
kill -0 "$pid" 2>/dev/null ||
{ echo "Celery worker 启动后提前退出,请检查 $BBAV5_CELERY_LOG_DIR" >&2; exit 3; }
done
(cd "$PLANTAI_DIR" && PYTHONPATH="$RAG_DIR:$PLANTAI_DIR" \
PLANTAI_WEB_PORT="$PLANTAI_PORT" "$PLANTAI_PYTHON" apps/web/vue3/main.py) &
pids+=("$!"); wait_http "http://127.0.0.1:$PLANTAI_PORT" PlantAI
echo "三项目与 Celery workers 已启动:PlantAI=$PLANTAI_PORT RAG=$RAG_PORT QA=$QA_PORT Ollama=$OLLAMA_PORT"
echo "Celery: prefix=$BBAV5_QUEUE_PREFIX broker=$BBAV5_REDIS_URL logs=$BBAV5_CELERY_LOG_DIR"
wait -n "${pids[@]}"
echo "一个应用服务意外退出,正在停止其余应用服务" >&2
exit 1
@@ -0,0 +1,8 @@
"""Make `plantai_admin` importable from the server bin/ directory."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "bin"))
@@ -0,0 +1,254 @@
"""Phase E (server) tests: TPM attestation verification + K_master wrapping.
Uses pure-Python fixture fingerprints (helpers.py) so no TPM is required.
Includes regression tests for the two real-world bugs encountered:
- `public_key_pem` holding a tpm2-tools text dump instead of a PEM
(MalformedFraming in load_pem_public_key)
"""
from __future__ import annotations
import argparse
import base64
import json
from pathlib import Path
import pytest
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding as _asym_padding
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.rsa import generate_private_key
from tpm_fixtures import (
make_attestation,
make_fingerprint,
marshal_tpmt_public,
pem_public_key,
tpm_name,
)
from plantai_admin import verify_tpm_attestation, wrap_key_for_device, wrap_key_with_tpm
DEVICE_ID = "test-device-001"
def _keypair():
key = generate_private_key(public_exponent=65537, key_size=2048)
return key, key.public_key()
@pytest.fixture(scope="module")
def valid_fingerprint():
wrap_priv, wrap_pub = _keypair()
cert_priv, cert_pub = _keypair()
tpmt = marshal_tpmt_public(wrap_pub)
attestation = make_attestation(wrap_pub, cert_priv, cert_pub, tpmt)
return make_fingerprint(DEVICE_ID, pem_public_key(wrap_pub), attestation), wrap_priv, tpmt
# ---------------------------------------------------------------------------
# verify_tpm_attestation
# ---------------------------------------------------------------------------
def test_verify_attestation_accepts_valid_fingerprint(valid_fingerprint):
fp, _, _ = valid_fingerprint
ok, reason = verify_tpm_attestation(fp["tpm_public_key"])
assert ok is True, reason
assert "verified" in reason.lower()
def test_verify_attestation_rejects_wrong_wrap_key(valid_fingerprint):
fp, _, _ = valid_fingerprint
tpm_info = dict(fp["tpm_public_key"])
# Submit a DIFFERENT RSA public key than the one the attestation covers.
other_priv, other_pub = _keypair()
tpm_info["public_key_pem"] = pem_public_key(other_pub)
ok, reason = verify_tpm_attestation(tpm_info)
assert ok is False
assert "mismatch" in reason.lower()
def test_verify_attestation_rejects_bad_signature(valid_fingerprint):
fp, _, _ = valid_fingerprint
tpm_info = dict(fp["tpm_public_key"])
attestation = dict(tpm_info["attestation"])
# Sign the blob with a random key -> invalid signature.
other_priv, other_pub = _keypair()
blob = base64.b64decode(attestation["attestation_blob_b64"])
attestation["attestation_signature_b64"] = base64.b64encode(
other_priv.sign(blob, _asym_padding.PKCS1v15(), hashes.SHA256())
).decode("ascii")
tpm_info["attestation"] = attestation
ok, reason = verify_tpm_attestation(tpm_info)
assert ok is False
assert "signature verification FAILED" in reason
def test_verify_attestation_rejects_name_mismatch(valid_fingerprint):
fp, _, _ = valid_fingerprint
tpm_info = dict(fp["tpm_public_key"])
attestation = dict(tpm_info["attestation"])
# Attestation certifies key A's TPM Name, but we submit key B's TPMT_PUBLIC.
_, other_pub = _keypair()
other_tpmt = marshal_tpmt_public(other_pub)
attestation["wrapkey_tpmt_public_b64"] = base64.b64encode(other_tpmt).decode("ascii")
tpm_info["attestation"] = attestation
ok, reason = verify_tpm_attestation(tpm_info)
assert ok is False
assert "name mismatch" in reason.lower()
def test_verify_attestation_rejects_text_dump_pem(valid_fingerprint):
"""Regression: public_key_pem containing a tpm2-tools TPMT_PUBLIC text
dump (the tpm2-tools 5.6 bug) must be rejected, not crash."""
fp, _, _ = valid_fingerprint
tpm_info = dict(fp["tpm_public_key"])
tpm_info["public_key_pem"] = (
"name: 000b8a0600ee6b\n"
"attributes:\n value: fixedtpm|fixedparent|sensitivedataorigin|userwithauth|decrypt\n"
"rsa: deadbeef\n"
)
ok, reason = verify_tpm_attestation(tpm_info)
assert ok is False
assert "could not parse public_key_pem" in reason.lower()
def test_verify_attestation_accepts_libtpms_style_blob(valid_fingerprint):
"""Regression: swtpm/libtpms marshals TPMS_ATTEST with the certified Name
at a non-spec offset (verified against a real swtpm 2.0 blob, where the
name lands at offset 75 instead of 78). The offset-based parser alone
fails on this, so the name must also be located by direct search.
"""
fp, _, tpmt = valid_fingerprint
tpm_info = dict(fp["tpm_public_key"])
# Build a fresh attestation with our own cert key so we can re-sign.
wrap_pub = serialization.load_pem_public_key(
tpm_info["public_key_pem"].encode())
cert_priv, cert_pub = _keypair()
attestation = make_attestation(wrap_pub, cert_priv, cert_pub, tpmt)
# Insert one byte into the clockInfo region, shifting the certified Name
# (and every later field) to a non-spec offset while keeping it intact.
blob = bytearray(base64.b64decode(attestation["attestation_blob_b64"]))
blob[10:10] = b"\x00"
attestation["attestation_blob_b64"] = base64.b64encode(bytes(blob)).decode("ascii")
attestation["attestation_signature_b64"] = base64.b64encode(
cert_priv.sign(bytes(blob), _asym_padding.PKCS1v15(), hashes.SHA256())
).decode("ascii")
tpm_info["attestation"] = attestation
ok, reason = verify_tpm_attestation(tpm_info)
assert ok is True, reason
# ---------------------------------------------------------------------------
# wrap_key_with_tpm
# ---------------------------------------------------------------------------
def test_wrap_key_roundtrip_with_tpm(valid_fingerprint):
fp, wrap_priv, _ = valid_fingerprint
master_key = b"K_master-" * 4
envelope = wrap_key_with_tpm(fp["tpm_public_key"]["public_key_pem"],
master_key, DEVICE_ID)
assert envelope["version"] == 3
assert envelope["wrap_mode"] == "tpm-rsa-oaep-v1"
plain = wrap_priv.decrypt(
base64.b64decode(envelope["wrapped_key_b64"]),
_asym_padding.OAEP(
mgf=_asym_padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
assert plain == master_key
def test_wrap_key_rejects_malformed_pem(valid_fingerprint):
"""Regression: the exact ValueError (MalformedFraming) from the original
bug — the server must fail cleanly on a text-dump public_key_pem."""
master_key = b"K_master-" * 4
with pytest.raises(ValueError):
wrap_key_with_tpm(
"name: 000b8a06\nattributes:\n value: fixedtpm|decrypt\nrsa: deadbeef\n",
master_key, DEVICE_ID,
)
# ---------------------------------------------------------------------------
# wrap_key_for_device (CLI handler)
# ---------------------------------------------------------------------------
def test_wrap_key_for_device_end_to_end(tmp_path, valid_fingerprint):
fp, wrap_priv, _ = valid_fingerprint
master_key = b"K_master-" * 4
fp_path = tmp_path / "device_fingerprint.json"
fp_path.write_text(json.dumps(fp, ensure_ascii=False), encoding="utf-8")
master_path = tmp_path / "K_master.key"
master_path.write_bytes(master_key)
out_path = tmp_path / "asset.key.enc"
wrap_key_for_device(argparse.Namespace(
fingerprint=str(fp_path),
master_key=str(master_path),
output=str(out_path),
allow_unverified_tpm_key=False,
allow_legacy_wrap=False,
))
envelope = json.loads(out_path.read_text(encoding="utf-8"))
assert envelope["version"] == 3
plain = wrap_priv.decrypt(
base64.b64decode(envelope["wrapped_key_b64"]),
_asym_padding.OAEP(
mgf=_asym_padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
assert plain == master_key
def test_issue_license_binds_tpm_key_and_signs(tmp_path, valid_fingerprint):
fp, _, _ = valid_fingerprint
from plantai_admin import issue_license, sign_payload
priv = Ed25519PrivateKey.generate()
priv_path = tmp_path / "supplier_private_key.pem"
priv_path.write_bytes(priv.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
))
pub_path = tmp_path / "supplier_public_key.pem"
pub_path.write_bytes(priv.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
))
fp_path = tmp_path / "device_fingerprint.json"
fp_path.write_text(json.dumps(fp, ensure_ascii=False), encoding="utf-8")
out_path = tmp_path / "license.json"
issue_license(argparse.Namespace(
fingerprint=str(fp_path),
private_key=str(priv_path),
output=str(out_path),
customer_id="CUS-0001",
customer_name="Test Co",
device_id=None,
serial_no="PlantAI-0001",
edition="standard",
release_version="1.0.0",
asset_key_id="ASSET-KEY-0001",
min_match_score="60",
allow_warning_score="85",
allow_weak_anchor=False,
asset_key_envelope=None,
allowed_assets=["runtime-assets"],
key_passphrase_env="",
key_passphrase_file="",
))
license_data = json.loads(out_path.read_text(encoding="utf-8"))
# Signature verifies against the supplier public key.
sign_payload(priv_path, license_data)
# TPM public key hash is bound into the license.
assert license_data["asset_policy"]["tpm_public_key_sha256"] == \
fp["tpm_public_key"]["public_key_sha256"]
@@ -0,0 +1,110 @@
"""Builders that produce a cryptographically-valid TPM fingerprint fixture.
A real client runs tpm2_readpublic / tpm2_certify against an actual TPM.
These helpers do the equivalent in pure Python using an in-memory RSA key
pair and a correctly-marshaled TPMS_ATTEST blob, so the server-side
verification code can be tested without any TPM.
Layouts follow the wire formats documented in plantai_admin.py:
TPMT_PUBLIC -> _parse_tpmt_public_rsa_numbers()
TPMS_ATTEST -> _parse_tpms_attest_name()
"""
from __future__ import annotations
import base64
import hashlib
import struct
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding as _asym_padding
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
_TPM_ALG_SHA256 = b"\x00\x0b"
# fixedtpm|fixedparent|sensitivedataorigin|userwithauth|decrypt
_DECRYPT_ATTRIBUTES = 0x00020072
def marshal_tpmt_public(pub: RSAPublicKey) -> bytes:
"""Marshal the TPMT_PUBLIC area of an RSA decrypt key (no size prefix)."""
n = pub.public_numbers().n
n_bytes = n.to_bytes((n.bit_length() + 7) // 8, "big").rjust(256, b"\x00")
auth_policy = b"\x00\x00" # TPM2B_DIGEST, len 0
symmetric = b"\x00\x10" # TPM_ALG_NULL (no keyBits/mode)
scheme = b"\x00\x10" # TPM_ALG_NULL, no details
unique = struct.pack(">H", len(n_bytes)) + n_bytes
return (
struct.pack(">H", 0x0001) # TPM_ALG_RSA
+ _TPM_ALG_SHA256 # nameAlg
+ struct.pack(">I", _DECRYPT_ATTRIBUTES)
+ auth_policy + symmetric + scheme
+ struct.pack(">H", 2048) # keyBits
+ struct.pack(">I", 0) # exponent 0 => 65537
+ unique
)
def tpm_name(tpmt_public: bytes) -> bytes:
return _TPM_ALG_SHA256 + hashlib.sha256(tpmt_public).digest()
def build_attestation_blob(name: bytes) -> bytes:
"""Construct a TPMS_ATTEST (TPM_ST_ATTEST_CERTIFY) blob embedding `name`."""
return (
struct.pack(">I", 0xFF544347) # magic
+ struct.pack(">H", 0x8017) # TPM_ST_ATTEST_CERTIFY
+ b"\x00\x00" # qualifiedSigner (len 0)
+ b"\x00\x00" # extraData (len 0)
+ b"\x00" * 20 # clockInfo
+ b"\x00" * 8 # firmwareVersion
+ struct.pack(">H", len(name)) + name # certified key Name
+ b"\x00\x00" # qualifiedName (len 0)
)
def pem_public_key(pub: RSAPublicKey) -> str:
return pub.public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("ascii")
def make_attestation(wrap_pub: RSAPublicKey,
cert_priv: RSAPrivateKey, cert_pub: RSAPublicKey,
tpmt_public: bytes) -> dict:
"""Produce the attestation block exactly as the client would."""
blob = build_attestation_blob(tpm_name(tpmt_public))
signature = cert_priv.sign(blob, _asym_padding.PKCS1v15(), hashes.SHA256())
cert_pem = pem_public_key(cert_pub)
return {
"attestation_blob_b64": base64.b64encode(blob).decode("ascii"),
"attestation_signature_b64": base64.b64encode(signature).decode("ascii"),
"certifying_key_pem": cert_pem,
"certifying_key_sha256": hashlib.sha256(cert_pem.encode()).hexdigest(),
"wrapkey_tpmt_public_b64": base64.b64encode(tpmt_public).decode("ascii"),
"attestation_json": {"type": "TPM_ST_ATTEST_CERTIFY"},
}
def make_fingerprint(device_id: str, wrap_pem: str, attestation: dict) -> dict:
return {
"device_id": device_id,
"core_id_anchor": "dmi_product_uuid",
"core_id_hash": hashlib.sha256(b"core").hexdigest(),
"fingerprint_material": {
"hostname": "dev",
"dmi": {},
"cpu_model": "test",
"gpu_uuid": [],
"disk_serial": [],
"network_mac": [],
},
"tpm_public_key": {
"tpm_version": "2.0",
"persistent_handle": 0x81008000,
"algorithm": "RSA-2048-OAEP-SHA256",
"public_key_pem": wrap_pem,
"public_key_sha256": hashlib.sha256(wrap_pem.encode()).hexdigest(),
"attestation": attestation,
},
}
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Audit a compiled tree and bind it to a tamper-evident build marker."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
import sysconfig
from datetime import datetime
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent))
from elf_visibility import summarize # noqa: E402
MARKER_NAME = ".hardened-build.json"
TREE_DIGEST_POLICY = "immutable-runtime-v2"
MUTABLE_TOP_LEVEL_DIRS = {
"cost_logs",
"data",
"input",
"logs",
"memory_storage",
"neo4j",
"output",
"outputs",
"rag_kb_store",
"service_data",
"temp",
"v15_output",
"workspace",
}
def file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def is_digest_excluded(relative: str) -> bool:
parts = Path(relative).parts
return (
relative == MARKER_NAME
or not parts
or parts[0] in MUTABLE_TOP_LEVEL_DIRS
or "__pycache__" in parts
or relative.endswith((".pyc", ".pyo"))
)
def tree_digest(root: Path) -> tuple[str, int]:
digest = hashlib.sha256()
count = 0
for path in sorted(
(item for item in root.rglob("*") if item.is_file()),
key=lambda item: item.relative_to(root).as_posix(),
):
relative = path.relative_to(root).as_posix()
if is_digest_excluded(relative):
continue
count += 1
digest.update(relative.encode("utf-8"))
digest.update(b"\0")
digest.update(bytes.fromhex(file_sha256(path)))
digest.update(b"\0")
return digest.hexdigest(), count
def audit_tree(root: Path, project: str) -> dict[str, Any]:
errors: list[dict[str, Any]] = []
extensions = sorted(root.rglob("*.so"))
expected_suffix = sysconfig.get_config_var("EXT_SUFFIX") or ".so"
hidden_non_pyinit = 0
if not extensions:
errors.append({"kind": "no_extensions"})
for extension in extensions:
relative = extension.relative_to(root).as_posix()
if not extension.name.endswith(expected_suffix):
errors.append(
{
"kind": "python_abi_suffix_mismatch",
"file": relative,
"expected_suffix": expected_suffix,
}
)
try:
info = summarize(extension, extension.read_bytes())
except BaseException as exc:
errors.append(
{
"kind": "elf_parse_failed",
"file": relative,
"error": f"{type(exc).__name__}: {exc}",
}
)
continue
if info["has_symtab"]:
errors.append({"kind": "symtab_present", "file": relative})
if len(info["pyinit"]) != 1 or not info["all_pyinit_default"]:
errors.append(
{
"kind": "invalid_pyinit",
"file": relative,
"pyinit": info["pyinit"],
}
)
if not info["all_non_pyinit_hidden"]:
errors.append(
{
"kind": "visible_non_pyinit",
"file": relative,
"symbols": info["non_pyinit"],
}
)
hidden_non_pyinit += len(info["non_pyinit"])
c_files = [
path.relative_to(root).as_posix()
for path in root.rglob("*.c")
if path.is_file()
]
if c_files:
errors.append({"kind": "generated_c_retained", "files": c_files})
sensitive = []
for path in root.rglob("*"):
if not path.is_file():
continue
lowered = path.name.lower()
if (
lowered.endswith(".key")
or "private_key" in lowered
or "master_key" in lowered
or lowered == "private_key.pass"
):
sensitive.append(path.relative_to(root).as_posix())
if sensitive:
errors.append({"kind": "sensitive_filename", "files": sensitive})
digest, file_count = tree_digest(root)
return {
"project": project,
"tree_digest_policy": TREE_DIGEST_POLICY,
"python_version": sys.version.split()[0],
"extension_suffix": expected_suffix,
"extension_count": len(extensions),
"hidden_non_pyinit_count": hidden_non_pyinit,
"file_count_without_marker": file_count,
"tree_sha256": digest,
"error_count": len(errors),
"errors": errors,
}
def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
os.chmod(temporary, 0o640)
os.replace(temporary, path)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--tree", type=Path, required=True)
parser.add_argument("--project", required=True)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--write-marker", action="store_true")
mode.add_argument("--require-marker", action="store_true")
args = parser.parse_args()
root = args.tree.resolve()
if not root.is_dir():
raise SystemExit(f"Compiled tree missing: {root}")
report = audit_tree(root, args.project)
marker_path = root / MARKER_NAME
if args.require_marker:
if not marker_path.is_file():
report["errors"].append({"kind": "marker_missing"})
else:
marker = json.loads(marker_path.read_text(encoding="utf-8"))
report["marker"] = marker
for field in (
"project",
"tree_digest_policy",
"python_version",
"extension_suffix",
"extension_count",
"tree_sha256",
):
if marker.get(field) != report.get(field):
report["errors"].append(
{
"kind": "marker_mismatch",
"field": field,
"expected": marker.get(field),
"actual": report.get(field),
}
)
report["error_count"] = len(report["errors"])
report["ok"] = report["error_count"] == 0
if args.write_marker and report["ok"]:
marker = {
key: report[key]
for key in (
"project",
"tree_digest_policy",
"python_version",
"extension_suffix",
"extension_count",
"hidden_non_pyinit_count",
"file_count_without_marker",
"tree_sha256",
)
}
marker["created_at"] = datetime.now().astimezone().isoformat(
timespec="seconds"
)
atomic_write_json(marker_path, marker)
report["marker_path"] = str(marker_path)
print(json.dumps(report, ensure_ascii=False, sort_keys=True))
return 0 if report["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Write the customer-facing manifest for the three-project offline delivery."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
def tree_bytes(path: Path) -> int:
total = 0
for directory, _, files in os.walk(path, followlinks=True):
for filename in files:
total += (Path(directory) / filename).stat().st_size
return total
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--runtime-root", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--version", required=True)
parser.add_argument("--generation-model", required=True)
parser.add_argument("--embed-model", required=True)
args = parser.parse_args()
root = args.runtime_root.resolve()
assets = {
"qdrant_storage": root / "qdrant" / "storage",
"neo4j_data": root / "neo4j" / "data",
"bge_reranker": root / "QA" / "models" / "bge-reranker-base",
"ollama_models": root / "ollama-models",
}
missing = [str(path) for path in assets.values() if not path.is_dir()]
if missing:
raise SystemExit("Missing runtime asset directories: " + ", ".join(missing))
payload = {
"release_version": args.version,
"runtime_data_archive": f"plantai-rag-runtime-data-{args.version}.tar",
"applications": {
"PlantAI": {"path": "app/PlantAI_compiled", "port": 7880},
"RAG": {"path": "app/RAG", "port": 7900},
"QA": {"path": "app/QA", "port": 7925},
},
"containers": {
"qdrant": {
"image": "qdrant/qdrant:latest",
"http_port": 16333,
"grpc_port": 16334,
"data_path": "runtime-assets/qdrant/storage",
},
"neo4j": {
"image": "neo4j:5.15.0-community",
"http_port": 17474,
"bolt_port": 17687,
"data_path": "runtime-assets/neo4j/data",
},
},
"models": {
"ollama_generation": args.generation_model,
"ollama_embedding": args.embed_model,
"ollama_path": "runtime-assets/ollama-models",
"reranker_path": "runtime-assets/QA/models/bge-reranker-base",
},
"asset_bytes": {name: tree_bytes(path) for name, path in assets.items()},
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(json.dumps(payload["asset_bytes"], ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""Inspect or hide non-PyInit defined symbols in an ELF64 little-endian .dynsym."""
from __future__ import annotations
import argparse
import json
import os
import struct
import tempfile
from pathlib import Path
from typing import Any
STN_UNDEF = 0
STV_DEFAULT = 0
STV_HIDDEN = 2
def c_string(data: bytes | bytearray, offset: int) -> str:
end = data.find(b"\0", offset)
if end < 0:
end = len(data)
return bytes(data[offset:end]).decode("utf-8", errors="replace")
def parse_sections(data: bytes | bytearray) -> tuple[list[dict[str, Any]], bytes]:
if data[:4] != b"\x7fELF":
raise ValueError("not an ELF file")
if data[4] != 2:
raise ValueError("only ELF64 is supported")
if data[5] != 1:
raise ValueError("only little-endian ELF is supported")
section_offset = struct.unpack_from("<Q", data, 40)[0]
section_entry_size = struct.unpack_from("<H", data, 58)[0]
section_count = struct.unpack_from("<H", data, 60)[0]
section_name_index = struct.unpack_from("<H", data, 62)[0]
if section_entry_size < 64 or not section_count:
raise ValueError("invalid or missing section table")
sections = []
for index in range(section_count):
offset = section_offset + index * section_entry_size
fields = struct.unpack_from("<IIQQQQIIQQ", data, offset)
sections.append(
{
"index": index,
"header_offset": offset,
"name_offset": fields[0],
"type": fields[1],
"flags": fields[2],
"address": fields[3],
"offset": fields[4],
"size": fields[5],
"link": fields[6],
"info": fields[7],
"alignment": fields[8],
"entry_size": fields[9],
}
)
if section_name_index >= len(sections):
raise ValueError("invalid section-name string-table index")
names_section = sections[section_name_index]
names = bytes(
data[
names_section["offset"] : names_section["offset"] + names_section["size"]
]
)
for section in sections:
section["name"] = c_string(names, section["name_offset"])
return sections, names
def inspect_dynsym(data: bytes | bytearray) -> tuple[list[dict[str, Any]], dict[str, Any]]:
sections, _ = parse_sections(data)
dynsym = next((section for section in sections if section["name"] == ".dynsym"), None)
if dynsym is None:
raise ValueError("missing .dynsym")
if dynsym["link"] >= len(sections):
raise ValueError("invalid .dynsym string-table link")
dynstr_section = sections[dynsym["link"]]
dynstr = data[
dynstr_section["offset"] : dynstr_section["offset"] + dynstr_section["size"]
]
entry_size = dynsym["entry_size"] or 24
if entry_size < 24:
raise ValueError("invalid ELF64 symbol entry size")
symbols = []
count = dynsym["size"] // entry_size
for index in range(count):
offset = dynsym["offset"] + index * entry_size
name_offset = struct.unpack_from("<I", data, offset)[0]
info = data[offset + 4]
other = data[offset + 5]
section_index = struct.unpack_from("<H", data, offset + 6)[0]
value, size = struct.unpack_from("<QQ", data, offset + 8)
name = c_string(dynstr, name_offset) if name_offset < len(dynstr) else ""
symbols.append(
{
"index": index,
"entry_offset": offset,
"name": name,
"binding": info >> 4,
"type": info & 0xF,
"visibility": other & 0x3,
"section_index": section_index,
"value": value,
"size": size,
"defined": section_index != STN_UNDEF,
}
)
return symbols, dynsym
def summarize(path: Path, data: bytes | bytearray) -> dict[str, Any]:
sections, _ = parse_sections(data)
symbols, _ = inspect_dynsym(data)
defined = [symbol for symbol in symbols if symbol["defined"] and symbol["name"]]
pyinit = [symbol for symbol in defined if symbol["name"].startswith("PyInit_")]
non_pyinit = [
symbol for symbol in defined if not symbol["name"].startswith("PyInit_")
]
return {
"path": str(path),
"has_symtab": any(section["name"] == ".symtab" for section in sections),
"defined_dynamic_count": len(defined),
"pyinit": [
{"name": symbol["name"], "visibility": symbol["visibility"]}
for symbol in pyinit
],
"non_pyinit": [
{"name": symbol["name"], "visibility": symbol["visibility"]}
for symbol in non_pyinit
],
"all_non_pyinit_hidden": all(
symbol["visibility"] == STV_HIDDEN for symbol in non_pyinit
),
"all_pyinit_default": bool(pyinit)
and all(symbol["visibility"] == STV_DEFAULT for symbol in pyinit),
}
def hide_non_pyinit(path: Path) -> dict[str, Any]:
original = path.read_bytes()
data = bytearray(original)
symbols, _ = inspect_dynsym(data)
changed = []
for symbol in symbols:
if (
symbol["defined"]
and symbol["name"]
and not symbol["name"].startswith("PyInit_")
and symbol["visibility"] != STV_HIDDEN
):
offset = symbol["entry_offset"] + 5
data[offset] = (data[offset] & ~0x3) | STV_HIDDEN
changed.append(symbol["name"])
if changed:
stat = path.stat()
with tempfile.NamedTemporaryFile(
mode="wb", dir=path.parent, prefix=f".{path.name}.", delete=False
) as handle:
temporary = Path(handle.name)
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, stat.st_mode)
os.replace(temporary, path)
result = summarize(path, data)
result["changed"] = changed
return result
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("elf", type=Path)
parser.add_argument("--hide-non-pyinit", action="store_true")
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
path = args.elf.resolve()
result = (
hide_non_pyinit(path)
if args.hide_non_pyinit
else summarize(path, path.read_bytes())
)
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
if args.check:
valid = (
not result["has_symtab"]
and result["all_pyinit_default"]
and result["all_non_pyinit_hidden"]
and len(result["pyinit"]) == 1
)
return 0 if valid else 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Copy selected Ollama manifests and their referenced blobs."""
from __future__ import annotations
import argparse
import json
import shutil
from pathlib import Path
def model_manifest(source: Path, model: str) -> Path:
repository, sep, tag = model.partition(":")
if not sep:
tag = "latest"
return source / "manifests" / "registry.ollama.ai" / "library" / repository / tag
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--model", action="append", required=True)
parser.add_argument("--check-only", action="store_true")
args = parser.parse_args()
source = args.source.resolve()
output = args.output.resolve()
copied_blobs: set[str] = set()
summary: list[dict[str, object]] = []
for model in args.model:
manifest = model_manifest(source, model)
if not manifest.is_file():
raise SystemExit(f"Ollama model manifest missing: {model}: {manifest}")
payload = json.loads(manifest.read_text(encoding="utf-8"))
digests = [payload["config"]["digest"]]
digests.extend(layer["digest"] for layer in payload.get("layers", []))
if not args.check_only:
destination = output / manifest.relative_to(source)
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(manifest, destination)
model_bytes = 0
for digest in digests:
if not digest.startswith("sha256:"):
raise SystemExit(f"Unsupported Ollama digest for {model}: {digest}")
blob_name = digest.replace(":", "-", 1)
blob_source = source / "blobs" / blob_name
if not blob_source.is_file():
raise SystemExit(f"Ollama blob missing for {model}: {blob_source}")
model_bytes += blob_source.stat().st_size
if args.check_only or blob_name in copied_blobs:
continue
blob_output = output / "blobs" / blob_name
blob_output.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(blob_source, blob_output)
copied_blobs.add(blob_name)
summary.append({"model": model, "bytes": model_bytes, "blobs": len(digests)})
if not args.check_only:
(output / "DELIVERED_MODELS.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(json.dumps(summary, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
为 Cython 编译产物生成 .py stub 文件。
每个 stub 同时支持:
1. 子进程调用 (python apps/cli/run_xxx.py --args)
-> __name__ == "__main__" 分支加载 .so 并调用 main()
2. 模块导入 (from apps.cli.run_xxx import SomeClass)
-> 模块级 __getattr__ 懒加载 .so ,透明转发属性访问
用法ᅳ
python generate_stubs.py <output_dir> [<output_dir2> ...]
"""
import sys
from pathlib import Path
STUB_TEMPLATE = (
"# Auto-generated stub — delegates to the compiled Cython extension.\n"
"# Supports both subprocess execution and module import.\n"
"import importlib.util as _ilu\n"
"import sys as _sys\n"
"from pathlib import Path as _P\n"
"\n"
"_stem = _P(__file__).stem\n"
"_so = next(_P(__file__).parent.glob(_stem + '.*.so'), None)\n"
"_cached_mod = None\n"
"\n"
"\n"
"def _load_compiled():\n"
" global _cached_mod\n"
" if _cached_mod is None:\n"
" if _so is None:\n"
" raise FileNotFoundError(\n"
" f'Compiled module not found for {__file__}'\n"
" )\n"
" _spec = _ilu.spec_from_file_location(_stem, _so)\n"
" _cached_mod = _ilu.module_from_spec(_spec)\n"
" _spec.loader.exec_module(_cached_mod)\n"
" return _cached_mod\n"
"\n"
"\n"
"def __getattr__(name: str):\n"
" \"\"\"Transparent proxy: forward attribute access to the compiled module.\"\"\"\n"
" return getattr(_load_compiled(), name)\n"
"\n"
"\n"
"if __name__ == '__main__':\n"
" _mod = _load_compiled()\n"
" _sys.exit(_mod.main() if hasattr(_mod, 'main') else 0)\n"
)
def generate_stubs(output_dir: Path) -> int:
created = 0
for so_path in sorted(output_dir.rglob("*.so")):
stem = so_path.name.split(".")[0]
if stem == "__init__":
continue
py_path = so_path.parent / f"{stem}.py"
if py_path.exists():
continue
py_path.write_text(STUB_TEMPLATE, encoding="utf-8")
print(f" stub: {py_path.relative_to(output_dir)}")
created += 1
return created
def main():
if len(sys.argv) < 2:
print(f"用法: {sys.argv[0]} <output_dir> [<output_dir2> ...]", file=sys.stderr)
sys.exit(1)
total = 0
for arg in sys.argv[1:]:
d = Path(arg)
if not d.is_dir():
print(f"目录不存在,跳过: {d}", file=sys.stderr)
continue
print(f"[generate_stubs] 扫描: {d}")
n = generate_stubs(d)
print(f"[generate_stubs] 已生成 {n} 个 stub: {d}")
total += n
print(f"[generate_stubs] 合计: {total} 个 stub")
if __name__ == "__main__":
main()
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Representative import and behavior smoke tests for hardened builds."""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
PLANTAI_PROBE = r"""
import importlib
import json
import sys
sys.path.insert(0, sys.argv[1])
module = importlib.import_module("gfa_core.mas.core.orchestrator")
class Dummy:
def __init__(self, name="dummy"):
self.name = name
def __getattr__(self, name):
return Dummy(f"{self.name}.{name}")
def __call__(self, *args, **kwargs):
return Dummy(f"{self.name}()")
instance = module.Orchestrator(Dummy("llm"), Dummy("memory"), None)
parsed = instance._parse_intent_from_query(
"identify the WRKY gene family in Arabidopsis"
)
route = instance._route_query({"query": "What is WRKY?"})
expected = {
"gene_family": "WRKY",
"species": "At",
"stage": "identify",
"task_type": "analysis",
}
assert parsed == expected, (parsed, expected)
assert route == "knowledge", route
print("@@RESULT@@" + json.dumps({
"module": module.__name__,
"parse_intent": parsed,
"route": route,
}))
"""
RAG_PROBE = r"""
import importlib
import json
import sys
root = sys.argv[1]
sys.path.insert(0, root)
sys.path.insert(0, root + "/src")
module = importlib.import_module("src.rag.retriever")
instance = module.RAGRetriever()
assert instance.count_chunks() == 0
assert instance.get_context([]) == ""
assert instance.index_documents([]) == 0
api_module = importlib.import_module("api.app")
route_paths = {route.path for route in api_module.app.routes}
assert "/health" in route_paths
assert "/api/v1/methodology/extract-jobs" in route_paths
print("@@RESULT@@" + json.dumps({
"module": module.__name__,
"api_module": api_module.__name__,
"methodology_route": True,
"count_chunks": 0,
"empty_context": "",
"empty_index_count": 0,
}))
"""
QA_PROBE = r"""
import importlib
import json
import sys
root = sys.argv[1]
sys.path.insert(0, root)
module = importlib.import_module("scripts.qa_v1_1_73_server")
route_paths = {route.path for route in module.app.routes}
assert "/health" in route_paths
assert "/api/v1/qa-v1-1-73/health" in route_paths
assert "/api/v1/qa-v1-1-73/info" in route_paths
assert "/api/v1/qa-v1-1-73/chat" in route_paths
print("@@RESULT@@" + json.dumps({
"module": module.__name__,
"health_route": True,
"info_route": True,
"chat_route": True,
}))
"""
def run_probe(python: str, code: str, root: Path, timeout: int) -> dict:
environment = os.environ.copy()
completed = subprocess.run(
[python, "-B", "-s", "-c", code, str(root)],
cwd=root,
env=environment,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
check=False,
)
result = None
for line in completed.stdout.splitlines():
if line.startswith("@@RESULT@@"):
result = json.loads(line[len("@@RESULT@@") :])
if completed.returncode != 0 or result is None:
raise RuntimeError(
f"probe failed rc={completed.returncode}\n"
f"stdout={completed.stdout[-3000:]}\n"
f"stderr={completed.stderr[-3000:]}"
)
result["stdout_tail"] = completed.stdout[-1000:]
return result
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--plantai", type=Path)
parser.add_argument("--rag", type=Path)
parser.add_argument("--qa", type=Path)
parser.add_argument("--python")
parser.add_argument("--qa-python")
parser.add_argument("--timeout", type=int, default=45)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
if args.plantai is None and args.rag is None and args.qa is None:
parser.error("at least one of --plantai, --rag or --qa is required")
if (args.plantai is not None or args.rag is not None) and not args.python:
parser.error("--python is required for PlantAI/RAG probes")
if args.qa is not None and not (args.qa_python or args.python):
parser.error("--qa-python or --python is required for the QA probe")
payload = {}
if args.plantai is not None:
payload["plantai"] = run_probe(
args.python, PLANTAI_PROBE, args.plantai.resolve(), args.timeout
)
if args.rag is not None:
payload["rag"] = run_probe(
args.python, RAG_PROBE, args.rag.resolve(), args.timeout
)
if args.qa is not None:
payload["qa"] = run_probe(
args.qa_python or args.python,
QA_PROBE,
args.qa.resolve(),
args.timeout,
)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# 生成可交付给客户的 client 包(不含开发者私密配置)
# 用法: ./package_client.sh <客户名称> [输出目录]
#
# 示例:
# ./package_client.sh customer-A /tmp/client-pkg
#
# 输出:
# /tmp/client-pkg/customer-A/
# install.env.example # 已填入 DEVICE_ID 等客户信息
# README.md # 含客户专属说明
# bin/ # 与原 client/bin 一致
# install_plantai.sh # 与原 client/install_plantai.sh 一致
# phase_d_tpm.sh # 与原一致
# phase_d_swtpm_test.sh # 与原一致
set -euo pipefail
CLIENT_NAME="${1:?用法: $0 <客户名称> [输出目录]}"
OUT_DIR="${2:-/tmp/client-pkg/$CLIENT_NAME}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
mkdir -p "$OUT_DIR"
# 复制不可变逻辑
cp -a "$SCRIPT_DIR/bin" "$OUT_DIR/bin"
cp -a "$SCRIPT_DIR/install_plantai.sh" "$OUT_DIR/"
cp -a "$SCRIPT_DIR/phase_d_tpm.sh" "$OUT_DIR/"
cp -a "$SCRIPT_DIR/phase_d_swtpm_test.sh" "$OUT_DIR/"
# 生成客户专属 install.env.example
DEVICE_ID_SUGGEST="${CLIENT_NAME}-$(hostname)"
sed "s/DEVICE_ID=.*/DEVICE_ID=$DEVICE_ID_SUGGEST/" \
"$SCRIPT_DIR/install.env.example" > "$OUT_DIR/install.env.example"
# 生成客户专属 README
cp "$SCRIPT_DIR/README.md" "$OUT_DIR/README.md"
cat >> "$OUT_DIR/README.md" <<EOF
## 客户专属信息
- **客户名称**: $CLIENT_NAME
- **建议 DEVICE_ID**: $DEVICE_ID_SUGGEST
- **供应商版本**: $(cat "$SCRIPT_DIR/../server/plantai_server_package/out/"*/release/VERSION 2>/dev/null || echo "待确认")
- **生成日期**: $(date '+%Y-%m-%d %H:%M')
EOF
echo "✅ 客户交付包已生成: $OUT_DIR"
echo ""
echo "下一步:"
echo " 1. 编辑 $OUT_DIR/install.env.example → 填入客户专属值后另存为 install.env"
echo " 2. 将 plantai-rag-release-<VERSION>.tar.gz 放到客户机的 release/ 目录"
echo " 3. 发送 $OUT_DIR 给客户(不含 install.env!)"