feat(chv-guest): 轻量镜像 + Gitea 双向同步(停全量版,改 Ubuntu Minimal)
- build.sh 改轻量:Ubuntu Minimal 底座、apt 精简(git/ssh/cloud-init/
python3/pip/curl/ca-certificates/xz-utils/fonts-wqy-microhei)、
Playwright + chromium-headless-shell、去 B-16 +12G 扩盘;
加 git + gt 1.0.0(官方 install.sh + SHA256 校验,GITEA_CLI_VERSION
钉 1.0.0)与 chv-sync 启动钩子(cloud-init runcmd)
- B-19(新发现):dpkg freeze 的 ${Package} 会被客机 shell 展开
成空,284 行全空白;加反斜杠转义,探针实测已出真实版本
- buildinfo.py:轻量 contents、chromium-headless-shell、gt_version
- generate/check_lockfile.py:gt/chv-sync 声明来源、headless-shell 映射
- chv-sync.sh(新):boot/pull/push/status,分叉/脏树失败关闭,
凭证走 seed 盘 guest-env,不进镜像
- README §1/§2/§3/§4 改轻量口径,新增 §7 Gitea 双向同步
- tests:ChvSyncTest 6 项(本地 bare 仓当远端);针对性测试 144 项全过
This commit is contained in:
1 parent
9e60d5dfaa
commit
4d09518338
7 files changed
+452
-73
No files matched your search
+54
-16
@@ -10,8 +10,10 @@
|
|||||||
|
|
||||||
## 1. 镜像定位
|
## 1. 镜像定位
|
||||||
|
|
||||||
- 基础:Ubuntu 云镜像(与 `cloud/docker/Dockerfile` 的任务镜像
|
- 基础:Ubuntu Minimal 24.04 官方云镜像(轻量口径,2026-11-06
|
||||||
对齐能力,不是另起一套环境口径)。
|
用户定:停掉 5.5G 全量版,不再对齐 Manus,不做三面板/
|
||||||
|
多租户/生产级全套;只装 codex + Python + SSH + cloud-init
|
||||||
|
+ 薄客机 agent + git + gt + 最小中文字体)。
|
||||||
- 载体:Cloud Hypervisor 直接内核启动(vmlinux)+ raw 磁盘
|
- 载体:Cloud Hypervisor 直接内核启动(vmlinux)+ raw 磁盘
|
||||||
镜像,执行器用 `CHV_KERNEL` / `CHV_ROOTFS` 指向它们。
|
镜像,执行器用 `CHV_KERNEL` / `CHV_ROOTFS` 指向它们。
|
||||||
- 客内 host.py 位置:`/app/host.py`(与 Docker 的 `/app:ro`
|
- 客内 host.py 位置:`/app/host.py`(与 Docker 的 `/app:ro`
|
||||||
@@ -21,20 +23,22 @@
|
|||||||
生产凭证。模型 key 由客机 cloud-init 预置的运行环境变量名
|
生产凭证。模型 key 由客机 cloud-init 预置的运行环境变量名
|
||||||
注入(与 Docker 的 `-e <名>` 同款惯例),不写进镜像层。
|
注入(与 Docker 的 `-e <名>` 同款惯例),不写进镜像层。
|
||||||
|
|
||||||
## 2. 内容清单(按现有 Docker 任务镜像能力对齐)
|
## 2. 内容清单(轻量口径,2026-11-06 用户定)
|
||||||
|
|
||||||
| 类别 | 内容 | 来源/版本口径 |
|
| 类别 | 内容 | 来源/版本口径 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 语言与工具链 | Python 3.12、git(Node.js 不装:Playwright 驱动是 Python playwright,见 PLAYWRIGHT=1 分支;任务镜像 Dockerfile 里也没有 Node.js,本清单不虚构它) | 与任务镜像同款大版本,构建时钉小版本并记入构建日志 |
|
| 语言与工具链 | Python 3.12、git、curl、ca-certificates、xz-utils(Node.js 不装:Playwright 驱动是 Python playwright,见 PLAYWRIGHT=1 分支;任务镜像 Dockerfile 里也没有 Node.js,本清单不虚构它) | 构建时钉小版本并记入构建日志 |
|
||||||
| 文档/图片工具 | pandoc、poppler-utils(pdftotext)、latexmk、texlive-latex-base、python-docx、openpyxl、python-pptx、PyMuPDF、Pillow | 逐项照抄 `cloud/docker/Dockerfile` 的 apt/pip 清单,不擅自加减 |
|
| 文档/图片工具 | 已裁剪:pandoc、poppler-utils、latexmk、texlive-latex-base、python-docx、openpyxl、python-pptx、PyMuPDF、Pillow 不在轻量镜像内 | 全量版曾照抄 `cloud/docker/Dockerfile` 的 apt/pip 清单,轻量版按用户决定砍掉 |
|
||||||
| agent 引擎 | codex、codex-code-mode-host、codex-resources/bwrap(成套,约 367M,缺 sibling 直接构建失败) | codex 0.164.0-alpha.9(与生产任务镜像一致);`CODEX_BIN_DIR` 由构建方提供,bin/ 不进仓库 |
|
| agent 引擎 | codex、codex-code-mode-host、codex-resources/bwrap(成套,约 367M,缺 sibling 直接构建失败) | codex 0.164.0-alpha.9(与生产任务镜像一致);`CODEX_BIN_DIR` 由构建方提供,bin/ 不进仓库 |
|
||||||
| 浏览器 | Playwright + Chromium(浏览器自动化 MCP 用,PLAYWRIGHT=1 时才装) | Python playwright 装当时的 Chromium,未在 BUILDINFO 里钉浏览器版本——这是已知缺口,不是已钉死 |
|
| 浏览器 | Playwright + chromium-headless-shell(默认装,不要完整 Chromium) | Python playwright 装当时的 headless-shell,版本以 ms-playwright 缓存目录名记入 BUILDINFO.versions.chromium |
|
||||||
| 客机基础 | openssh-server(SSH 通道用)、cloud-init、qemu-guest-agent(可选,宿主信息用) | sshd 只允许 key 登录,禁止密码登录——build.sh 写入 /etc/ssh/sshd_config.d/99-chv.conf 并经 sshd -t + grep 断言(feat-chv-secfix,M8,此前只是文档声称) |
|
| 客机基础 | openssh-server(SSH 通道用)、cloud-init(qemu-guest-agent 已裁剪,CHV 不用它) | sshd 只允许 key 登录,禁止密码登录——build.sh 写入 /etc/ssh/sshd_config.d/99-chv.conf 并经 sshd -t + grep 断言(feat-chv-secfix,M8,此前只是文档声称) |
|
||||||
|
| Gitea 同步 | gt 1.0.0(官方 install.sh + SHA256 校验)、chv-sync(启动时 clone、显式 pull/push 的同步脚本) | 凭证走 seed 盘 /etc/chv/guest-env(0600)投递,不进镜像;见 §7 |
|
||||||
| 任务代码 | codex-app-server 仓库代码置于 `/app`(host.py 入口) | 版本见 §3,不挂载宿主代码目录(VM 与宿主无共享路径) |
|
| 任务代码 | codex-app-server 仓库代码置于 `/app`(host.py 入口) | 版本见 §3,不挂载宿主代码目录(VM 与宿主无共享路径) |
|
||||||
|
|
||||||
中文 LaTeX 说明:与 Docker 镜像同款口径——xelatex + CJK
|
中文 LaTeX 说明:与 Docker 镜像同款口径——xelatex + CJK
|
||||||
字体会再增数百 MB,本清单不默认包含;需要时单独一版镜像,
|
字体会再增数百 MB,本清单不默认包含;需要时单独一版镜像,
|
||||||
不在本轨悄悄加。
|
不在本轨悄悄加。字体只留最小的 fonts-wqy-microhei(中文能
|
||||||
|
看),不要全套 fonts-noto-cjk。
|
||||||
|
|
||||||
## 3. 版本化
|
## 3. 版本化
|
||||||
|
|
||||||
@@ -52,21 +56,24 @@ chv-guest-<ubuntu 版本>-<codex 版本>-<仓库 commit 短 hash>
|
|||||||
- `vmlinux`(与 Ubuntu 云镜像配套的内核)
|
- `vmlinux`(与 Ubuntu 云镜像配套的内核)
|
||||||
- `disk.raw`(根文件系统镜像)
|
- `disk.raw`(根文件系统镜像)
|
||||||
- `BUILDINFO.json`:`{ubuntu, codex_version, repo_commit, built_at,
|
- `BUILDINFO.json`:`{ubuntu, codex_version, repo_commit, built_at,
|
||||||
contents: [清单逐项名]}`——注意:build.sh 生成的 contents 只有
|
contents: [清单逐项名]}`——contents 只有名字列表;逐项版本
|
||||||
名字列表,没有逐项版本号(feat-chv-fix 纠正 §3 旧措辞);
|
在 `versions` 里(B-02:dpkg + pip freeze 从客机读回,
|
||||||
逐项版本只留在构建日志里,未进 BUILDINFO,这是已知缺口。
|
chromium 以 ms-playwright 缓存目录名记,gt 以 gt_version 记),
|
||||||
|
`generate_lockfile.py` / `check_lockfile.py` 以它为唯一来源。
|
||||||
|
|
||||||
## 4. 构建脚本(补缺口轨:已从骨架改成真实步骤)
|
## 4. 构建脚本(补缺口轨:已从骨架改成真实步骤)
|
||||||
|
|
||||||
见 `build.sh`:参数校验 → 下载 Ubuntu 云镜像 → qemu-img
|
见 `build.sh`:参数校验 → 下载 Ubuntu Minimal 云镜像(SHA256
|
||||||
转 raw → virt-customize 装 §2 清单、拷入 codex 成套二进制
|
必填)→ qemu-img 转 raw(轻量版不扩盘,底座根分区 2.4G 够用;
|
||||||
与仓库代码到 /app、写 host key 不重生成配置 →
|
全量版 B-16 的 +12G 扩盘已删除)→ virt-customize 装 §2 清单、
|
||||||
|
拷入 codex 成套二进制与仓库代码到 /app、装 gt 1.0.0、拷入
|
||||||
|
chv-sync 并写 cloud-init 启动钩子、写 host key 不重生成配置 →
|
||||||
virt-get-kernel 取内核 → 生成并校验 BUILDINFO.json。
|
virt-get-kernel 取内核 → 生成并校验 BUILDINFO.json。
|
||||||
`./build.sh --dry-run out/` 只打印命令、不执行、不要求
|
`./build.sh --dry-run out/` 只打印命令、不执行、不要求
|
||||||
KVM;全量模式在无 /dev/kvm 的机器上第一步就明确退出
|
KVM;全量模式在无 /dev/kvm 的机器上第一步就明确退出
|
||||||
(`CHV_GUEST_BUILD needs a KVM host`),绝不假装构建成功。
|
(`CHV_GUEST_BUILD needs a KVM host`),绝不假装构建成功。
|
||||||
本轨未在真机上跑过全量模式,镜像未建成、未发布,仍属
|
全量版曾在 WSL KVM 宿主(ajpkmq6i)上跑过四轮真机构建,
|
||||||
“需 KVM 宿主实测”项。
|
轻量版沿用同一宿主与流程,构建记录见 `~/workspace/chv-prod-audit/`。
|
||||||
|
|
||||||
## 5. Host key 首启注入硬性要求(补缺口轨新增)
|
## 5. Host key 首启注入硬性要求(补缺口轨新增)
|
||||||
|
|
||||||
@@ -99,3 +106,34 @@ KVM;全量模式在无 /dev/kvm 的机器上第一步就明确退出
|
|||||||
磁盘,任务之间文件系统独立;reuse 模式仅单租户实验,preflight 拦截(须同时设 CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1 并审计放行,feat-chv-fix)。
|
磁盘,任务之间文件系统独立;reuse 模式仅单租户实验,preflight 拦截(须同时设 CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1 并审计放行,feat-chv-fix)。
|
||||||
- 快照基准:`CHV_SNAPSHOT_URL` 指向的快照必须由本镜像的干净
|
- 快照基准:`CHV_SNAPSHOT_URL` 指向的快照必须由本镜像的干净
|
||||||
启动状态制成(pause 后 snapshot),预热池用它做干净 VM 来源。
|
启动状态制成(pause 后 snapshot),预热池用它做干净 VM 来源。
|
||||||
|
|
||||||
|
## 7. Gitea 双向同步(用户工作区口径,2026-11-06 追加)
|
||||||
|
|
||||||
|
主角是用户,不是 agent:用户在 Gitea 上的仓库在 VM 启动时
|
||||||
|
clone 到 `~/workspace`,用完再同步回 Gitea;VM 是用户的工作
|
||||||
|
区,Gitea 是权威远端。同步口径沿用 Git Workspace Platform
|
||||||
|
已定的规则:分叉或冲突时失败关闭,不强制覆盖、不自动
|
||||||
|
rebase、不静默丢提交。
|
||||||
|
|
||||||
|
- 凭证:seed 盘投递 `/etc/chv/guest-env`(0600,root 所有),
|
||||||
|
含 `GITEA_URL` / `GITEA_USER` / `GITEA_TOKEN` / `SYNC_REPO`
|
||||||
|
/ 可选 `SYNC_BRANCH` / 可选 `GITEA_EMAIL`。token 绝不进镜像、
|
||||||
|
代码、日志;`chv-sync boot`(root,cloud-init runcmd 调)把
|
||||||
|
token 写进 `/home/chv/.git-credentials`(0600,git credential
|
||||||
|
store 标准机制),再以 chv 身份 clone;`pull` / `push` /
|
||||||
|
`status` 以 chv 身份跑,只用已落盘的 credential,不再读
|
||||||
|
guest-env。无 guest-env 的启动静默不同步,VM 照常用。
|
||||||
|
- 行为:`boot` 只在未 clone 时 clone,已 clone 则静默退出,
|
||||||
|
不自动 pull(避免重启时覆盖进行中的工作);`pull` 要求
|
||||||
|
工作树干净且只接受 fast-forward;`push` 无 upstream 时
|
||||||
|
`push -u origin HEAD`,否则普通 push,非 fast-forward 即
|
||||||
|
失败,绝不 `--force`。
|
||||||
|
- gt 1.0.0 随镜像装好(官方 install.sh + SHA256 校验),登录
|
||||||
|
不 baked:运行时用 `GITEA_TOKEN` + `GITEA_CLI_HOST` 环境变量
|
||||||
|
即可用(`gt repo list` 等),或在 VM 里 `gt auth login`。
|
||||||
|
- 验收(用户视角):VM 启动后仓在 `~/workspace` 且是最新
|
||||||
|
HEAD;VM 内改文件 commit push 后 Gitea 网页可见该提交;
|
||||||
|
Gitea 上改一次后 VM 内 pull 能同步回来。双向都通才算集成通。
|
||||||
|
- 残留:执行器生成 seed 时自动写 guest-env 的集成未做(本轨
|
||||||
|
验收用手工 seed 测通链路);专用 token 与测试仓名须用户给,
|
||||||
|
本轨不自编 token、不动生产 Gitea 配置。
|
||||||
+89
-41
@@ -1,16 +1,39 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# chv-guest 镜像构建脚本(补缺口轨:从 TODO 骨架改成真实步骤)。
|
# chv-guest 轻量镜像构建脚本(2026-11-06 用户定:停掉 5.5G 全量版,
|
||||||
|
# 按轻量口径重开;不再对齐 Manus,不做三面板/多租户/生产级全套)。
|
||||||
|
#
|
||||||
|
# 底座:Ubuntu Minimal 24.04 官方 qcow2(248MB,
|
||||||
|
# https://cloud-images.ubuntu.com/minimal/releases/noble/release/
|
||||||
|
# ubuntu-24.04-minimal-cloudimg-amd64.img——minimal 发布在 noble
|
||||||
|
# 代号目录下,不是 releases/24.04)。只装 codex 0.164.0-alpha.9
|
||||||
|
# linux-x64 + Python + SSH + cloud-init + 一个薄客机 agent
|
||||||
|
# (/app/host.py + /app/cloud)。浏览器:Playwright +
|
||||||
|
# chromium-headless-shell(不要完整 Chromium);字体只要最小的
|
||||||
|
# fonts-wqy-microhei(中文能看),不要全套 fonts-noto-cjk。
|
||||||
|
# 目标:qcow2 实际占用 ≤1GB,raw ≤2GB(见下注)。
|
||||||
|
# 控制面只留最小的:现有 cloud/chv/executor.py 起停 VM + SSH
|
||||||
|
# 进去调 codex + Playwright 截图。
|
||||||
|
#
|
||||||
|
# Gitea 双向同步(用户工作区口径,2026-11-06 追加):镜像里加
|
||||||
|
# git + gt 1.0.0(官方 install.sh + SHA256 校验,跟 170/172
|
||||||
|
# 同款),配好 git user 与 credential helper 占位,不含真实
|
||||||
|
# token;客机启动时 /usr/local/bin/chv-sync boot 按 seed 盘
|
||||||
|
# /etc/chv/guest-env(0600)把用户选定的仓 clone 到
|
||||||
|
# ~/workspace,后续 pull/push 由用户显式同步,分叉/冲突时
|
||||||
|
# 失败关闭(不 --force、不自动 rebase)。token 绝不进镜像、
|
||||||
|
# 代码、日志。
|
||||||
#
|
#
|
||||||
# 用法(必须在带 /dev/kvm 的宿主上跑全量模式):
|
# 用法(必须在带 /dev/kvm 的宿主上跑全量模式):
|
||||||
# CODEX_BIN_DIR=bin UBUNTU_VERSION=24.04 CODEX_VERSION=0.164.0-alpha.9 \
|
# 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/
|
# REPO_COMMIT=$(git rev-parse --short HEAD) \
|
||||||
|
# UBUNTU_IMAGE_SHA256=<minimal 镜像 sha256> ./build.sh out/
|
||||||
# ./build.sh --dry-run out/ # 只打印命令,不执行、不要求 KVM
|
# ./build.sh --dry-run out/ # 只打印命令,不执行、不要求 KVM
|
||||||
#
|
#
|
||||||
# 产物(out/ 内):
|
# 产物(out/ 内):
|
||||||
# vmlinux # virt-get-kernel 从云镜像取出的内核(CHV --kernel 指向它)
|
# vmlinux # virt-get-kernel 从云镜像取出的内核(CHV --kernel 指向它)
|
||||||
# disk.raw # qemu-img 转成的 raw 根文件系统镜像(CHV_ROOTFS 指向它)
|
# disk.raw # qemu-img 转成的 raw 根文件系统镜像(CHV_ROOTFS 指向它)
|
||||||
# BUILDINFO.json # {tag, ubuntu, codex_version, repo_commit, built_at, contents}
|
# BUILDINFO.json # {tag, ubuntu, codex_version, repo_commit, built_at, contents}
|
||||||
# + versions {dpkg, pip}(构建时从客机读回的实际版本)+
|
# + versions {dpkg, pip, chromium, gt}(构建时从客机读回的实际版本)+
|
||||||
# ubuntu_image_sha256_set(SHA256 必填,见下)
|
# ubuntu_image_sha256_set(SHA256 必填,见下)
|
||||||
# 绝不进镜像的东西:任何模型 key、系统 key、用户账号数据、
|
# 绝不进镜像的东西:任何模型 key、系统 key、用户账号数据、
|
||||||
# 生产凭证;模型 key 由客机 cloud-init 预置的运行环境变量名
|
# 生产凭证;模型 key 由客机 cloud-init 预置的运行环境变量名
|
||||||
@@ -30,6 +53,13 @@
|
|||||||
# 执行器的 per-VM 预置钉死即失效。本脚本在镜像里写
|
# 执行器的 per-VM 预置钉死即失效。本脚本在镜像里写
|
||||||
# /etc/cloud/cloud.cfg.d/99-chv-hostkey.cfg(ssh_deletekeys:
|
# /etc/cloud/cloud.cfg.d/99-chv-hostkey.cfg(ssh_deletekeys:
|
||||||
# false、ssh_genkeytypes: []),把“不重生成”钉进镜像。
|
# false、ssh_genkeytypes: []),把“不重生成”钉进镜像。
|
||||||
|
#
|
||||||
|
# 注:Minimal 底座虚拟盘 3.5G(根分区 2.4G,实测 1.9G 空闲),
|
||||||
|
# qemu-img convert 出的 disk.raw 表观 3.5G、实际占用约 0.6G
|
||||||
|
# (稀疏)。raw 表观 ≤2G 须在构建后另做一步收窄(guestfish
|
||||||
|
# 缩根文件系统 + sgdisk 重建 1 号分区 + qemu-img resize
|
||||||
|
# --shrink),不在本脚本内做——收窄前先确认装完后根分区实际
|
||||||
|
# 占用,避免把镜像收到无法启动;qcow2 压缩产物是首要验收项。
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
DRY_RUN=0
|
DRY_RUN=0
|
||||||
@@ -44,6 +74,12 @@ OUT="${1:-out}"
|
|||||||
: "${REPO_COMMIT:?REPO_COMMIT 未设:构建时仓库 commit 短 hash}"
|
: "${REPO_COMMIT:?REPO_COMMIT 未设:构建时仓库 commit 短 hash}"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
REPO_DIR="${REPO_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
REPO_DIR="${REPO_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
||||||
|
# gt 1.0.0:官方 install.sh(gitea.com/gitea/cli,main 分支
|
||||||
|
# scripts/install.sh),脚本本身下载 dl.gitea.com 的二进制并
|
||||||
|
# 验 SHA256;此处再验脚本自身的 SHA256(2026-11-06 实测值,
|
||||||
|
# 上游轮换脚本后构建会明确失败,不静默跑新脚本)。
|
||||||
|
GT_VERSION="${GT_VERSION:-1.0.0}"
|
||||||
|
GT_INSTALL_SH_SHA256="${GT_INSTALL_SH_SHA256:-cf82acecc0e3ffa6f690d33902eefcf1cc21a5b11004103bf23c4436664288d5}"
|
||||||
# B-03/B-04:输入格式校验(干跑与全量都做,报错点提前,
|
# B-03/B-04:输入格式校验(干跑与全量都做,报错点提前,
|
||||||
# 不等到 curl 404 或 sha256sum -c 才失败)。
|
# 不等到 curl 404 或 sha256sum -c 才失败)。
|
||||||
if ! printf '%s' "$UBUNTU_VERSION" | grep -Eq '^[0-9]+\.[0-9]+$'; then
|
if ! printf '%s' "$UBUNTU_VERSION" | grep -Eq '^[0-9]+\.[0-9]+$'; then
|
||||||
@@ -55,6 +91,12 @@ fi
|
|||||||
if ! printf '%s' "$CODEX_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+'; then
|
if ! printf '%s' "$CODEX_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+'; then
|
||||||
echo "CODEX_VERSION 格式异常:$CODEX_VERSION" >&2; exit 1
|
echo "CODEX_VERSION 格式异常:$CODEX_VERSION" >&2; exit 1
|
||||||
fi
|
fi
|
||||||
|
if ! printf '%s' "$GT_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo "GT_VERSION 格式异常:$GT_VERSION" >&2; exit 1
|
||||||
|
fi
|
||||||
|
if ! printf '%s' "$GT_INSTALL_SH_SHA256" | grep -Eq '^[0-9a-f]{64}$'; then
|
||||||
|
echo "GT_INSTALL_SH_SHA256 须为 64 位 hex" >&2; exit 1
|
||||||
|
fi
|
||||||
if [ -n "${UBUNTU_IMAGE_SHA256:-}" ] && ! printf '%s' "$UBUNTU_IMAGE_SHA256" | grep -Eq '^[0-9a-f]{64}$'; then
|
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
|
echo "UBUNTU_IMAGE_SHA256 须为 64 位 hex" >&2; exit 1
|
||||||
fi
|
fi
|
||||||
@@ -74,7 +116,7 @@ if [ -n "$(git -C "$REPO_DIR" status --porcelain)" ]; then
|
|||||||
echo "仓库工作树有未提交修改,拒绝构建(tag 会谎称干净 commit)" >&2; exit 1
|
echo "仓库工作树有未提交修改,拒绝构建(tag 会谎称干净 commit)" >&2; exit 1
|
||||||
fi
|
fi
|
||||||
TAG="chv-guest-${UBUNTU_VERSION}-${CODEX_VERSION}-${REPO_COMMIT}"
|
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}"
|
UBUNTU_IMAGE_URL="${UBUNTU_IMAGE_URL:-https://cloud-images.ubuntu.com/minimal/releases/noble/release/ubuntu-${UBUNTU_VERSION}-minimal-cloudimg-amd64.img}"
|
||||||
|
|
||||||
run() {
|
run() {
|
||||||
if [ "$DRY_RUN" = "1" ]; then
|
if [ "$DRY_RUN" = "1" ]; then
|
||||||
@@ -100,12 +142,16 @@ if [ ! -f "$REPO_DIR/host.py" ] || [ ! -d "$REPO_DIR/cloud" ]; then
|
|||||||
echo "REPO_DIR 不是 codex-app-server 仓库根(缺 host.py/cloud/):$REPO_DIR" >&2
|
echo "REPO_DIR 不是 codex-app-server 仓库根(缺 host.py/cloud/):$REPO_DIR" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [ ! -f "$SCRIPT_DIR/chv-sync.sh" ]; then
|
||||||
|
echo "缺少 chv-sync.sh(Gitea 双向同步脚本):$SCRIPT_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
if [ "$DRY_RUN" = "0" ]; then
|
if [ "$DRY_RUN" = "0" ]; then
|
||||||
if [ ! -e /dev/kvm ]; then
|
if [ ! -e /dev/kvm ]; then
|
||||||
echo "CHV_GUEST_BUILD needs a KVM host: /dev/kvm 不存在" >&2
|
echo "CHV_GUEST_BUILD needs a KVM host: /dev/kvm 不存在" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
for tool in curl qemu-img virt-customize virt-get-kernel python3 virt-cat sgdisk guestfish; do
|
for tool in curl qemu-img virt-customize virt-get-kernel python3 virt-cat; do
|
||||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||||
echo "缺少构建工具:$tool,构建失败" >&2
|
echo "缺少构建工具:$tool,构建失败" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -115,13 +161,15 @@ fi
|
|||||||
|
|
||||||
run mkdir -p "$OUT"
|
run mkdir -p "$OUT"
|
||||||
# B-07:磁盘空间预检(仅全量模式;干跑不建目录,无法 df)。
|
# B-07:磁盘空间预检(仅全量模式;干跑不建目录,无法 df)。
|
||||||
|
# 轻量版实际占用约 2G(底座 248M + disk.raw 稀疏约 0.6G +
|
||||||
|
# 内核约 46M + 构建余量),阈值从全量版 20G 降到 8G。
|
||||||
if [ "$DRY_RUN" = "0" ]; then
|
if [ "$DRY_RUN" = "0" ]; then
|
||||||
avail_kb="$(df -k --output=avail "$OUT" | tail -1 | tr -d ' ')"
|
avail_kb="$(df -k --output=avail "$OUT" | tail -1 | tr -d ' ')"
|
||||||
if [ "${avail_kb:-0}" -lt 20971520 ]; then
|
if [ "${avail_kb:-0}" -lt 8388608 ]; then
|
||||||
echo "磁盘不足:$OUT 所在盘可用 ${avail_kb}KB,需 ≥20GB" >&2; exit 1
|
echo "磁盘不足:$OUT 所在盘可用 ${avail_kb}KB,需 ≥8GB" >&2; exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
IMG="$OUT/ubuntu-${UBUNTU_VERSION}-server-cloudimg-amd64.img"
|
IMG="$OUT/ubuntu-${UBUNTU_VERSION}-minimal-cloudimg-amd64.img"
|
||||||
# feat-chv-secfix(M4):云镜像 SHA256 改为必填(干跑只验
|
# feat-chv-secfix(M4):云镜像 SHA256 改为必填(干跑只验
|
||||||
# “已设”,不验文件;全量模式下载后验哈希)。未设即失败,
|
# “已设”,不验文件;全量模式下载后验哈希)。未设即失败,
|
||||||
# 不再只依赖 HTTPS 传输层作唯一锚。
|
# 不再只依赖 HTTPS 传输层作唯一锚。
|
||||||
@@ -148,37 +196,29 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
run qemu-img convert -f qcow2 -O raw "$IMG" "$OUT/disk.raw"
|
run qemu-img convert -f qcow2 -O raw "$IMG" "$OUT/disk.raw"
|
||||||
# B-16:云镜像根分区只有 ~2.4G,apt 全量清单(texlive +
|
# 轻量版不扩盘:Minimal 底座根分区 2.4G、实测 1.9G 空闲,
|
||||||
# Playwright --with-deps + Chromium + CJK 字体)会写满根分区
|
# 轻量清单(见下)装完仍有余量;全量版 B-16 的 +12G 扩盘
|
||||||
# (No space left on device,首轮真机构建实测)。先扩盘:
|
# 已删除——扩盘会把 raw 表观顶到 16G,与 raw ≤2G 目标直接冲突。
|
||||||
# qemu-img resize 扩文件;sgdisk 把 1 号分区尾移到磁盘末尾
|
# 清单软件(轻量口径,2026-11-06 用户定):
|
||||||
# (只动 1 号分区,14/15/16 不动);guestfish resize2fs 扩
|
# git、openssh-server、cloud-init、python3、python3-pip、
|
||||||
# 文件系统。注意不能用 virt-resize:它会重写分区表、把根分区
|
# curl、ca-certificates、xz-utils、fonts-wqy-microhei(最小
|
||||||
# 从 1 号重编号为 4 号,而执行器默认 cmdline 是 root=/dev/vda1,
|
# 中文字体,中文能看;不要全套 fonts-noto-cjk)。
|
||||||
# 重编号后无法启动。根分区起始扇区从 sgdisk -i 1 实测取,不写死。
|
# 已裁剪:pandoc、poppler-utils、latexmk、texlive-latex-base、
|
||||||
if [ "$DRY_RUN" = "0" ]; then
|
# python-docx、openpyxl、python-pptx、PyMuPDF、Pillow、
|
||||||
run qemu-img resize "$OUT/disk.raw" +12G
|
# qemu-guest-agent(CHV 不用它;薄客机 agent 是 /app/host.py)。
|
||||||
run sgdisk -e "$OUT/disk.raw"
|
# 中文 LaTeX(xelatex)仍不装。
|
||||||
ROOT_START="$(sgdisk -i 1 "$OUT/disk.raw" | awk '/^First sector:/ {print $3}')"
|
# Playwright 默认开启(PLAYWRIGHT=0 可显式关掉),浏览器只装
|
||||||
[ -n "$ROOT_START" ] || { echo "读不到根分区起始扇区" >&2; exit 1; }
|
# chromium-headless-shell(不要完整 Chromium)。
|
||||||
run sgdisk -d 1 -n "1:${ROOT_START}:0" -t 1:8300 "$OUT/disk.raw"
|
# 单次 virt-customize:apt 安装、pip、拷入、sshd 加固、git 占位、
|
||||||
run guestfish -a "$OUT/disk.raw" -m /dev/sda1 resize2fs /dev/sda1
|
# gt 安装、chv-sync、版本读回都在同一次定制里按参数顺序跑;
|
||||||
else
|
# Playwright 装在第一次 freeze 之后、最终 freeze 之前(B-08:
|
||||||
echo "DRY-RUN: qemu-img resize $OUT/disk.raw +12G; sgdisk 扩 1 号分区; guestfish resize2fs /dev/sda1"
|
# 最终 freeze 须在 Playwright 安装之后,否则
|
||||||
fi
|
# BUILDINFO.versions.pip 里没有 playwright;Chromium 版本以
|
||||||
# 清单软件(照抄 cloud/docker/Dockerfile 的 apt/pip 清单,
|
# ms-playwright 缓存目录名记录(如 chromium_headless_shell-1181),
|
||||||
# 中文 LaTeX xelatex 故意不装,见 README;fonts-noto-cjk 只
|
# 写进 /app/guest-versions-chromium.txt)。
|
||||||
# 是浏览器渲染用的中文字体,不是 LaTeX。Playwright 默认开启
|
GT_INSTALL_CMD="curl -fsSL -o /tmp/gt-install.sh https://gitea.com/gitea/cli/raw/branch/main/scripts/install.sh && echo \"${GT_INSTALL_SH_SHA256} /tmp/gt-install.sh\" | sha256sum -c - && GITEA_CLI_VERSION=${GT_VERSION} GITEA_CLI_INSTALL_DIR=/usr/local/bin sh /tmp/gt-install.sh && rm -f /tmp/gt-install.sh && gt version"
|
||||||
# (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"
|
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
|
--install git,openssh-server,cloud-init,python3,python3-pip,curl,ca-certificates,xz-utils,fonts-wqy-microhei
|
||||||
--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:/usr/local/bin/"
|
||||||
--copy-in "$CODEX_BIN_DIR/codex-code-mode-host:/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/"
|
--copy-in "$CODEX_BIN_DIR/codex-resources:/usr/local/bin/"
|
||||||
@@ -186,13 +226,19 @@ VC_ARGS=(-a "$OUT/disk.raw"
|
|||||||
--mkdir /app
|
--mkdir /app
|
||||||
--copy-in "$REPO_DIR/host.py:/app/"
|
--copy-in "$REPO_DIR/host.py:/app/"
|
||||||
--copy-in "$REPO_DIR/cloud:/app/"
|
--copy-in "$REPO_DIR/cloud:/app/"
|
||||||
|
--copy-in "$SCRIPT_DIR/chv-sync.sh:/usr/local/bin/"
|
||||||
|
--run-command 'mv /usr/local/bin/chv-sync.sh /usr/local/bin/chv-sync && chmod +x /usr/local/bin/chv-sync'
|
||||||
|
--run-command 'printf "runcmd:\n - /usr/local/bin/chv-sync boot >>/var/log/chv-sync.log 2>&1 || true\n" > /etc/cloud/cloud.cfg.d/99-chv-sync.cfg'
|
||||||
|
--run-command 'git config --system user.name chv && git config --system user.email chv@localhost && git config --system credential.helper store && git config --system init.defaultBranch main'
|
||||||
|
--run-command "$GT_INSTALL_CMD"
|
||||||
|
--run-command 'gt version > /app/guest-versions-gt.txt'
|
||||||
--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 "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 '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')
|
--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
|
if [ "${PLAYWRIGHT:-1}" = "1" ]; then
|
||||||
VC_ARGS+=(
|
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 '(pip3 install --no-cache-dir --break-system-packages playwright || pip3 install --no-cache-dir playwright) && python3 -m playwright install --with-deps chromium-headless-shell'
|
||||||
--run-command 'dpkg-query -W -f "${Package} ${Version}\n" > /app/guest-versions-dpkg.txt; pip3 freeze > /app/guest-versions-pip.txt || true'
|
--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')
|
--run-command 'ls /root/.cache/ms-playwright/ > /app/guest-versions-chromium.txt 2>/dev/null || true')
|
||||||
fi
|
fi
|
||||||
run virt-customize "${VC_ARGS[@]}"
|
run virt-customize "${VC_ARGS[@]}"
|
||||||
@@ -221,12 +267,14 @@ fi
|
|||||||
if [ "$DRY_RUN" = "1" ]; then
|
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-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"
|
echo "DRY-RUN: virt-cat -a $OUT/disk.raw /app/guest-versions-pip.txt > $OUT/guest-versions-pip.txt"
|
||||||
|
echo "DRY-RUN: virt-cat -a $OUT/disk.raw /app/guest-versions-gt.txt > $OUT/guest-versions-gt.txt"
|
||||||
if [ "${PLAYWRIGHT:-1}" = "1" ]; then
|
if [ "${PLAYWRIGHT:-1}" = "1" ]; then
|
||||||
echo "DRY-RUN: virt-cat -a $OUT/disk.raw /app/guest-versions-chromium.txt > $OUT/guest-versions-chromium.txt"
|
echo "DRY-RUN: virt-cat -a $OUT/disk.raw /app/guest-versions-chromium.txt > $OUT/guest-versions-chromium.txt"
|
||||||
fi
|
fi
|
||||||
else
|
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-dpkg.txt > "$OUT/guest-versions-dpkg.txt"
|
||||||
virt-cat -a "$OUT/disk.raw" /app/guest-versions-pip.txt > "$OUT/guest-versions-pip.txt"
|
virt-cat -a "$OUT/disk.raw" /app/guest-versions-pip.txt > "$OUT/guest-versions-pip.txt"
|
||||||
|
virt-cat -a "$OUT/disk.raw" /app/guest-versions-gt.txt > "$OUT/guest-versions-gt.txt" || true
|
||||||
if [ "${PLAYWRIGHT:-1}" = "1" ]; then
|
if [ "${PLAYWRIGHT:-1}" = "1" ]; then
|
||||||
virt-cat -a "$OUT/disk.raw" /app/guest-versions-chromium.txt > "$OUT/guest-versions-chromium.txt" || true
|
virt-cat -a "$OUT/disk.raw" /app/guest-versions-chromium.txt > "$OUT/guest-versions-chromium.txt" || true
|
||||||
fi
|
fi
|
||||||
|
|||||||
+22
-6
@@ -15,16 +15,20 @@ sha256/size 是对产物文件实测的;image_sha256 即 disk.raw
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
# 轻量口径(2026-11-06 用户定):Ubuntu Minimal 底座,只装
|
||||||
|
# codex + Python + SSH + cloud-init + 薄 agent + git + gt +
|
||||||
|
# 最小中文字体;已裁剪 pandoc/texlive/python-docx 等文档工具
|
||||||
|
# 与 qemu-guest-agent。chv-sync 是仓库内脚本,版本随 repo_commit。
|
||||||
BASE_CONTENTS = [
|
BASE_CONTENTS = [
|
||||||
"git", "pandoc", "poppler-utils", "latexmk",
|
"git", "openssh-server", "cloud-init",
|
||||||
"texlive-latex-base", "openssh-server", "cloud-init",
|
"python3", "python3-pip", "curl", "ca-certificates",
|
||||||
"qemu-guest-agent", "python-docx", "openpyxl",
|
"xz-utils", "fonts-wqy-microhei", "codex",
|
||||||
"python-pptx", "PyMuPDF", "Pillow", "codex",
|
|
||||||
"codex-code-mode-host", "codex-resources/bwrap",
|
"codex-code-mode-host", "codex-resources/bwrap",
|
||||||
"/app/host.py",
|
"/app/host.py", "gt", "chv-sync",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 执行器默认内核 cmdline(config.py),构建产物须与它一起记录(B-12),
|
# 执行器默认内核 cmdline(config.py),构建产物须与它一起记录(B-12),
|
||||||
@@ -62,9 +66,17 @@ def build(outdir, tag, ubuntu, codex_version, repo_commit,
|
|||||||
os.path.join(outdir, "guest-versions-dpkg.txt")),
|
os.path.join(outdir, "guest-versions-dpkg.txt")),
|
||||||
"pip": _read_lines(
|
"pip": _read_lines(
|
||||||
os.path.join(outdir, "guest-versions-pip.txt")),
|
os.path.join(outdir, "guest-versions-pip.txt")),
|
||||||
|
"gt": _read_lines(
|
||||||
|
os.path.join(outdir, "guest-versions-gt.txt")),
|
||||||
}
|
}
|
||||||
|
gt_version = ""
|
||||||
|
for line in versions["gt"]:
|
||||||
|
m = re.search(r"(\d+\.\d+\.\d+)", line)
|
||||||
|
if m:
|
||||||
|
gt_version = m.group(1)
|
||||||
|
break
|
||||||
if playwright:
|
if playwright:
|
||||||
contents += ["playwright", "chromium"]
|
contents += ["playwright", "chromium-headless-shell"]
|
||||||
versions["chromium"] = _read_lines(
|
versions["chromium"] = _read_lines(
|
||||||
os.path.join(outdir, "guest-versions-chromium.txt"))
|
os.path.join(outdir, "guest-versions-chromium.txt"))
|
||||||
info = {
|
info = {
|
||||||
@@ -72,6 +84,7 @@ def build(outdir, tag, ubuntu, codex_version, repo_commit,
|
|||||||
"ubuntu": ubuntu,
|
"ubuntu": ubuntu,
|
||||||
"codex_version": codex_version,
|
"codex_version": codex_version,
|
||||||
"repo_commit": repo_commit,
|
"repo_commit": repo_commit,
|
||||||
|
"gt_version": gt_version,
|
||||||
"built_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
"built_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
"contents": contents,
|
"contents": contents,
|
||||||
"versions": versions,
|
"versions": versions,
|
||||||
@@ -108,6 +121,9 @@ def main(argv):
|
|||||||
if not info["versions"]["pip"]:
|
if not info["versions"]["pip"]:
|
||||||
print("BUILDINFO versions.pip 为空", file=sys.stderr)
|
print("BUILDINFO versions.pip 为空", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
if not info["gt_version"]:
|
||||||
|
print("BUILDINFO gt_version 为空(gt 未装好)", file=sys.stderr)
|
||||||
|
return 1
|
||||||
if info["tag"] != tag:
|
if info["tag"] != tag:
|
||||||
print("BUILDINFO tag 不一致", file=sys.stderr)
|
print("BUILDINFO tag 不一致", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ DECLARED_SOURCES = {
|
|||||||
"codex-code-mode-host": "codex_version",
|
"codex-code-mode-host": "codex_version",
|
||||||
"codex-resources/bwrap": "codex_version",
|
"codex-resources/bwrap": "codex_version",
|
||||||
"/app/host.py": "repo_commit",
|
"/app/host.py": "repo_commit",
|
||||||
|
"gt": "gt_version",
|
||||||
|
"chv-sync": "repo_commit",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -102,7 +104,8 @@ def main(argv):
|
|||||||
source = DECLARED_SOURCES.get(name)
|
source = DECLARED_SOURCES.get(name)
|
||||||
if source and data.get(source):
|
if source and data.get(source):
|
||||||
continue
|
continue
|
||||||
if name.lower() == "chromium" and versions.get("chromium"):
|
if name.lower() in ("chromium", "chromium-headless-shell") \
|
||||||
|
and versions.get("chromium"):
|
||||||
continue
|
continue
|
||||||
missing.append(name)
|
missing.append(name)
|
||||||
if missing:
|
if missing:
|
||||||
|
|||||||
Executable
+135
@@ -0,0 +1,135 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# chv-sync:轻量 VM 里的 Gitea 双向同步(用户工作区口径)。
|
||||||
|
#
|
||||||
|
# 主角是用户,不是 agent:用户在 Gitea 上的仓库在 VM 启动时
|
||||||
|
# clone 到 ~/workspace,用完再同步回 Gitea;VM 是用户的工作区,
|
||||||
|
# Gitea 是权威远端。同步口径沿用 Git Workspace Platform 已定
|
||||||
|
# 的规则:分叉或冲突时失败关闭,不强制覆盖、不自动 rebase、
|
||||||
|
# 不静默丢提交。
|
||||||
|
#
|
||||||
|
# 凭证绝不进镜像。启动时由 seed 盘投递 /etc/chv/guest-env
|
||||||
|
# (0600,root 所有),格式(KEY=VALUE,每行一项):
|
||||||
|
# GITEA_URL=https://gitea.tose.top
|
||||||
|
# GITEA_USER=<Gitea 用户名>
|
||||||
|
# GITEA_TOKEN=<专用 API token,仅此次测试/此次 VM 有效>
|
||||||
|
# SYNC_REPO=<owner/repo>
|
||||||
|
# SYNC_BRANCH=<可选,默认分支>
|
||||||
|
# GITEA_EMAIL=<可选,commit 身份邮箱;不给则留镜像占位>
|
||||||
|
# boot(root,cloud-init runcmd 调)读 guest-env,把 token 写进
|
||||||
|
# /home/chv/.git-credentials(0600,chv 所有,git credential
|
||||||
|
# store 标准机制),再以 chv 身份 clone 到 ~/workspace/<仓名>。
|
||||||
|
# pull/push/status 以 chv 身份跑,只用已落盘的 credential,不
|
||||||
|
# 再读 guest-env;token 不会出现在 git remote URL 或镜像里。
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# chv-sync boot # 启动时 clone(已 clone 则静默退出,不自动 pull)
|
||||||
|
# chv-sync pull [仓目录] # 脏树拒绝;只接受 fast-forward,分叉即失败
|
||||||
|
# chv-sync push [仓目录] # 普通 push;无 upstream 时 push -u origin HEAD;
|
||||||
|
# # 非 fast-forward 即失败,绝不 --force
|
||||||
|
# chv-sync status [仓目录] # 本地状态 + 最近提交
|
||||||
|
# 仓目录省略时取 $WORKSPACE 下唯一的 git 仓库;多个时必须显式给。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GUEST_ENV="${CHV_GUEST_ENV:-/etc/chv/guest-env}"
|
||||||
|
WORKSPACE="${CHV_WORKSPACE:-/home/chv/workspace}"
|
||||||
|
USER_HOME="${CHV_USER_HOME:-/home/chv}"
|
||||||
|
SYNC_USER="${CHV_USER:-chv}"
|
||||||
|
|
||||||
|
die() { echo "chv-sync: $*" >&2; exit 1; }
|
||||||
|
info() { echo "chv-sync: $*"; }
|
||||||
|
|
||||||
|
# 解析仓目录:显式给则用它;否则取 WORKSPACE 下唯一的 git 仓库。
|
||||||
|
resolve_repo() {
|
||||||
|
if [ -n "${1:-}" ]; then
|
||||||
|
[ -d "$1/.git" ] || die "不是 git 仓库:$1"
|
||||||
|
printf '%s' "$1"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local found=()
|
||||||
|
local d
|
||||||
|
for d in "$WORKSPACE"/*/; do
|
||||||
|
[ -d "${d}.git" ] && found+=("${d%/}")
|
||||||
|
done
|
||||||
|
if [ "${#found[@]}" -eq 1 ]; then
|
||||||
|
printf '%s' "${found[0]}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
die "WORKSPACE 下 git 仓库不是唯一(${#found[@]} 个),请显式给仓目录"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_boot() {
|
||||||
|
# 无 guest-env = 本次启动不同步任何仓,静默成功(VM 照常用)。
|
||||||
|
[ -f "$GUEST_ENV" ] || { info "无 $GUEST_ENV,本次启动不同步"; exit 0; }
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a; . "$GUEST_ENV"; set +a
|
||||||
|
: "${GITEA_URL:?guest-env 缺 GITEA_URL}"
|
||||||
|
: "${GITEA_USER:?guest-env 缺 GITEA_USER}"
|
||||||
|
: "${GITEA_TOKEN:?guest-env 缺 GITEA_TOKEN}"
|
||||||
|
: "${SYNC_REPO:?guest-env 缺 SYNC_REPO}"
|
||||||
|
[ "$(id -u)" = "0" ] || die "boot 须以 root 运行(cloud-init runcmd)"
|
||||||
|
local host="${GITEA_URL#https://}"
|
||||||
|
host="${host#http://}"
|
||||||
|
host="${host%%/*}"
|
||||||
|
local name="${SYNC_REPO##*/}"
|
||||||
|
local dir="$WORKSPACE/$name"
|
||||||
|
mkdir -p "$WORKSPACE"
|
||||||
|
chown "$SYNC_USER:$SYNC_USER" "$WORKSPACE"
|
||||||
|
# token 只落盘进用户家目录的 credential store(0600),
|
||||||
|
# 不进 git remote URL、不进镜像、不进日志(此处不打印 token)。
|
||||||
|
local creds="$USER_HOME/.git-credentials"
|
||||||
|
printf 'https://%s:%s@%s\n' "$GITEA_USER" "$GITEA_TOKEN" "$host" > "$creds"
|
||||||
|
chmod 0600 "$creds"
|
||||||
|
chown "$SYNC_USER:$SYNC_USER" "$creds"
|
||||||
|
if [ -d "$dir/.git" ]; then
|
||||||
|
info "已 clone:$dir(不自动 pull,改动由用户显式同步)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
[ -e "$dir" ] && die "$dir 已存在但不是 git 仓库,请人工处理"
|
||||||
|
local clone_args=(clone)
|
||||||
|
[ -n "${SYNC_BRANCH:-}" ] && clone_args+=(--branch "$SYNC_BRANCH")
|
||||||
|
clone_args+=("$GITEA_URL/$SYNC_REPO.git" "$dir")
|
||||||
|
runuser -u "$SYNC_USER" -- env HOME="$USER_HOME" git "${clone_args[@]}"
|
||||||
|
runuser -u "$SYNC_USER" -- env HOME="$USER_HOME" git -C "$dir" config user.name "$GITEA_USER"
|
||||||
|
if [ -n "${GITEA_EMAIL:-}" ]; then
|
||||||
|
runuser -u "$SYNC_USER" -- env HOME="$USER_HOME" git -C "$dir" config user.email "$GITEA_EMAIL"
|
||||||
|
fi
|
||||||
|
info "已 clone $SYNC_REPO 到 $dir,HEAD=$(runuser -u "$SYNC_USER" -- env HOME="$USER_HOME" git -C "$dir" rev-parse --short HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_pull() {
|
||||||
|
local dir
|
||||||
|
dir="$(resolve_repo "${1:-}")"
|
||||||
|
[ -z "$(git -C "$dir" status --porcelain)" ] \
|
||||||
|
|| die "工作树有未提交改动,拒绝 pull(先 commit 或 stash)"
|
||||||
|
git -C "$dir" pull --ff-only \
|
||||||
|
|| die "pull 不是 fast-forward(本地与远端已分叉),请人工处理,不自动 rebase/merge"
|
||||||
|
info "已同步:$dir HEAD=$(git -C "$dir" rev-parse --short HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_push() {
|
||||||
|
local dir
|
||||||
|
dir="$(resolve_repo "${1:-}")"
|
||||||
|
if git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
|
||||||
|
git -C "$dir" push \
|
||||||
|
|| die "push 被拒绝(远端已有新提交,先 pull 再 push,不 --force)"
|
||||||
|
else
|
||||||
|
git -C "$dir" push -u origin HEAD \
|
||||||
|
|| die "push 被拒绝(远端已有新提交,先 pull 再 push,不 --force)"
|
||||||
|
fi
|
||||||
|
info "已 push:$dir HEAD=$(git -C "$dir" rev-parse --short HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_status() {
|
||||||
|
local dir
|
||||||
|
dir="$(resolve_repo "${1:-}")"
|
||||||
|
git -C "$dir" status -sb
|
||||||
|
git -C "$dir" log --oneline -5
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
boot) shift; cmd_boot "$@" ;;
|
||||||
|
pull) shift; cmd_pull "$@" ;;
|
||||||
|
push) shift; cmd_push "$@" ;;
|
||||||
|
status) shift; cmd_status "$@" ;;
|
||||||
|
*) echo "用法:chv-sync boot|pull|push|status [仓目录]" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -20,7 +20,8 @@ Playwright=1 时另有 chromium 标识)为唯一版本来源,
|
|||||||
- codex / codex-code-mode-host / codex-resources/bwrap
|
- codex / codex-code-mode-host / codex-resources/bwrap
|
||||||
→ BUILDINFO.codex_version(随 codex 一起发布)
|
→ BUILDINFO.codex_version(随 codex 一起发布)
|
||||||
- /app/host.py → BUILDINFO.repo_commit
|
- /app/host.py → BUILDINFO.repo_commit
|
||||||
- chromium → versions.chromium(ms-playwright 缓存目录名)
|
- chromium / chromium-headless-shell → versions.chromium
|
||||||
|
(ms-playwright 缓存目录名)
|
||||||
|
|
||||||
注意:旧版逐项查构建宿主的 dpkg/pip,方法不成立——宿主
|
注意:旧版逐项查构建宿主的 dpkg/pip,方法不成立——宿主
|
||||||
版本与客机版本没有必然关系,且 /app/host.py、codex 三件
|
版本与客机版本没有必然关系,且 /app/host.py、codex 三件
|
||||||
@@ -43,6 +44,8 @@ DECLARED_SOURCES = {
|
|||||||
"codex-code-mode-host": "codex_version",
|
"codex-code-mode-host": "codex_version",
|
||||||
"codex-resources/bwrap": "codex_version",
|
"codex-resources/bwrap": "codex_version",
|
||||||
"/app/host.py": "repo_commit",
|
"/app/host.py": "repo_commit",
|
||||||
|
"gt": "gt_version",
|
||||||
|
"chv-sync": "repo_commit",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -67,11 +70,16 @@ def _index_pip(lines):
|
|||||||
|
|
||||||
|
|
||||||
def _index_chromium(lines):
|
def _index_chromium(lines):
|
||||||
"""chromium 标识行(如 chromium-1200)→ {"chromium": 行}。"""
|
"""chromium 标识行 → 键。完整 Chromium 的目录名是
|
||||||
|
chromium-<rev>,headless-shell 的是 chromium_headless_shell-<rev>,
|
||||||
|
各自映射到同名 contents 项。"""
|
||||||
index = {}
|
index = {}
|
||||||
for line in lines or []:
|
for line in lines or []:
|
||||||
text = str(line).strip()
|
text = str(line).strip()
|
||||||
if text:
|
if text.startswith("chromium_headless_shell"):
|
||||||
|
index["chromium-headless-shell"] = \
|
||||||
|
"chromium-headless-shell %s" % text
|
||||||
|
elif text:
|
||||||
index["chromium"] = "chromium %s" % text
|
index["chromium"] = "chromium %s" % text
|
||||||
return index
|
return index
|
||||||
|
|
||||||
@@ -97,12 +105,8 @@ def generate(buildinfo_path, output_path):
|
|||||||
else:
|
else:
|
||||||
unresolved.append(name)
|
unresolved.append(name)
|
||||||
continue
|
continue
|
||||||
if key == "chromium":
|
if key in chromium_index:
|
||||||
entry = chromium_index.get("chromium")
|
declared.append(chromium_index[key])
|
||||||
if entry:
|
|
||||||
declared.append(entry)
|
|
||||||
else:
|
|
||||||
unresolved.append(name)
|
|
||||||
continue
|
continue
|
||||||
if key in dpkg_index:
|
if key in dpkg_index:
|
||||||
dpkg.append(dpkg_index[key])
|
dpkg.append(dpkg_index[key])
|
||||||
|
|||||||
@@ -447,6 +447,141 @@ class BuildScriptTest(unittest.TestCase):
|
|||||||
self.assertNotEqual(r.returncode, 0)
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# -- (5b) chv-sync 双向同步 ------------------------------------------------
|
||||||
|
# 轻量口径 + Gitea 集成(2026-11-06):本地 bare 仓当远端替身,
|
||||||
|
# 不碰真实 Gitea、不需 token,测同步机制本身(双向通 + 分叉/
|
||||||
|
# 脏树时失败关闭)。boot 的 clone 一步需 root + guest-env,
|
||||||
|
# 不在单元测试里跑,只测 boot 无 guest-env 时静默成功与
|
||||||
|
# guest-env 缺字段时明确失败;真机验收见构建记录。
|
||||||
|
class ChvSyncTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.dir = self.tmp.name
|
||||||
|
self.script = str(Path(__file__).resolve().parents[2]
|
||||||
|
/ "chv-guest" / "chv-sync.sh")
|
||||||
|
self.ws = os.path.join(self.dir, "workspace")
|
||||||
|
os.makedirs(self.ws)
|
||||||
|
# 远端替身:bare 仓 + 一个已推入的初始提交。
|
||||||
|
self.remote = os.path.join(self.dir, "remote.git")
|
||||||
|
subprocess.run(["git", "init", "-q", "--bare", self.remote],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
seed = os.path.join(self.dir, "seed-clone")
|
||||||
|
subprocess.run(["git", "clone", "-q", self.remote, seed],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
self._git(seed, "config", "user.email", "t@t")
|
||||||
|
self._git(seed, "config", "user.name", "t")
|
||||||
|
with open(os.path.join(seed, "a.txt"), "w") as fh:
|
||||||
|
fh.write("v1\n")
|
||||||
|
self._git(seed, "add", "-A")
|
||||||
|
self._git(seed, "commit", "-qm", "v1")
|
||||||
|
self._git(seed, "push", "-q", "origin", "HEAD:main")
|
||||||
|
# 本地工作区:模拟 boot 已 clone 的结果。
|
||||||
|
self.repo = os.path.join(self.ws, "remote")
|
||||||
|
subprocess.run(["git", "clone", "-q", "--branch", "main",
|
||||||
|
self.remote, self.repo],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
self._git(self.repo, "config", "user.email", "t@t")
|
||||||
|
self._git(self.repo, "config", "user.name", "t")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _git(self, cwd, *args):
|
||||||
|
return subprocess.run(["git", "-C", cwd] + list(args),
|
||||||
|
check=True, capture_output=True, text=True)
|
||||||
|
|
||||||
|
def _sync(self, *args, env_extra=None):
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["CHV_WORKSPACE"] = self.ws
|
||||||
|
env["CHV_GUEST_ENV"] = os.path.join(self.dir, "no-guest-env")
|
||||||
|
if env_extra:
|
||||||
|
env.update(env_extra)
|
||||||
|
return subprocess.run(["bash", self.script] + list(args),
|
||||||
|
capture_output=True, text=True, env=env)
|
||||||
|
|
||||||
|
def _head(self, cwd):
|
||||||
|
return self._git(cwd, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
|
||||||
|
def test_push_then_pull_round_trip(self):
|
||||||
|
# 本地改 → push → 远端可见。
|
||||||
|
with open(os.path.join(self.repo, "a.txt"), "a") as fh:
|
||||||
|
fh.write("v2-local\n")
|
||||||
|
self._git(self.repo, "add", "-A")
|
||||||
|
self._git(self.repo, "commit", "-qm", "v2-local")
|
||||||
|
r = self._sync("push", self.repo)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
other = os.path.join(self.dir, "other-clone")
|
||||||
|
subprocess.run(["git", "clone", "-q", "--branch", "main",
|
||||||
|
self.remote, other],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
self.assertEqual(self._head(other), self._head(self.repo))
|
||||||
|
# 远端改 → pull → 本地同步回来。
|
||||||
|
self._git(other, "config", "user.email", "t@t")
|
||||||
|
self._git(other, "config", "user.name", "t")
|
||||||
|
with open(os.path.join(other, "a.txt"), "a") as fh:
|
||||||
|
fh.write("v3-remote\n")
|
||||||
|
self._git(other, "add", "-A")
|
||||||
|
self._git(other, "commit", "-qm", "v3-remote")
|
||||||
|
self._git(other, "push", "-q", "origin", "HEAD:main")
|
||||||
|
r = self._sync("pull", self.repo)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
with open(os.path.join(self.repo, "a.txt")) as fh:
|
||||||
|
self.assertIn("v3-remote", fh.read())
|
||||||
|
|
||||||
|
def test_push_new_branch_sets_upstream(self):
|
||||||
|
self._git(self.repo, "checkout", "-qb", "feature")
|
||||||
|
with open(os.path.join(self.repo, "b.txt"), "w") as fh:
|
||||||
|
fh.write("f\n")
|
||||||
|
self._git(self.repo, "add", "-A")
|
||||||
|
self._git(self.repo, "commit", "-qm", "feature")
|
||||||
|
r = self._sync("push", self.repo)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
out = subprocess.run(["git", "--git-dir", self.remote,
|
||||||
|
"branch", "--list", "feature"],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
text=True).stdout
|
||||||
|
self.assertIn("feature", out)
|
||||||
|
|
||||||
|
def test_diverged_pull_fails_closed(self):
|
||||||
|
# 本地与远端同分支各进一步 → pull 必须失败,不自动合并。
|
||||||
|
with open(os.path.join(self.repo, "a.txt"), "a") as fh:
|
||||||
|
fh.write("local-diverge\n")
|
||||||
|
self._git(self.repo, "add", "-A")
|
||||||
|
self._git(self.repo, "commit", "-qm", "local-diverge")
|
||||||
|
other = os.path.join(self.dir, "other2")
|
||||||
|
subprocess.run(["git", "clone", "-q", "--branch", "main",
|
||||||
|
self.remote, other],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
self._git(other, "config", "user.email", "t@t")
|
||||||
|
self._git(other, "config", "user.name", "t")
|
||||||
|
with open(os.path.join(other, "a.txt"), "a") as fh:
|
||||||
|
fh.write("remote-diverge\n")
|
||||||
|
self._git(other, "add", "-A")
|
||||||
|
self._git(other, "commit", "-qm", "remote-diverge")
|
||||||
|
self._git(other, "push", "-q", "origin", "HEAD:main")
|
||||||
|
r = self._sync("pull", self.repo)
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
with open(os.path.join(self.repo, "a.txt")) as fh:
|
||||||
|
self.assertNotIn("remote-diverge", fh.read())
|
||||||
|
|
||||||
|
def test_dirty_tree_pull_refused(self):
|
||||||
|
with open(os.path.join(self.repo, "a.txt"), "a") as fh:
|
||||||
|
fh.write("uncommitted\n")
|
||||||
|
r = self._sync("pull", self.repo)
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
|
||||||
|
def test_boot_without_guest_env_is_noop(self):
|
||||||
|
r = self._sync("boot")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
|
||||||
|
def test_boot_with_incomplete_guest_env_fails(self):
|
||||||
|
env_file = os.path.join(self.dir, "guest-env")
|
||||||
|
with open(env_file, "w") as fh:
|
||||||
|
fh.write("GITEA_URL=https://gitea.tose.top\n")
|
||||||
|
r = self._sync("boot", env_extra={"CHV_GUEST_ENV": env_file})
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
|
||||||
|
|
||||||
# -- (6) 中途死亡 → interrupted → 断点续跑闭环 ---------------------------
|
# -- (6) 中途死亡 → interrupted → 断点续跑闭环 ---------------------------
|
||||||
class MidDeathLoopTest(ChvGapCase):
|
class MidDeathLoopTest(ChvGapCase):
|
||||||
def test_vm_death_is_interrupted_then_breakpoint_resumes(self):
|
def test_vm_death_is_interrupted_then_breakpoint_resumes(self):
|
||||||
|
|||||||
Reference in new issue
Block a user