Files
codex-app-server/chv-guest/chv-cell.py
T
host adc7bf6666 part 5 修复 2:默认 dispatch 在只读 /app 下建不了 worktree——workspace 绑定 /app/work
RETEST4 新发现 2:make_worktree 把 worktree 建在 HERE/work/
host-worktrees,单元内 HERE=/app 而 /app 是 --bind-ro,带 repo
的默认 dispatch 撞 OSError Errno 30(--no-worktree 的同任务
越过此关、跑到真实 turn)。

二选一取 (a) 给 worktree 一条单元内可写路径:客机 workspace
下的 .chv-worktrees 以可写 --bind 挂到单元内 /app/work 上
(build_nspawn_argv 新增 worktrees_dir 参数,默认
WORKTREE_DIR=/home/chv/workspace/.chv-worktrees;run_cell 起
单元前 _ensure_bind_source 建好该目录并 chown 回 chv)。
取舍:
- 不选 (b) 执行器默认 dispatch 带 --no-worktree:worktree
  隔离就丢了——任务改动直接落进 repo 本身,多任务同仓互撞,
  merge/discard/seed-dir 语义全坏;且只修 dispatch,goal/fork
  同样调 make_worktree,照样撞 Errno 30。
- (a) 内不选可写 tmpfs:tmpfs 随单元退出即丢 worktree,跨
  cell 的 run/merge 续跑找不着工作区;workspace 绑定随客机
  磁盘持久化,worktree 语义、隔离与落盘位置(仍是
  /app/work/host-worktrees/<任务>)全都不变,也不碰 P2 的
  /usr 只读约束。
- bind 必须排在 --bind-ro=/app 之后(nspawn 按顺序挂载,后挂
  的才盖住只读子树下的子路径),单测钉住该顺序。

未决(须 RETEST5 真机验证):nspawn 对 --bind 目的路径的
处理(目的不存在时是否自动建挂载点)与挂载叠加顺序只能在
真机上验证,单测只能钉住 argv 形状;本轮不建镜像、不跑真机。

新测试 cloud/tests/test_chv_muse_p2.py::CellPart5WorktreeTest
(4 项):可写 bind 在位且在 workspace 下、顺序在
--bind-ro=/app 之后、自定义目录参数生效。
2026-11-07 12:07:01 +00:00

