- 第二轮真机构建实测:单次 virt-customize 在 playwright 步 失败,pip3 install --no-cache-dir playwright 在 Ubuntu 24.04 上报 PEP 668 externally-managed-environment 而拒绝安装; 随后 python3 -m playwright 报 No module named playwright。 此前 PLAYWRIGHT 默认 0,该步从未在默认构建里跑过,缺口 被默认开启暴露出来。 - 修复:playwright 的 pip 安装与第一处 pip 安装同款口径 (--break-system-packages,失败回落无 flag 再试),且只有 pip 成功才跑 python3 -m playwright install --with-deps chromium(括号分组 + &&,失败即停,不再以 ; 带病续跑)。 - 第二轮中间证据:B-10 步已过(sshd -t 完成、99-chv.conf 与 cloud-init hostkey 配置已写入、客机 /etc/ssh 无残留 host key;dpkg/pip freeze 已写 876/92 行)。
235 lines
13 KiB
Bash
Executable File
235 lines
13 KiB
Bash
Executable File
#!/bin/bash
|
||
# chv-guest 镜像构建脚本(补缺口轨:从 TODO 骨架改成真实步骤)。
|
||
#
|
||
# 用法(必须在带 /dev/kvm 的宿主上跑全量模式):
|
||
# CODEX_BIN_DIR=bin UBUNTU_VERSION=24.04 CODEX_VERSION=0.164.0-alpha.9 \
|
||
# REPO_COMMIT=$(git rev-parse --short HEAD) ./build.sh out/
|
||
# ./build.sh --dry-run out/ # 只打印命令,不执行、不要求 KVM
|
||
#
|
||
# 产物(out/ 内):
|
||
# vmlinux # virt-get-kernel 从云镜像取出的内核(CHV --kernel 指向它)
|
||
# disk.raw # qemu-img 转成的 raw 根文件系统镜像(CHV_ROOTFS 指向它)
|
||
# BUILDINFO.json # {tag, ubuntu, codex_version, repo_commit, built_at, contents}
|
||
# + versions {dpkg, pip}(构建时从客机读回的实际版本)+
|
||
# ubuntu_image_sha256_set(SHA256 必填,见下)
|
||
# 绝不进镜像的东西:任何模型 key、系统 key、用户账号数据、
|
||
# 生产凭证;模型 key 由客机 cloud-init 预置的运行环境变量名
|
||
# 注入,不写进镜像层。
|
||
# feat-chv-secfix(M4/M8):UBUNTU_IMAGE_SHA256 必填;
|
||
# sshd 加固由构建写入 /etc/ssh/sshd_config.d/99-chv.conf
|
||
# 并经 sshd -t + grep 断言(README 的“只允许 key 登录”
|
||
# 从此是构建断言,不再只是文档声称)。
|
||
# B-10:virt-customize 的 chroot 里 sshd -t 须先 ssh-keygen -A
|
||
# 临时生成 host key 并 mkdir -p /run/sshd,否则分别报无 host
|
||
# key / 缺 privilege separation 目录而失败;断言通过后立即删
|
||
# 临时 key,镜像仍不带 host key(per-VM 预置才生效)。
|
||
#
|
||
# 客机 host key 硬性要求(见 README §5,补缺口轨新增):
|
||
# 客机必须装 cloud-init 且支持 NoCloud seed,首启先应用
|
||
# seed 再启动 sshd,且不得无条件重新生成 host key,否则
|
||
# 执行器的 per-VM 预置钉死即失效。本脚本在镜像里写
|
||
# /etc/cloud/cloud.cfg.d/99-chv-hostkey.cfg(ssh_deletekeys:
|
||
# false、ssh_genkeytypes: []),把“不重生成”钉进镜像。
|
||
set -euo pipefail
|
||
|
||
DRY_RUN=0
|
||
if [ "${1:-}" = "--dry-run" ]; then
|
||
DRY_RUN=1
|
||
shift
|
||
fi
|
||
OUT="${1:-out}"
|
||
: "${CODEX_BIN_DIR:?CODEX_BIN_DIR 未设:需 npm 安装 @openai/codex 后的配对二进制目录}"
|
||
: "${UBUNTU_VERSION:?UBUNTU_VERSION 未设}"
|
||
: "${CODEX_VERSION:?CODEX_VERSION 未设}"
|
||
: "${REPO_COMMIT:?REPO_COMMIT 未设:构建时仓库 commit 短 hash}"
|
||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||
REPO_DIR="${REPO_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
||
# B-03/B-04:输入格式校验(干跑与全量都做,报错点提前,
|
||
# 不等到 curl 404 或 sha256sum -c 才失败)。
|
||
if ! printf '%s' "$UBUNTU_VERSION" | grep -Eq '^[0-9]+\.[0-9]+$'; then
|
||
echo "UBUNTU_VERSION 格式须为 NN.NN:$UBUNTU_VERSION" >&2; exit 1
|
||
fi
|
||
if ! printf '%s' "$REPO_COMMIT" | grep -Eq '^[0-9a-f]{7,40}$'; then
|
||
echo "REPO_COMMIT 须为 7-40 位 hex:$REPO_COMMIT" >&2; exit 1
|
||
fi
|
||
if ! printf '%s' "$CODEX_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+'; then
|
||
echo "CODEX_VERSION 格式异常:$CODEX_VERSION" >&2; exit 1
|
||
fi
|
||
if [ -n "${UBUNTU_IMAGE_SHA256:-}" ] && ! printf '%s' "$UBUNTU_IMAGE_SHA256" | grep -Eq '^[0-9a-f]{64}$'; then
|
||
echo "UBUNTU_IMAGE_SHA256 须为 64 位 hex" >&2; exit 1
|
||
fi
|
||
# B-09:版本声称须核对,不只进 tag。codex 二进制真实版本
|
||
# 须包含 CODEX_VERSION;REPO_COMMIT 须等于仓库 HEAD 短 hash;
|
||
# 工作树脏即拒绝构建(tag 会谎称干净 commit)。
|
||
CODEX_ACTUAL_VERSION="$("$CODEX_BIN_DIR/codex" --version 2>/dev/null || true)"
|
||
case "$CODEX_ACTUAL_VERSION" in
|
||
*"$CODEX_VERSION"*) ;;
|
||
*) echo "codex 二进制版本与 CODEX_VERSION 不符:got [$CODEX_ACTUAL_VERSION] want 含 [$CODEX_VERSION]" >&2; exit 1 ;;
|
||
esac
|
||
HEAD_SHORT="$(git -C "$REPO_DIR" rev-parse --short HEAD)"
|
||
if [ "$HEAD_SHORT" != "$REPO_COMMIT" ]; then
|
||
echo "REPO_COMMIT 与仓库 HEAD 不符:$REPO_COMMIT != $HEAD_SHORT" >&2; exit 1
|
||
fi
|
||
if [ -n "$(git -C "$REPO_DIR" status --porcelain)" ]; then
|
||
echo "仓库工作树有未提交修改,拒绝构建(tag 会谎称干净 commit)" >&2; exit 1
|
||
fi
|
||
TAG="chv-guest-${UBUNTU_VERSION}-${CODEX_VERSION}-${REPO_COMMIT}"
|
||
UBUNTU_IMAGE_URL="${UBUNTU_IMAGE_URL:-https://cloud-images.ubuntu.com/releases/${UBUNTU_VERSION}/release/ubuntu-${UBUNTU_VERSION}-server-cloudimg-amd64.img}"
|
||
|
||
run() {
|
||
if [ "$DRY_RUN" = "1" ]; then
|
||
echo "DRY-RUN: $*"
|
||
else
|
||
echo "+ $*"
|
||
"$@"
|
||
fi
|
||
}
|
||
|
||
# 参数校验(干跑与全量都做,不依赖 KVM)。
|
||
for bin in codex codex-code-mode-host; do
|
||
if [ ! -x "$CODEX_BIN_DIR/$bin" ]; then
|
||
echo "缺少配对二进制:$CODEX_BIN_DIR/$bin,构建失败" >&2
|
||
exit 1
|
||
fi
|
||
done
|
||
if [ ! -d "$CODEX_BIN_DIR/codex-resources" ]; then
|
||
echo "缺少 codex-resources(bwrap 载体),构建失败" >&2
|
||
exit 1
|
||
fi
|
||
if [ ! -f "$REPO_DIR/host.py" ] || [ ! -d "$REPO_DIR/cloud" ]; then
|
||
echo "REPO_DIR 不是 codex-app-server 仓库根(缺 host.py/cloud/):$REPO_DIR" >&2
|
||
exit 1
|
||
fi
|
||
if [ "$DRY_RUN" = "0" ]; then
|
||
if [ ! -e /dev/kvm ]; then
|
||
echo "CHV_GUEST_BUILD needs a KVM host: /dev/kvm 不存在" >&2
|
||
exit 1
|
||
fi
|
||
for tool in curl qemu-img virt-customize virt-get-kernel python3 virt-cat sgdisk guestfish; do
|
||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||
echo "缺少构建工具:$tool,构建失败" >&2
|
||
exit 1
|
||
fi
|
||
done
|
||
fi
|
||
|
||
run mkdir -p "$OUT"
|
||
# B-07:磁盘空间预检(仅全量模式;干跑不建目录,无法 df)。
|
||
if [ "$DRY_RUN" = "0" ]; then
|
||
avail_kb="$(df -k --output=avail "$OUT" | tail -1 | tr -d ' ')"
|
||
if [ "${avail_kb:-0}" -lt 20971520 ]; then
|
||
echo "磁盘不足:$OUT 所在盘可用 ${avail_kb}KB,需 ≥20GB" >&2; exit 1
|
||
fi
|
||
fi
|
||
IMG="$OUT/ubuntu-${UBUNTU_VERSION}-server-cloudimg-amd64.img"
|
||
# feat-chv-secfix(M4):云镜像 SHA256 改为必填(干跑只验
|
||
# “已设”,不验文件;全量模式下载后验哈希)。未设即失败,
|
||
# 不再只依赖 HTTPS 传输层作唯一锚。
|
||
if [ -z "${UBUNTU_IMAGE_SHA256:-}" ]; then
|
||
echo "UBUNTU_IMAGE_SHA256 未设:云镜像须以哈希锚定,构建失败" >&2
|
||
exit 1
|
||
fi
|
||
if [ "$DRY_RUN" = "1" ]; then
|
||
echo "DRY-RUN: curl -fL --retry 3 --retry-all-errors --connect-timeout 30 $UBUNTU_IMAGE_URL -o $IMG"
|
||
echo "DRY-RUN: [ -s $IMG ] || exit 1"
|
||
echo "DRY-RUN: sha256sum -c (UBUNTU_IMAGE_SHA256)"
|
||
else
|
||
if [ ! -s "$IMG" ]; then
|
||
curl -fL --retry 3 --retry-all-errors --connect-timeout 30 "$UBUNTU_IMAGE_URL" -o "$IMG"
|
||
fi
|
||
[ -s "$IMG" ] || { echo "下载的云镜像为空:$IMG" >&2; exit 1; }
|
||
# B-06:哈希不符时删文件重下一次,不直接退出让操作员手动清理。
|
||
if ! echo "$UBUNTU_IMAGE_SHA256 $IMG" | sha256sum -c -; then
|
||
echo "云镜像哈希不符,删除后重下一次:$IMG" >&2
|
||
rm -f "$IMG"
|
||
curl -fL --retry 3 --retry-all-errors --connect-timeout 30 "$UBUNTU_IMAGE_URL" -o "$IMG"
|
||
[ -s "$IMG" ] || { echo "重下的云镜像为空:$IMG" >&2; exit 1; }
|
||
echo "$UBUNTU_IMAGE_SHA256 $IMG" | sha256sum -c -
|
||
fi
|
||
fi
|
||
run qemu-img convert -f qcow2 -O raw "$IMG" "$OUT/disk.raw"
|
||
# B-16:云镜像根分区只有 ~2.4G,apt 全量清单(texlive +
|
||
# Playwright --with-deps + Chromium + CJK 字体)会写满根分区
|
||
# (No space left on device,首轮真机构建实测)。先扩盘:
|
||
# qemu-img resize 扩文件;sgdisk 把 1 号分区尾移到磁盘末尾
|
||
# (只动 1 号分区,14/15/16 不动);guestfish resize2fs 扩
|
||
# 文件系统。注意不能用 virt-resize:它会重写分区表、把根分区
|
||
# 从 1 号重编号为 4 号,而执行器默认 cmdline 是 root=/dev/vda1,
|
||
# 重编号后无法启动。根分区起始扇区从 sgdisk -i 1 实测取,不写死。
|
||
if [ "$DRY_RUN" = "0" ]; then
|
||
run qemu-img resize "$OUT/disk.raw" +12G
|
||
run sgdisk -e "$OUT/disk.raw"
|
||
ROOT_START="$(sgdisk -i 1 "$OUT/disk.raw" | awk '/^First sector:/ {print $3}')"
|
||
[ -n "$ROOT_START" ] || { echo "读不到根分区起始扇区" >&2; exit 1; }
|
||
run sgdisk -d 1 -n "1:${ROOT_START}:0" -t 1:8300 "$OUT/disk.raw"
|
||
run guestfish -a "$OUT/disk.raw" -m /dev/sda1 resize2fs /dev/sda1
|
||
else
|
||
echo "DRY-RUN: qemu-img resize $OUT/disk.raw +12G; sgdisk 扩 1 号分区; guestfish resize2fs /dev/sda1"
|
||
fi
|
||
# 清单软件(照抄 cloud/docker/Dockerfile 的 apt/pip 清单,
|
||
# 中文 LaTeX xelatex 故意不装,见 README;fonts-noto-cjk 只
|
||
# 是浏览器渲染用的中文字体,不是 LaTeX。Playwright 默认开启
|
||
# (PLAYWRIGHT=0 可显式关掉),浏览器常驻复用)。
|
||
# 单次 virt-customize:apt 安装、pip、拷入、sshd 加固、版本
|
||
# 读回都在同一次定制里按参数顺序跑;Playwright 装在第一次
|
||
# freeze 之后、最终 freeze 之前(B-08:最终 freeze 须在
|
||
# Playwright 安装之后,否则 BUILDINFO.versions.pip 里没有
|
||
# playwright;Chromium 版本以 ms-playwright 缓存目录名记录
|
||
# (如 chromium-1200),写进 /app/guest-versions-chromium.txt)。
|
||
VC_ARGS=(-a "$OUT/disk.raw"
|
||
--install git,pandoc,poppler-utils,latexmk,texlive-latex-base,openssh-server,cloud-init,qemu-guest-agent,python3,python3-pip,fonts-noto-cjk
|
||
--run-command 'pip3 install --no-cache-dir --break-system-packages python-docx openpyxl python-pptx PyMuPDF Pillow || pip3 install --no-cache-dir python-docx openpyxl python-pptx PyMuPDF Pillow'
|
||
--copy-in "$CODEX_BIN_DIR/codex:/usr/local/bin/"
|
||
--copy-in "$CODEX_BIN_DIR/codex-code-mode-host:/usr/local/bin/"
|
||
--copy-in "$CODEX_BIN_DIR/codex-resources:/usr/local/bin/"
|
||
--run-command 'chmod +x /usr/local/bin/codex /usr/local/bin/codex-code-mode-host; ln -sf /usr/local/bin/codex-resources/bwrap /usr/local/bin/bwrap'
|
||
--mkdir /app
|
||
--copy-in "$REPO_DIR/host.py:/app/"
|
||
--copy-in "$REPO_DIR/cloud:/app/"
|
||
--run-command 'printf "ssh_deletekeys: false\nssh_genkeytypes: []\n" > /etc/cloud/cloud.cfg.d/99-chv-hostkey.cfg; systemctl enable ssh || true'
|
||
--run-command 'printf "PasswordAuthentication no\nPermitRootLogin prohibit-password\nChallengeResponseAuthentication no\nKbdInteractiveAuthentication no\nPubkeyAuthentication yes\n" > /etc/ssh/sshd_config.d/99-chv.conf && mkdir -p /run/sshd && ssh-keygen -A && sshd -t && grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config.d/99-chv.conf && grep -q "^PermitRootLogin prohibit-password" /etc/ssh/sshd_config.d/99-chv.conf && rm -f /etc/ssh/ssh_host_* && test -z "$(ls /etc/ssh/ssh_host_* 2>/dev/null)"'
|
||
--run-command 'dpkg-query -W -f "${Package} ${Version}\n" > /app/guest-versions-dpkg.txt; pip3 freeze > /app/guest-versions-pip.txt || true')
|
||
if [ "${PLAYWRIGHT:-1}" = "1" ]; then
|
||
VC_ARGS+=(
|
||
--run-command '(pip3 install --no-cache-dir --break-system-packages playwright || pip3 install --no-cache-dir playwright) && python3 -m playwright install --with-deps chromium'
|
||
--run-command 'dpkg-query -W -f "${Package} ${Version}\n" > /app/guest-versions-dpkg.txt; pip3 freeze > /app/guest-versions-pip.txt || true'
|
||
--run-command 'ls /root/.cache/ms-playwright/ > /app/guest-versions-chromium.txt 2>/dev/null || true')
|
||
fi
|
||
run virt-customize "${VC_ARGS[@]}"
|
||
run virt-get-kernel -a "$OUT/disk.raw" -o "$OUT/vmlinux"
|
||
# feat-chv-secfix(M4):构建时把客机内实际装好的版本
|
||
# 读回 BUILDINFO.versions(dpkg + pip freeze),名字列表
|
||
# 之外再有逐项版本——可审计、可复现比对。事前 lockfile
|
||
# 钉死(apt/pip 精确版本预先锁定)仍待构建宿主生成,
|
||
# 见 README 与就绪清单 §11;本步先保证“建成什么版本
|
||
# 都有记录”,不再只有名字。
|
||
if [ "$DRY_RUN" = "1" ]; then
|
||
echo "DRY-RUN: virt-cat -a $OUT/disk.raw /app/guest-versions-dpkg.txt > $OUT/guest-versions-dpkg.txt"
|
||
echo "DRY-RUN: virt-cat -a $OUT/disk.raw /app/guest-versions-pip.txt > $OUT/guest-versions-pip.txt"
|
||
if [ "${PLAYWRIGHT:-1}" = "1" ]; then
|
||
echo "DRY-RUN: virt-cat -a $OUT/disk.raw /app/guest-versions-chromium.txt > $OUT/guest-versions-chromium.txt"
|
||
fi
|
||
else
|
||
virt-cat -a "$OUT/disk.raw" /app/guest-versions-dpkg.txt > "$OUT/guest-versions-dpkg.txt"
|
||
virt-cat -a "$OUT/disk.raw" /app/guest-versions-pip.txt > "$OUT/guest-versions-pip.txt"
|
||
if [ "${PLAYWRIGHT:-1}" = "1" ]; then
|
||
virt-cat -a "$OUT/disk.raw" /app/guest-versions-chromium.txt > "$OUT/guest-versions-chromium.txt" || true
|
||
fi
|
||
fi
|
||
# 产物校验(干跑只打印,不执行校验命令的副作用部分)。
|
||
if [ "$DRY_RUN" = "1" ]; then
|
||
echo "DRY-RUN: test -s $OUT/vmlinux && test -s $OUT/disk.raw"
|
||
echo "DRY-RUN: python3 $SCRIPT_DIR/buildinfo.py $OUT $TAG $UBUNTU_VERSION $CODEX_VERSION $REPO_COMMIT <sha256> ${PLAYWRIGHT:-1} # 生成 $OUT/BUILDINFO.json 并校验 tag=$TAG"
|
||
else
|
||
test -s "$OUT/vmlinux" || { echo "内核产物为空:$OUT/vmlinux" >&2; exit 1; }
|
||
test -s "$OUT/disk.raw" || { echo "磁盘产物为空:$OUT/disk.raw" >&2; exit 1; }
|
||
python3 "$SCRIPT_DIR/buildinfo.py" "$OUT" "$TAG" "$UBUNTU_VERSION" "$CODEX_VERSION" "$REPO_COMMIT" "$UBUNTU_IMAGE_SHA256" "${PLAYWRIGHT:-1}"
|
||
fi
|
||
echo "tag=$TAG"
|
||
echo "out=$OUT"
|
||
if [ "$DRY_RUN" = "1" ]; then
|
||
echo "干跑结束:未执行任何构建步骤,镜像未建成"
|
||
else
|
||
echo "构建完成:$OUT/vmlinux $OUT/disk.raw $OUT/BUILDINFO.json"
|
||
fi
|