#!/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 登录” # 从此是构建断言,不再只是文档声称)。 # # 客机 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 -eu 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)}" 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; do if ! command -v "$tool" >/dev/null 2>&1; then echo "缺少构建工具:$tool,构建失败" >&2 exit 1 fi done fi run mkdir -p "$OUT" 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 $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 "$UBUNTU_IMAGE_URL" -o "$IMG" fi [ -s "$IMG" ] || { echo "下载的云镜像为空:$IMG" >&2; exit 1; } echo "$UBUNTU_IMAGE_SHA256 $IMG" | sha256sum -c - fi run qemu-img convert -f qcow2 -O raw "$IMG" "$OUT/disk.raw" # 清单软件(照抄 cloud/docker/Dockerfile 的 apt/pip 清单, # 中文 LaTeX xelatex+CJK 故意不装,见 README;Playwright # 默认不装,PLAYWRIGHT=1 时才加装,属可选步骤)。 run virt-customize -a "$OUT/disk.raw" \ --install git,pandoc,poppler-utils,latexmk,texlive-latex-base,openssh-server,cloud-init,qemu-guest-agent,python3,python3-pip \ --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; 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' \ --run-command 'dpkg-query -W -f "${Package} ${Version}\n" > /app/guest-versions-dpkg.txt; pip3 freeze > /app/guest-versions-pip.txt || true' if [ "${PLAYWRIGHT:-0}" = "1" ]; then run virt-customize -a "$OUT/disk.raw" \ --run-command 'pip3 install --no-cache-dir playwright; python3 -m playwright install --with-deps chromium' fi 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" 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" fi # 产物校验(干跑只打印,不执行校验命令的副作用部分)。 if [ "$DRY_RUN" = "1" ]; then echo "DRY-RUN: test -s $OUT/vmlinux && test -s $OUT/disk.raw" echo "DRY-RUN: python3 生成 $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 - "$OUT/BUILDINFO.json" "$TAG" "$UBUNTU_VERSION" "$CODEX_VERSION" "$REPO_COMMIT" <<'PYEOF' import json, sys, time path, tag, ubuntu, codex, commit = sys.argv[1:6] def _read_lines(p): try: with open(p) as fh: return [l.strip() for l in fh if l.strip()] except OSError: return [] import os outdir = os.path.dirname(path) info = {"tag": tag, "ubuntu": ubuntu, "codex_version": codex, "repo_commit": commit, "built_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "contents": ["git", "pandoc", "poppler-utils", "latexmk", "texlive-latex-base", "openssh-server", "cloud-init", "qemu-guest-agent", "python-docx", "openpyxl", "python-pptx", "PyMuPDF", "Pillow", "codex", "codex-code-mode-host", "codex-resources/bwrap", "/app/host.py"], "versions": { "dpkg": _read_lines(os.path.join(outdir, "guest-versions-dpkg.txt")), "pip": _read_lines(os.path.join(outdir, "guest-versions-pip.txt")), }, "ubuntu_image_sha256_set": True} with open(path, "w") as fh: json.dump(info, fh, indent=2) PYEOF python3 -c "import json; d=json.load(open('$OUT/BUILDINFO.json')); assert d['tag'] == '$TAG', d; assert d['versions']['dpkg'], 'BUILDINFO versions.dpkg 为空'; assert d['versions']['pip'], 'BUILDINFO versions.pip 为空'" fi echo "tag=$TAG" echo "out=$OUT" if [ "$DRY_RUN" = "1" ]; then echo "干跑结束:未执行任何构建步骤,镜像未建成" else echo "构建完成:$OUT/vmlinux $OUT/disk.raw $OUT/BUILDINFO.json" fi