832 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""chv-cell:客机内 systemd-nspawn 运行时单元(Hatch 式)启动器。
对齐 Muse P2(见 docs/chv-muse-p2-gap.md):codex 跑在单元内,不再
直接跑在客机用户态。单元是单命令容器(--as-pid2,不 boot 第二个
systemd),单元即沙箱——单元内 codex 用 danger-full-access,不再
套 bwrap(bwrap 的 --proc 挂载在嵌套命名空间里走不通,见 AGENTS.md
内层 proc 定位)。
关键映射(与 Muse“单元 root 映射为 host 侧非特权用户”同款):
--private-users=1000:65536 —— 单元内 uid 0 → 客机侧 chv(uid 1000)。
效果:workspace / ~/.codex(chv 所有)单元内仍可写;host-root 0600
文件(如 /etc/chv/guest-env)在单元内显示为 overflow uid、不可读;
再加 --inaccessible 遮蔽作纵深防御(只遮 /usr 下路径:--volatile=yes
下容器根是基本空的 tmpfs,/etc/chv 等在容器根里根本不存在,按构造
即不可达,列出反而让 nspawn 解析失败而起不来,见 INACCESSIBLE_BASE)。
网络:--network-veth。每次 run_cell 在锁下分配唯一身份
(CellNet:machine chv-cell-<xx>、宿主 ve-chv-cell-<xx>、
10.200.<xx>.0/24,宿主 .1、单元 .2),不再共用固定名与固定
子网——固定名是并发 cell 撞毁的根因(part 4)。客机侧 NAT
出站(MASQUERADE 走客机默认路由的上行口,动态发现)。单元内
配网经 nsenter 进单元 init 的 netns 做。veth 建不起来即大声
失败,不静默回落共享网络;CHV_CELL_NETWORK=shared 是显式调试
逃生口,须显式设置并打日志,绝不默认。
起步门(part 4):payload 在 Popen 的瞬间就开始跑,与配网全程
并发是启动竞态的根因——快 payload 在配网完成前退出,nspawn 随
之退出,包装器再去配网即撞墙。run_cell 把 payload 包一层
sh 起步门(等 /run/chv/.cell-gate-<machine> 出现再 exec),
配网完成后才开门;find_cell_init_pid 改成带超时轮询;nspawn
已死时返回它的退出码而不是抛错。
volatile 空根(part 4):--volatile=yes 下单元根是基本空的
tmpfs,/app、/etc/resolv.conf、/etc/hosts、/etc/ssl/certs 都须
显式绑进去(/app 与 CA 只读绑;resolv/hosts 是 run_cell 按客机
有效 DNS 生成的单元专属文件,只读绑;不绑 nsswitch.conf,
glibc 默认 files+dns 正是单元需要的)。part 5 补一条:
/app/work 以可写 bind 盖住(backing 在客机 workspace 下),
否则 host.py 的 make_worktree 在只读 /app 下建不了 worktree
(RETEST4 新发现 2,OSError Errno 30)。
环境透传:nspawn 不透传调用方环境,本脚本只转发白名单变量(模型
网关与代理变量)。GITEA_TOKEN 等凭证变量不在白名单里,绝不进单元。
用法(客机侧 root 运行,执行器经 SSH 调):
chv-cell run [--network veth|shared] -- <cmd...>
# 例:chv-cell run -- python3 /app/host.py --state
# /home/chv/.codex/host-state.json --codex-home /home/chv/.codex
# --sandbox danger-full-access dispatch --name t --task ...
chv-cell argv [--network veth|shared] -- <cmd...>
# 只打印 nspawn 参数表(JSON),不启动
chv-cell network-commands [--uplink IFACE] [--cell-pid PID]
# 只打印配网命令表(JSON),不执行
只用标准库。单元启动失败、配网失败一律非零退出,不静默降级。
"""
import fcntl
import glob
import json
import os
import pwd
import re
import subprocess
import sys
import time
from typing import NamedTuple
MACHINE = "chv-cell"
# 单元映射:单元内 uid 0 → 客机侧 chv(uid 1000,非特权用户)。
UID_BASE = 1000
UID_COUNT = 65536
CELL_USER = "chv"
CELL_UID = 1000
CELL_GID = 1000
WORKSPACE = "/home/chv/workspace"
CODEX_HOME = "/home/chv/.codex"
BROKER_DIR = "/run/chv"
# part 5:单元内 worktree 的可写落点(RETEST4 新发现 2)。
# host.py 的 make_worktree 把 worktree 建在 HERE/work(单元内
# HERE=/app,而 /app 是 --bind-ro),带 repo 的默认 dispatch
# 撞 OSError Errno 30。把客机 workspace 下的 .chv-worktrees
# 以可写 bind 挂到单元内的 /app/work 上:worktree 的语义、隔离
# 与落盘位置都不变(仍是 /app/work/host-worktrees/<任务>),
# 只是 backing store 落在 bind 出去的 workspace 下、随客机磁盘
# 持久化(跨 cell 的 run/merge 续跑仍找得到 worktree);不碰
# /usr 的只读约束。必须排在 --bind-ro=/app 之后:nspawn 按顺序
# 挂载,后挂的才盖住只读子树下的该子路径。
WORKTREE_DIR = "/home/chv/workspace/.chv-worktrees"
CELL_PID_FILE = "/run/chv/cell.pid"
# 单元网络(与客机 tap 网 192.168.10.0/24 不重叠的独立 /24)。
# part 4(2026-11-07):固定名/固定子网是并发 cell 撞毁的根因——
# 每次 run_cell 都在锁下分配唯一身份(CellNet),不再共用常量。
# machine 名须短:nspawn 把宿主侧 veth 叫 ve-<machine>,接口名
# 上限 15 字符;"chv-cell-%02x" 为 11 字符,ve- 前缀后 14 字符。
CELL_NET_BASE = "10.200"
CELL_NET = "10.200.0.0/24"
HOST_IP = "10.200.0.1"
CELL_IP = "10.200.0.2"
HOST_IFACE = "ve-chv-cell"
CELL_IFACE = "host0"
MAX_CELL_INDEX = 250
ALLOC_LOCK_FILE = "/run/chv/.cell-alloc.lock"
# Muse 点名要丢的能力 + 同批丢的危险能力(nspawn 默认集已有限,
# 这里是显式再丢,--drop-capability 逐项写,测试逐项断言)。
DROP_CAPABILITIES = [
"CAP_SYS_ADMIN",
"CAP_SYS_PTRACE",
"CAP_NET_ADMIN",
"CAP_NET_RAW",
"CAP_SYS_MODULE",
"CAP_SYS_RAWIO",
"CAP_SYS_BOOT",
"CAP_MKNOD",
]
# syscall 过滤:@system-service 集合再扣掉 io_uring 三件套
#(Muse 点名禁用 io_uring)。
SYSCALL_FILTER = ("@system-service ~io_uring_setup "
"~io_uring_enter ~io_uring_register")
# 单元内遮掉的路径:浏览器(单元内跑不起自己的浏览器,
# 只能走 broker 的 chv-browser)。只列 /usr 下的路径——
# round-11 复测纠正(第二轮):--volatile=yes 下容器根是
# 基本空的 tmpfs(只挂进只读 /usr),nspawn 按容器根解析
# --inaccessible 路径,不存在的路径即启动失败;/etc/chv 与
# /home/chv/.git-credentials 在容器根里根本不存在(无 /home、
# 无 /etc/chv),凭证文件按构造就不可达,不再列出遮蔽。
# 旧的两项若列回,cell 会报 Failed to resolve 而起不来。
INACCESSIBLE_BASE = [
"/usr/local/share/ms-playwright",
]
# 只转发白名单环境变量进单元。凭证类变量(GITEA_TOKEN 等)
# 不在白名单里,绝不进单元。
FORWARD_ENV_ALLOWLIST = (
"SUB2API_BASE_URL",
"SUB2API_API_KEY",
"XCM_API_KEY",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
)
class CellNet(NamedTuple):
"""一次 cell 运行的唯一网络身份(锁下分配,见 allocate_cell_net)。
machine 同时决定宿主侧 veth 名(ve-<machine>,nspawn 约定)与
/run/chv 下的专属文件后缀(gate/resolv/hosts/pid)。"""
index: int
machine: str
host_iface: str
host_ip: str
cell_ip: str
subnet: str
def legacy_cell_net():
"""无身份时的旧常量(argv/network-commands 子命令与旧测试用;
run_cell 一律走 allocate_cell_net,不用它)。"""
return CellNet(index=0, machine=MACHINE, host_iface=HOST_IFACE,
host_ip=HOST_IP, cell_ip=CELL_IP, subnet=CELL_NET)
# Muse 点名要丢的能力 + 同批丢的危险能力(nspawn 默认集已有限,
# 这里是显式再丢,--drop-capability 逐项写,测试逐项断言)。
DROP_CAPABILITIES = [
"CAP_SYS_ADMIN",
"CAP_SYS_PTRACE",
"CAP_NET_ADMIN",
"CAP_NET_RAW",
"CAP_SYS_MODULE",
"CAP_SYS_RAWIO",
"CAP_SYS_BOOT",
"CAP_MKNOD",
]
# syscall 过滤:@system-service 集合再扣掉 io_uring 三件套
#(Muse 点名禁用 io_uring)。
SYSCALL_FILTER = ("@system-service ~io_uring_setup "
"~io_uring_enter ~io_uring_register")
# 单元内遮掉的路径:浏览器(单元内跑不起自己的浏览器,
# 只能走 broker 的 chv-browser)。只列 /usr 下的路径——
# round-11 复测纠正(第二轮):--volatile=yes 下容器根是
# 基本空的 tmpfs(只挂进只读 /usr),nspawn 按容器根解析
# --inaccessible 路径,不存在的路径即启动失败;/etc/chv 与
# /home/chv/.git-credentials 在容器根里根本不存在(无 /home、
# 无 /etc/chv),凭证文件按构造就不可达,不再列出遮蔽。
# 旧的两项若列回,cell 会报 Failed to resolve 而起不来。
INACCESSIBLE_BASE = [
"/usr/local/share/ms-playwright",
]
# 只转发白名单环境变量进单元。凭证类变量(GITEA_TOKEN 等)
# 不在白名单里,绝不进单元。
FORWARD_ENV_ALLOWLIST = (
"SUB2API_BASE_URL",
"SUB2API_API_KEY",
"XCM_API_KEY",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
)
def _playwright_package_dir():
"""playwright pip 包目录(遮蔽用)。算不出时回落到 noble 默认路径。"""
try:
import sysconfig
purelib = sysconfig.get_path("purelib")
if purelib:
return os.path.join(purelib, "playwright")
except Exception:
pass
return "/usr/local/lib/python3.12/dist-packages/playwright"
def build_nspawn_argv(command, network="veth",
workspace=WORKSPACE, codex_home=CODEX_HOME,
broker_dir=BROKER_DIR,
playwright_package_dir=None,
cell_net=None, extra_ro_binds=None,
worktrees_dir=WORKTREE_DIR):
"""产 nspawn 参数表(纯函数,可单测)。command 是单元内要跑的命令
(list,如 ["python3", "/app/host.py", ...])。
cell_net 为 None 时用旧常量身份(argv 子命令与旧测试);
run_cell 一律传 allocate_cell_net() 的唯一身份。
extra_ro_binds 是 [(src, dest)],只读绑进单元(如单元专属
resolv.conf/hosts;/app 与 /etc/ssl/certs 是固定只读绑定,
直接列在下面)。worktrees_dir 是客机侧 worktree backing
目录(part 5:以可写 bind 挂到单元内 /app/work 上,见
WORKTREE_DIR;排在 --bind-ro=/app 之后才能盖住只读子树)。"""
if not command:
raise ValueError("chv-cell: 单元内命令不能为空")
if network not in ("veth", "shared"):
raise ValueError("chv-cell: network 只能是 veth|shared:%r"
% network)
if playwright_package_dir is None:
playwright_package_dir = _playwright_package_dir()
machine = cell_net.machine if cell_net is not None else MACHINE
argv = [
"systemd-nspawn", "--quiet",
"--machine=%s" % machine,
"--directory=/",
# round-11 真机修复:nspawn 255 拒裸 --directory=/(须
# --ephemeral 或 --volatile),也拒裸 --no-new-privileges
#(把它当作带必填参数的选项,吞掉下一个参数后报错)。
# 2026-11-07 复测纠正:首轮修用的 --ephemeral 在客内
# ext4 根盘(2.4G、已用 1.8G、可用 593M)上做整盘快照
# 拷贝,空间不够即 ENOSPC,全部 cell 调用失败。改
# --volatile=yes:根为基本空的 tmpfs、/usr 只读挂进,
# 单元内除 bind 出去的目录外一律可写、用完即弃——与
# --ephemeral 的设计意图一致,但不拷盘、不占空间。
# --volatile=state 不行:整树只读、仅 /var 为 tmpfs,
# codex 写 $HOME/.cache 等非 bind 路径会失败。
"--volatile=yes",
"--as-pid2",
"--private-users=%d:%d" % (UID_BASE, UID_COUNT),
"--no-new-privileges=yes",
"--system-call-filter=%s" % SYSCALL_FILTER,
]
for cap in DROP_CAPABILITIES:
argv.append("--drop-capability=%s" % cap)
for path in INACCESSIBLE_BASE + [playwright_package_dir]:
argv.append("--inaccessible=%s" % path)
argv += [
"--bind=%s" % workspace,
"--bind=%s" % codex_home,
"--bind=%s" % broker_dir,
# part 4(volatile 空根缺口):--volatile=yes 下单元根是
# 基本空的 tmpfs,执行器默认在单元内跑 python3 /app/host.py、
# codex 要做 DNS 与 TLS——/app、CA bundle、resolv.conf、
# hosts 都须显式绑进去,否则单元内一律 ENOENT/无 DNS。
# /app 与 /etc/ssl/certs 是镜像/系统固定路径,只读绑;
# resolv.conf/hosts 是 run_cell 按客机有效 DNS 生成的
# 单元专属文件,经 extra_ro_binds 传进来(见
# prepare_cell_files:客机 /etc/resolv.conf 可能是
# systemd-resolved stub(127.0.0.53),单元内无 resolved,
# 直接绑 stub 即 DNS 全灭,故须生成真实服务器列表)。
# 不绑 /etc/nsswitch.conf:客机版可能含 resolve 模块,
# 单元内无 resolved/dbus,绑了反而可能挂起;glibc 缺
# nsswitch.conf 时默认 files+dns,正是单元需要的。
"--bind-ro=/app",
# part 5(RETEST4 新发现 2):/app/work 以可写 bind 盖住
# 只读 /app 下的该子路径——host.py 的 make_worktree 在
# /app/work/host-worktrees 下建目录,绑了才写得进;必须在
# --bind-ro=/app 之后(nspawn 按顺序挂载),且必须是
# --bind(可写),不是 --bind-ro。backing 目录在客机
# workspace 下(run_cell 起单元前 _ensure_bind_source
# 建好),worktree 随客机磁盘持久化。
"--bind=%s:/app/work" % worktrees_dir,
"--bind-ro=/etc/ssl/certs",
"--tmpfs=/tmp",
]
for src, dest in (extra_ro_binds or []):
argv.append("--bind-ro=%s:%s" % (src, dest))
if network == "veth":
argv.append("--network-veth")
argv += [
"--setenv=CHV_CELL=1",
"--setenv=HOME=/home/chv",
]
for name in FORWARD_ENV_ALLOWLIST:
value = os.environ.get(name)
if value:
argv.append("--setenv=%s=%s" % (name, value))
argv += ["--chdir=%s" % workspace]
argv += list(command)
return argv
def parse_default_route_iface(ip_route_output):
"""从 `ip route show default` 的输出里解析上行口名。
无默认路由时返回 None(调用方大声失败)。"""
for line in (ip_route_output or "").splitlines():
line = line.strip()
if not line.startswith("default "):
continue
m = re.search(r"\bdev\s+(\S+)", line)
if m:
return m.group(1)
return None
def find_uplink_iface(run=None):
"""发现客机上行口(默认路由的 dev)。run 供单测注入。"""
run = run or subprocess.run
proc = run(["ip", "route", "show", "default"],
capture_output=True, text=True)
if proc.returncode != 0:
return None
return parse_default_route_iface(proc.stdout)
def host_network_commands(uplink_iface, net=None):
"""客机侧配网命令表(纯函数):ve 接口起 IP + 转发 + NAT。
调用方按顺序执行,任一失败即停。net 为 None 时用旧常量
(argv/network-commands 子命令与旧测试);run_cell 传
allocate_cell_net() 的唯一身份。"""
if not uplink_iface:
raise ValueError("chv-cell: 上行口名为空,配不出 NAT")
net = net or legacy_cell_net()
return [
["ip", "addr", "add", "%s/24" % net.host_ip, "dev",
net.host_iface],
["ip", "link", "set", net.host_iface, "up"],
["sysctl", "-w", "net.ipv4.ip_forward=1"],
["iptables", "-t", "nat", "-A", "POSTROUTING",
"-s", net.subnet, "-o", uplink_iface, "-j", "MASQUERADE"],
["iptables", "-A", "FORWARD", "-i", net.host_iface,
"-o", uplink_iface, "-j", "ACCEPT"],
["iptables", "-A", "FORWARD", "-i", uplink_iface,
"-o", net.host_iface, "-m", "state", "--state",
"RELATED,ESTABLISHED", "-j", "ACCEPT"],
]
def host_network_cleanup_commands(uplink_iface, net=None):
"""配网清理命令表(与 host_network_commands 配对,-A 改 -D)。
清理是 best-effort:删不掉(规则已不在)不报错。"""
if not uplink_iface:
raise ValueError("chv-cell: 上行口名为空,清不了 NAT")
cmds = []
for cmd in host_network_commands(uplink_iface, net):
if "iptables" not in cmd:
continue
cleaned = [c if c != "-A" else "-D" for c in cmd]
cmds.append(cleaned)
return cmds
def find_cell_init_pid(nspawn_pid, proc_root="/proc", timeout=0.0,
interval=0.1, proc_alive=None):
"""找单元 init 的 pid:nspawn 进程的第一个子进程
(--as-pid2 时它是 stub init,payload 是 PID 2;两者同 netns)。
timeout=0(默认)即只查一次(旧行为);timeout>0 时带超时轮询
(part 4 竞态修复:init 可能还没 fork,单次查找即误判)。
proc_alive 供调用方传“nspawn 是否还活着”(如 proc.poll() is
None 的 lambda):nspawn 已退出即立刻回 None,不再空等超时。
找不到返回 None。"""
deadline = time.time() + timeout
while True:
found = None
try:
task_dir = os.path.join(proc_root, str(nspawn_pid), "task")
for tid in os.listdir(task_dir):
children_path = os.path.join(task_dir, tid, "children")
try:
with open(children_path) as fh:
children = fh.read().split()
except OSError:
continue
if children:
try:
found = int(children[0])
except ValueError:
continue
break
except OSError:
# 任务目录还没出现(init 未 fork)即当作“这次没找到”,
# 继续轮询,不直接回 None(旧代码这里直接 return None,
# 正是单次查找误判的根因之一)。
found = None
if found is not None:
return found
if proc_alive is not None and not proc_alive():
return None
if time.time() >= deadline:
return None
time.sleep(min(interval, max(0.0, deadline - time.time())))
def cell_network_commands(cell_pid, net=None):
"""单元内配网命令表(纯函数):nsenter 进单元 init 的 netns,
给 host0 配 IP、起接口、加默认路由。net 为 None 时用旧常量。"""
if not cell_pid:
raise ValueError("chv-cell: 单元 init pid 为空,配不了单元内网络")
net = net or legacy_cell_net()
prefix = ["nsenter", "-t", str(cell_pid), "-n"]
return [
prefix + ["ip", "addr", "add", "%s/24" % net.cell_ip,
"dev", CELL_IFACE],
prefix + ["ip", "link", "set", CELL_IFACE, "up"],
prefix + ["ip", "route", "add", "default", "via", net.host_ip],
]
def _ensure_bind_source(path, uid=CELL_UID, gid=CELL_GID):
"""bind 源目录须存在且属 chv(单元内 root 即客机 chv,属主不对
就写不进 workspace)。root 建的目录要 chown 回 chv。"""
os.makedirs(path, exist_ok=True)
try:
st = os.stat(path)
if st.st_uid != uid or st.st_gid != gid:
os.chown(path, uid, gid)
except OSError as e:
print("chv-cell: chown %s 失败:%s" % (path, e), file=sys.stderr)
raise
def gate_wrapper_command(command, gate_path):
"""把单元内命令包一层起步门(纯函数):payload 先在单元内
等 gate 文件出现再 exec 原命令。gate 文件由 run_cell 在配网
完成后才创建(见 run_cell)——payload 与配网零同步是 RETEST3
新缺陷 1 的根因(快 payload 在配网完成前退出,nspawn 随之
退出,包装器再去配网即撞墙)。/usr/bin/sh 用绝对路径:
volatile 单元根里 /bin 未必有,/usr 是只读挂进来的,必有。
注意:$1 是 gate 路径,shift 后 "$@" 即原命令。"""
script = ("while [ ! -e \"$1\" ]; do sleep 0.05; done; "
"shift; exec \"$@\"")
return ["/usr/bin/sh", "-c", script, "chv-cell-gate",
gate_path] + list(command)
def _parse_nameservers(path):
"""读一个 resolv.conf 风格文件,返回 nameserver 列表
(去 127.0.0.0/8 与 ::1——单元内无 resolved,stub 指过去
即 DNS 全灭)。文件不存在或读不出即 []。"""
servers = []
try:
with open(path) as fh:
for line in fh:
line = line.strip()
if not line.startswith("nameserver"):
continue
parts = line.split()
if len(parts) < 2:
continue
ip = parts[1]
if ip.startswith("127.") or ip == "::1":
continue
if ip not in servers:
servers.append(ip)
except OSError:
return []
return servers
def _effective_nameservers(resolv_conf="/etc/resolv.conf",
resolved_conf="/run/systemd/resolve/resolv.conf"):
"""客机有效 DNS:先读 /etc/resolv.conf;只剩 stub(127.0.0.53)
或为空时读 resolved 的 resolv.conf;仍为空即大声失败。"""
servers = _parse_nameservers(resolv_conf)
if not servers:
servers = _parse_nameservers(resolved_conf)
if not servers:
raise RuntimeError(
"chv-cell: 客机无有效 DNS,配不出单元 resolv.conf")
return servers
def prepare_cell_files(machine, run_dir=BROKER_DIR,
resolv_conf="/etc/resolv.conf",
resolved_conf="/run/systemd/resolve/resolv.conf",
hosts_file="/etc/hosts"):
"""准备单元专属文件(run_cell 在 Popen 之前调),返回 dict:
gate(起步门,run_cell 配网完成后才创建——注意 gate 文件本身
在这里不创建,只给路径)、resolv(单元专属 resolv.conf,已写
好)、hosts(单元专属 hosts,已写好)。文件都在 /run/chv 下
(bind 进单元),0644(单元内 root 在客机侧是 chv,0600 的
root 文件在单元内读不到;DNS/hosts 不含秘密,0644 安全)。
/app 与 /etc/ssl/certs 是固定只读绑定,不走这里。"""
gate = os.path.join(run_dir, ".cell-gate-%s" % machine)
resolv = os.path.join(run_dir, "resolv.conf.%s" % machine)
hosts = os.path.join(run_dir, "hosts.%s" % machine)
servers = _effective_nameservers(resolv_conf, resolved_conf)
with open(resolv, "w") as fh:
for server in servers:
fh.write("nameserver %s\n" % server)
os.chmod(resolv, 0o644)
try:
with open(hosts_file) as src:
content = src.read()
except OSError:
content = ""
if "localhost" not in content:
content = "127.0.0.1 localhost\n" + content
with open(hosts, "w") as fh:
fh.write(content)
os.chmod(hosts, 0o644)
return {"gate": gate, "resolv": resolv, "hosts": hosts}
def _wait_for_iface(iface, timeout=10.0):
deadline = time.time() + timeout
while time.time() < deadline:
proc = subprocess.run(["ip", "link", "show", iface],
capture_output=True)
if proc.returncode == 0:
return True
time.sleep(0.2)
return False
def _alloc_lock(lock_path, timeout=30.0):
"""拿分配锁(fcntl 排他锁,阻塞拿不到即超时大声失败)。
返回锁 fd(调用方负责 close 即解锁)。"""
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
deadline = time.time() + timeout
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return fd
except BlockingIOError:
if time.time() >= deadline:
os.close(fd)
raise RuntimeError(
"chv-cell: 分配锁 %s 拿不到(超时 %.0f 秒)"
% (lock_path, timeout))
time.sleep(0.05)
def _used_cell_indices(run):
"""扫描已占用的 cell 下标:活着的 ve-chv-cell-<xx> 接口名、
10.200.<N>.1/24 地址、machinectl 里仍在册的同名 machine
(注销滞后时避让,见 RETEST3 新缺陷 1 的 veth 超时分析)。
run 供单测注入(subprocess.run 兼容)。machinectl 查不到
(无此命令/失败)即跳过该项,不因此失败。"""
used = set()
proc = run(["ip", "-o", "link", "show"], capture_output=True,
text=True)
if proc.returncode != 0:
raise RuntimeError("chv-cell: 扫描已占用 cell 下标失败:"
"ip -o link show")
for m in re.finditer(r"\bve-chv-cell-([0-9a-f]{2})\b",
proc.stdout or ""):
try:
used.add(int(m.group(1), 16))
except ValueError:
continue
proc = run(["ip", "-o", "addr", "show"], capture_output=True,
text=True)
if proc.returncode != 0:
raise RuntimeError("chv-cell: 扫描已占用 cell 下标失败:"
"ip -o addr show")
for m in re.finditer(r"\b10\.200\.(\d+)\.1/24\b",
proc.stdout or ""):
try:
used.add(int(m.group(1)))
except ValueError:
continue
try:
proc = run(["machinectl", "list", "--no-legend"],
capture_output=True, text=True)
if proc.returncode == 0:
for m in re.finditer(r"\bchv-cell-([0-9a-f]{2})\b",
proc.stdout or ""):
try:
used.add(int(m.group(1), 16))
except ValueError:
continue
except OSError:
pass
return used
def allocate_cell_net(run=None, lock_path=ALLOC_LOCK_FILE):
"""锁下分配唯一 cell 身份(run_cell 每调一次即分配一个)。
取最小空闲下标 0..250;machine 名 chv-cell-<xx>(11 字符,
ve- 前缀后 14 字符,符合接口名 15 字符上限);子网
10.200.<idx>.0/24、宿主 10.200.<idx>.1、单元 10.200.<idx>.2。
全部下标占满即大声失败,不静默回落固定身份。"""
run = run or subprocess.run
fd = _alloc_lock(lock_path)
try:
used = _used_cell_indices(run)
for index in range(MAX_CELL_INDEX + 1):
if index not in used:
break
else:
raise RuntimeError(
"chv-cell: cell 下标已占满(0..%d)" % MAX_CELL_INDEX)
machine = "chv-cell-%02x" % index
return CellNet(
index=index,
machine=machine,
host_iface="ve-%s" % machine,
host_ip="%s.%d.1" % (CELL_NET_BASE, index),
cell_ip="%s.%d.2" % (CELL_NET_BASE, index),
subnet="%s.%d.0/24" % (CELL_NET_BASE, index))
finally:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except OSError:
pass
os.close(fd)
def _write_cell_pid_files(machine, cell_pid):
"""写 pid 文件:每单元一份 cell-<machine>.pid + 兼容旧路径
/run/chv/cell.pid(最新单元;P2-9 驱动与 broker 降级路径
仍读它)。"""
per_cell = os.path.join(BROKER_DIR, "cell-%s.pid" % machine)
with open(per_cell, "w") as fh:
fh.write(str(cell_pid))
with open(CELL_PID_FILE, "w") as fh:
fh.write(str(cell_pid))
def _remove_cell_pid_files(machine, cell_pid):
"""清 pid 文件(best-effort):每单元份必删;旧路径只在
内容仍指向本单元时才删(并发 cell 时别删别人的指针)。"""
for path in (os.path.join(BROKER_DIR, "cell-%s.pid" % machine),):
try:
os.remove(path)
except OSError:
pass
try:
with open(CELL_PID_FILE) as fh:
if fh.read().strip() == str(cell_pid):
os.remove(CELL_PID_FILE)
except OSError:
pass
def _run_cmds(cmds, what):
for cmd in cmds:
print("chv-cell: + %s" % " ".join(cmd), file=sys.stderr)
proc = subprocess.run(cmd)
if proc.returncode != 0:
raise RuntimeError(
"chv-cell: %s 失败(rc=%d):%s"
% (what, proc.returncode, " ".join(cmd)))
def run_cell(command, network="veth"):
"""起单元、配网、等 payload 退出、清 NAT,返回 payload 退出码。
part 4 顺序(RETEST3 新缺陷 1):分配唯一身份 → 准备单元专属
文件 → Popen 带起步门的 payload(payload 在门后等着,不与配网
并发)→ 等 veth → 客机侧配网 → 轮询找 init pid → 写 pid 文件 →
单元内配网 → 开门放行 payload → proc.wait()。payload 已退出
(nspawn 起不来等)时返回它的退出码而不是抛错;其余配网失败
仍大声抛错,不静默回落共享网络。"""
if os.geteuid() != 0:
print("chv-cell: 须以 root 运行(nspawn + 配网都要 root)",
file=sys.stderr)
return 2
_ensure_bind_source(WORKSPACE)
_ensure_bind_source(CODEX_HOME)
_ensure_bind_source(BROKER_DIR, uid=0, gid=0)
# part 5:worktree backing 目录须在 Popen 前存在(nspawn
# --bind 的源不存在即起不来),且属 chv(单元内 root 即客机
# chv,属主不对就写不进 worktree)。
_ensure_bind_source(WORKTREE_DIR)
net = None
files = None
uplink = None
cell_pid = None
proc = None
if network == "veth":
net = allocate_cell_net()
files = prepare_cell_files(net.machine)
wrapped = gate_wrapper_command(command, files["gate"])
else:
wrapped = list(command)
argv = build_nspawn_argv(
wrapped, network=network, cell_net=net,
extra_ro_binds=[(files["resolv"], "/etc/resolv.conf"),
(files["hosts"], "/etc/hosts")]
if files else None)
print("chv-cell: 启动单元:%s" % " ".join(argv), file=sys.stderr)
proc = subprocess.Popen(argv)
try:
if network == "veth":
# veth 等待放宽到 20 秒:RETEST3 里连续调用在 10.2/10.3
# 秒处超时(疑似 machine 注销滞后);唯一身份 + 起步门
# 后配网不再与 payload 赛跑,等久一点无害。
if not _wait_for_iface(net.host_iface, timeout=20.0):
raise RuntimeError(
"chv-cell: veth 接口 %s 未出现,拒绝回落共享网络"
% net.host_iface)
uplink = find_uplink_iface()
if not uplink:
raise RuntimeError(
"chv-cell: 客机无默认路由,配不出 NAT")
_run_cmds(host_network_commands(uplink, net), "客机侧配网")
cell_pid = find_cell_init_pid(
proc.pid, timeout=10.0,
proc_alive=lambda: proc.poll() is None)
if not cell_pid:
raise RuntimeError("chv-cell: 找不到单元 init pid")
_write_cell_pid_files(net.machine, cell_pid)
_run_cmds(cell_network_commands(cell_pid, net), "单元内配网")
# 配网完成,开门放行 payload。
with open(files["gate"], "w") as fh:
fh.write("")
return proc.wait()
except RuntimeError:
# nspawn 已死(如 machine 名冲突、argv 错)即返回它的退出
# 码,不把“起不来”报成配网失败;还活着才是真配网失败,大声抛。
if proc is not None and proc.poll() is not None:
return proc.returncode
raise
finally:
if uplink:
for cmd in host_network_cleanup_commands(uplink, net):
subprocess.run(cmd, capture_output=True)
if cell_pid:
_remove_cell_pid_files(net.machine, cell_pid)
if files:
for key in ("gate", "resolv", "hosts"):
try:
os.remove(files[key])
except OSError:
pass
if proc is not None and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
def main(argv):
if len(argv) < 1:
print("用法:chv-cell run|argv|network-commands …",
file=sys.stderr)
return 2
verb = argv[0]
rest = argv[1:]
network = os.environ.get("CHV_CELL_NETWORK", "veth")
# --network 只在 run/argv 前接受,其它位置一律不认。
if rest and rest[0] == "--network":
if len(rest) < 2:
print("chv-cell: --network 缺值", file=sys.stderr)
return 2
network = rest[1]
rest = rest[2:]
if network not in ("veth", "shared"):
print("chv-cell: network 只能是 veth|shared:%r" % network,
file=sys.stderr)
return 2
if network == "shared":
print("chv-cell: 警告:共享网络模式(调试逃生口),单元与"
"客机共用网络命名空间", file=sys.stderr)
if verb == "run":
if not rest or rest[0] != "--":
print("chv-cell: run 用法:chv-cell run -- <cmd...>",
file=sys.stderr)
return 2
command = rest[1:]
if not command:
print("chv-cell: 单元内命令不能为空", file=sys.stderr)
return 2
try:
return run_cell(command, network=network)
except RuntimeError as e:
print(str(e), file=sys.stderr)
return 1
if verb == "argv":
if not rest or rest[0] != "--":
print("chv-cell: argv 用法:chv-cell argv -- <cmd...>",
file=sys.stderr)
return 2
print(json.dumps(build_nspawn_argv(rest[1:], network=network),
ensure_ascii=False))
return 0
if verb == "network-commands":
uplink = None
cell_pid = None
i = 0
while i < len(rest):
if rest[i] == "--uplink" and i + 1 < len(rest):
uplink = rest[i + 1]
i += 2
elif rest[i] == "--cell-pid" and i + 1 < len(rest):
cell_pid = int(rest[i + 1])
i += 2
else:
print("chv-cell: network-commands 未知参数:%s"
% rest[i], file=sys.stderr)
return 2
out = {}
if uplink:
out["host"] = host_network_commands(uplink)
out["host_cleanup"] = host_network_cleanup_commands(uplink)
if cell_pid:
out["cell"] = cell_network_commands(cell_pid)
print(json.dumps(out, ensure_ascii=False))
return 0
print("chv-cell: 未知子命令:%s" % verb, file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))