279 lines
12 KiB
Bash
279 lines
12 KiB
Bash
#!/usr/bin/env bash
|
|
set -Eeuo pipefail
|
|
|
|
# 在可联网的 Linux amd64 构建机上执行:
|
|
# ./deploy/linux-docker-offline-deerflow-only/build-release.sh WORKSPACE BUILD_ROOT
|
|
|
|
WORKSPACE="${1:?workspace path is required}"
|
|
BUILD_ROOT="${2:?build root is required}"
|
|
WORKSPACE="$(realpath "$WORKSPACE")"
|
|
BUILD_ROOT="$(realpath -m "$BUILD_ROOT")"
|
|
|
|
[[ -f "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/release-template/OFFLINE_INSTALL.sh" ]] || {
|
|
echo "invalid DeerFlow workspace: $WORKSPACE" >&2
|
|
exit 2
|
|
}
|
|
[[ -f "$WORKSPACE/offline-backend-20260512/backend/pyproject.toml" ]] || exit 2
|
|
[[ -f "$WORKSPACE/frontend-web/package.json" ]] || exit 2
|
|
|
|
case "$BUILD_ROOT" in
|
|
/|/opt|/opt/codex-build|"$WORKSPACE")
|
|
echo "unsafe build root: $BUILD_ROOT" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
|
|
STAMP="${RELEASE_STAMP:-$(date +%Y%m%d-%H%M%S)}"
|
|
DEERFLOW_COMMIT="${DEERFLOW_COMMIT_OVERRIDE:-}"
|
|
if [[ -z "$DEERFLOW_COMMIT" ]] && git -C "$WORKSPACE" rev-parse HEAD >/dev/null 2>&1; then
|
|
DEERFLOW_COMMIT="$(git -C "$WORKSPACE" rev-parse HEAD)"
|
|
fi
|
|
[[ "$DEERFLOW_COMMIT" =~ ^[0-9a-fA-F]{40}$ ]] || {
|
|
echo "set DEERFLOW_COMMIT_OVERRIDE to the 40-character source commit" >&2
|
|
exit 2
|
|
}
|
|
DEERFLOW_SHORT="${DEERFLOW_COMMIT:0:8}"
|
|
SOURCE_DIRTY="${DEERFLOW_DIRTY_OVERRIDE:-false}"
|
|
[[ "$SOURCE_DIRTY" == "true" || "$SOURCE_DIRTY" == "false" ]] || exit 2
|
|
|
|
RELEASE_NAME="deerflow-linux-amd64-offline-${STAMP}"
|
|
STAGE="$BUILD_ROOT/stage/$RELEASE_NAME"
|
|
DIST="$BUILD_ROOT/dist"
|
|
CONTEXT_ROOT="$BUILD_ROOT/context/$RELEASE_NAME"
|
|
BACKEND_CONTEXT="$CONTEXT_ROOT/backend"
|
|
FRONTEND_CONTEXT="$CONTEXT_ROOT/frontend-root"
|
|
|
|
for target in "$STAGE" "$CONTEXT_ROOT"; do
|
|
if [[ -e "$target" ]]; then
|
|
resolved="$(realpath "$target")"
|
|
case "$resolved" in
|
|
"$BUILD_ROOT/stage/$RELEASE_NAME"|"$BUILD_ROOT/context/$RELEASE_NAME") rm -rf -- "$resolved" ;;
|
|
*) echo "refusing to replace unexpected path: $resolved" >&2; exit 2 ;;
|
|
esac
|
|
fi
|
|
done
|
|
mkdir -p "$STAGE" "$DIST" "$BACKEND_CONTEXT" "$FRONTEND_CONTEXT/frontend-web" \
|
|
"$FRONTEND_CONTEXT/deploy/linux-docker-offline/nginx"
|
|
|
|
echo "[1/7] Assemble sanitized DeerFlow-only release"
|
|
cp -a "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/release-template/." "$STAGE/"
|
|
mkdir -p \
|
|
"$STAGE/config/deerflow" \
|
|
"$STAGE/config/frontend" \
|
|
"$STAGE/agents-skills/deerflow-agents" \
|
|
"$STAGE/agents-skills/deerflow-skills" \
|
|
"$STAGE/agents-skills/deerflow-knowledge" \
|
|
"$STAGE/images" \
|
|
"$STAGE/source/wheelhouse" \
|
|
"$STAGE/data/generated" \
|
|
"$STAGE/data/state"
|
|
|
|
cp -a "$WORKSPACE/offline-backend-20260512/backend/." "$BACKEND_CONTEXT/"
|
|
cp -a "$WORKSPACE/frontend-web/." "$FRONTEND_CONTEXT/frontend-web/"
|
|
cp -a "$WORKSPACE/deploy/linux-docker-offline/nginx/." \
|
|
"$FRONTEND_CONTEXT/deploy/linux-docker-offline/nginx/"
|
|
rm -rf -- \
|
|
"$BACKEND_CONTEXT/.venv" \
|
|
"$BACKEND_CONTEXT/.deer-flow" \
|
|
"$BACKEND_CONTEXT/.pytest_cache" \
|
|
"$BACKEND_CONTEXT/.ruff_cache" \
|
|
"$FRONTEND_CONTEXT/frontend-web/node_modules" \
|
|
"$FRONTEND_CONTEXT/frontend-web/dist" \
|
|
"$FRONTEND_CONTEXT/frontend-web/.vite"
|
|
find "$BACKEND_CONTEXT" "$FRONTEND_CONTEXT" -type d -name __pycache__ -prune -exec rm -rf -- {} +
|
|
|
|
python3 "$WORKSPACE/deploy/linux-docker-offline/sanitize_release_sources.py" backend "$BACKEND_CONTEXT"
|
|
python3 "$WORKSPACE/deploy/linux-docker-offline/sanitize_release_sources.py" frontend "$FRONTEND_CONTEXT/frontend-web"
|
|
|
|
# 运行时直接挂载的后端源码。用户后续只需覆盖这三个目录并重启。
|
|
mkdir -p "$STAGE/source/backend" "$STAGE/source/frontend/dist"
|
|
cp -a "$BACKEND_CONTEXT/app" "$STAGE/source/backend/app"
|
|
cp -a "$BACKEND_CONTEXT/packages" "$STAGE/source/backend/packages"
|
|
cp -a "$BACKEND_CONTEXT/tests" "$STAGE/source/backend/tests"
|
|
|
|
cp -a "$BACKEND_CONTEXT/config.yaml" "$STAGE/config/deerflow/config.base.yaml"
|
|
cp -a "$BACKEND_CONTEXT/extensions_config.json" "$STAGE/config/deerflow/extensions_config.json"
|
|
cp -a "$FRONTEND_CONTEXT/frontend-web/public/runtime-config.js" "$STAGE/config/frontend/runtime-config.base.js"
|
|
cp -a "$WORKSPACE/offline-backend-20260512/backend/skills/." "$STAGE/agents-skills/deerflow-skills/"
|
|
cp -a "$WORKSPACE/offline-backend-20260512/wheelhouse/." "$STAGE/source/wheelhouse/"
|
|
[[ -f "$STAGE/source/wheelhouse/aiomysql-0.3.2-py3-none-any.whl" ]] || {
|
|
echo "aiomysql wheel is missing from source/wheelhouse" >&2
|
|
exit 2
|
|
}
|
|
|
|
# 预置项目自带的命名智能体;32 位十六进制目录是用户运行数据,不迁移。
|
|
AGENT_ROOT="$WORKSPACE/offline-backend-20260512/backend/.deer-flow/agents"
|
|
if [[ -d "$AGENT_ROOT" ]]; then
|
|
while IFS= read -r -d '' agent_dir; do
|
|
agent_name="$(basename "$agent_dir")"
|
|
if [[ ! "$agent_name" =~ ^[0-9a-fA-F]{32}$ ]]; then
|
|
cp -a "$agent_dir" "$STAGE/agents-skills/deerflow-agents/"
|
|
fi
|
|
done < <(find "$AGENT_ROOT" -mindepth 1 -maxdepth 1 -type d -print0)
|
|
fi
|
|
|
|
cp -a "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/docs/." "$STAGE/docs/"
|
|
cp -a "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/README_OFFLINE.md" "$STAGE/README_OFFLINE.md"
|
|
printf '%s\n' '把需要预置的本地知识文件放在此目录;本包不导入旧部署业务数据。' \
|
|
> "$STAGE/agents-skills/deerflow-knowledge/README.txt"
|
|
find "$STAGE" -type d -name __pycache__ -prune -exec rm -rf -- {} +
|
|
|
|
echo "[2/7] Build current DeerFlow linux/amd64 images"
|
|
BACKEND_IMAGE="cmzs/deerflow-backend-offline:${STAMP}-${DEERFLOW_SHORT}"
|
|
FRONTEND_IMAGE="cmzs/deerflow-frontend-offline:${STAMP}-${DEERFLOW_SHORT}"
|
|
if [[ -n "${DEERFLOW_BACKEND_RUNTIME_BASE:-}" ]]; then
|
|
[[ -n "${DEERFLOW_BASE_SOURCE_ROOT:-}" ]] || {
|
|
echo "DEERFLOW_BASE_SOURCE_ROOT is required with DEERFLOW_BACKEND_RUNTIME_BASE" >&2
|
|
exit 2
|
|
}
|
|
DEERFLOW_BASE_SOURCE_ROOT="$(realpath "$DEERFLOW_BASE_SOURCE_ROOT")"
|
|
for manifest in pyproject.toml uv.lock requirements-offline.txt packages/harness/pyproject.toml; do
|
|
cmp -s "$BACKEND_CONTEXT/$manifest" "$DEERFLOW_BASE_SOURCE_ROOT/$manifest" || {
|
|
echo "dependency manifest differs from runtime base: $manifest" >&2
|
|
exit 2
|
|
}
|
|
done
|
|
docker image inspect "$DEERFLOW_BACKEND_RUNTIME_BASE" >/dev/null
|
|
docker build --platform linux/amd64 --network host --provenance=false \
|
|
--build-arg "BASE_IMAGE=$DEERFLOW_BACKEND_RUNTIME_BASE" \
|
|
-f "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/Dockerfile.backend-code-refresh" \
|
|
-t "$BACKEND_IMAGE" "$BACKEND_CONTEXT"
|
|
else
|
|
docker build --platform linux/amd64 --network host --provenance=false \
|
|
-f "$WORKSPACE/deploy/linux-docker-offline/Dockerfile.backend" \
|
|
-t "$BACKEND_IMAGE" "$BACKEND_CONTEXT"
|
|
fi
|
|
|
|
if [[ -n "${DEERFLOW_FRONTEND_RUNTIME_BASE:-}" ]]; then
|
|
docker image inspect "$DEERFLOW_FRONTEND_RUNTIME_BASE" >/dev/null
|
|
docker build --platform linux/amd64 --network host --provenance=false \
|
|
--build-arg "BASE_IMAGE=$DEERFLOW_FRONTEND_RUNTIME_BASE" \
|
|
-f "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/Dockerfile.frontend-runtime-config-refresh" \
|
|
-t "$FRONTEND_IMAGE" "$FRONTEND_CONTEXT"
|
|
else
|
|
docker build --platform linux/amd64 --network host --provenance=false \
|
|
-f "$WORKSPACE/deploy/linux-docker-offline/Dockerfile.frontend" \
|
|
-t "$FRONTEND_IMAGE" "$FRONTEND_CONTEXT"
|
|
fi
|
|
|
|
# 从刚构建的前端镜像取出与镜像完全一致的 dist,作为宿主机挂载初始内容。
|
|
FRONTEND_EXPORT_CONTAINER="$(docker create "$FRONTEND_IMAGE")"
|
|
cleanup_frontend_export() {
|
|
docker rm -f "$FRONTEND_EXPORT_CONTAINER" >/dev/null 2>&1 || true
|
|
}
|
|
trap cleanup_frontend_export EXIT
|
|
docker cp "$FRONTEND_EXPORT_CONTAINER:/usr/share/nginx/html/." \
|
|
"$STAGE/source/frontend/dist/"
|
|
docker rm "$FRONTEND_EXPORT_CONTAINER" >/dev/null
|
|
FRONTEND_EXPORT_CONTAINER=""
|
|
trap - EXIT
|
|
|
|
echo "[3/7] Validate image contents and architecture"
|
|
docker image inspect "$BACKEND_IMAGE" "$FRONTEND_IMAGE" >/dev/null
|
|
[[ "$(docker image inspect -f '{{.Architecture}}' "$BACKEND_IMAGE")" == "amd64" ]] || exit 2
|
|
[[ "$(docker image inspect -f '{{.Architecture}}' "$FRONTEND_IMAGE")" == "amd64" ]] || exit 2
|
|
docker run --rm --network none "$BACKEND_IMAGE" python -c \
|
|
'import app.gateway.app, deerflow; print("backend import smoke passed")'
|
|
docker run --rm --network none "$BACKEND_IMAGE" \
|
|
/opt/venv/bin/python /app/backend/scripts/office_runtime_selfcheck.py
|
|
|
|
echo "[4/7] Write version metadata"
|
|
cat > "$STAGE/images/deerflow-image-list.txt" <<EOF
|
|
$BACKEND_IMAGE
|
|
$FRONTEND_IMAGE
|
|
EOF
|
|
cat > "$STAGE/VERSION.json" <<EOF
|
|
{
|
|
"release": "$RELEASE_NAME",
|
|
"built_at": "$(date --iso-8601=seconds)",
|
|
"platform": "linux/amd64",
|
|
"deerflow_commit": "$DEERFLOW_COMMIT",
|
|
"deerflow_source_includes_worktree_changes": $SOURCE_DIRTY,
|
|
"packaging_revision": "compose-v1-office-v1-aiomysql-wheelhouse-multimodel-v1",
|
|
"office_runtime_included": true,
|
|
"weknora_included": false,
|
|
"images": {
|
|
"deerflow_backend": "$BACKEND_IMAGE",
|
|
"deerflow_frontend": "$FRONTEND_IMAGE"
|
|
}
|
|
}
|
|
EOF
|
|
|
|
# 固定源码覆盖允许继承的运行时依赖。后续 build-source-update.sh 只要发现
|
|
# 任一锁定文件变化就拒绝生成源码包,要求重新制作完整依赖基线。
|
|
python3 - "$WORKSPACE" "$STAGE/SOURCE_BASELINE.json" "$RELEASE_NAME" \
|
|
"$DEERFLOW_COMMIT" "$BACKEND_IMAGE" "$FRONTEND_IMAGE" <<'PY'
|
|
from __future__ import annotations
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
workspace = Path(sys.argv[1]).resolve()
|
|
output = Path(sys.argv[2])
|
|
locked_paths = (
|
|
"offline-backend-20260512/backend/pyproject.toml",
|
|
"offline-backend-20260512/backend/uv.lock",
|
|
"offline-backend-20260512/backend/requirements-offline.txt",
|
|
"offline-backend-20260512/backend/packages/harness/pyproject.toml",
|
|
"offline-backend-20260512/backend/Dockerfile.backend-offline",
|
|
"deploy/linux-docker-offline/Dockerfile.backend",
|
|
)
|
|
locked: dict[str, str] = {}
|
|
for relative in locked_paths:
|
|
path = workspace / relative
|
|
if not path.is_file():
|
|
raise SystemExit(f"runtime dependency lock input is missing: {relative}")
|
|
locked[relative] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
data = {
|
|
"format_version": 1,
|
|
"runtime_base_release": sys.argv[3],
|
|
"runtime_base_commit": sys.argv[4],
|
|
"backend_image": sys.argv[5],
|
|
"frontend_image": sys.argv[6],
|
|
"locked_files": locked,
|
|
}
|
|
output.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
PY
|
|
|
|
echo "[5/7] Export exactly two offline images"
|
|
docker save "$BACKEND_IMAGE" "$FRONTEND_IMAGE" | gzip -1 > \
|
|
"$STAGE/images/deerflow-images-linux-amd64.tar.gz"
|
|
gzip -t "$STAGE/images/deerflow-images-linux-amd64.tar.gz"
|
|
|
|
echo "[6/7] Generate checksums and run static release checks"
|
|
(
|
|
cd "$STAGE"
|
|
sha256sum images/deerflow-images-linux-amd64.tar.gz VERSION.json SOURCE_BASELINE.json > SHA256SUMS
|
|
)
|
|
chmod 0755 "$STAGE/OFFLINE_INSTALL.sh" "$STAGE/UPDATE_INSTALL.sh" \
|
|
"$STAGE/scripts/"*.py
|
|
find "$STAGE/data" -mindepth 1 -type f -delete
|
|
[[ "$(grep -cvE '^[[:space:]]*(#|$)' "$STAGE/images/deerflow-image-list.txt")" == "2" ]] || exit 2
|
|
python3 "$WORKSPACE/deploy/linux-docker-offline-deerflow-only/test_release_renderer.py" "$WORKSPACE"
|
|
# source/ 是供用户直接覆盖的 DeerFlow 项目源码,可能包含未启用的可选
|
|
# WeKnora 适配代码;纯部署校验只禁止实际服务、镜像和部署文件。
|
|
if find "$STAGE" -path "$STAGE/source" -prune -o \
|
|
\( -iname '*weknora*' -o -iname '*paradedb*' -o -iname '*docreader*' \) \
|
|
-print | grep -q .; then
|
|
echo "WeKnora-related deployment artifact found in pure DeerFlow stage" >&2
|
|
exit 2
|
|
fi
|
|
if grep -RIlE --exclude-dir=source \
|
|
'wechatopenai/weknora|paradedb/paradedb|redis:7\.0-alpine' "$STAGE" | grep -q .; then
|
|
echo "WeKnora image/service reference found in pure DeerFlow stage" >&2
|
|
exit 2
|
|
fi
|
|
|
|
echo "[7/7] Create final archive"
|
|
FINAL="$DIST/$RELEASE_NAME.tar.gz"
|
|
tar -C "$(dirname "$STAGE")" -cf - "$(basename "$STAGE")" | gzip -1 > "$FINAL"
|
|
gzip -t "$FINAL"
|
|
(
|
|
cd "$DIST"
|
|
sha256sum "$(basename "$FINAL")" > "$(basename "$FINAL").sha256"
|
|
)
|
|
printf 'FINAL=%s\n' "$FINAL"
|
|
printf 'SIZE_BYTES=%s\n' "$(stat -c %s "$FINAL")"
|
|
printf 'SHA256=%s\n' "$(sha256sum "$FINAL" | awk '{print $1}')"
|