- 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>
549 lines
20 KiB
Bash
Executable File
549 lines
20 KiB
Bash
Executable File
#!/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")"
|