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 之后、自定义目录参数生效。
832 lines
35 KiB
Python
832 lines
35 KiB
Python
#!/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:]))
|