part 4 缺陷 2:volatile 空根缺 /app 与 /etc 关键文件

RETEST3 缺陷 2:--volatile=yes 下单元根是基本空的 tmpfs,
/app、/etc/resolv.conf、/etc/hosts、/etc/ssl/certs 全无,
执行器单元路径一律 ENOENT、DNS/TLS 不可用。
- build_nspawn_argv:固定只读绑 --bind-ro=/app 与
  --bind-ro=/etc/ssl/certs;新增 extra_ro_binds 参数
  (单元专属 resolv.conf/hosts 由 run_cell 传进来)。
- prepare_cell_files():按客机有效 DNS 生成单元专属
  resolv.conf/hosts(/run/chv/resolv.conf.<machine>、
  hosts.<machine>,0644——单元 root 映射为客机 uid 1000,
  0600 的 root 文件单元内读不到;DNS/hosts 不含秘密)。
  客机 resolv.conf 可能是 systemd-resolved stub
  (127.0.0.53),单元内无 resolved,直接绑 stub 即 DNS
  全灭,故过滤 stub 后备选 /run/systemd/resolve/resolv.conf,
  两处都没有即大声失败。nsswitch.conf 故意不绑(客机版
  可能含 resolve 模块;glibc 默认 files+dns 正是单元需要的)。
新测试:CellFilesTest、CellPart4ArgvTest(绑定两法)。
run_cell 接线在缺陷 3 的重写里做。
This commit is contained in:
host committed 2026-11-07 10:29:31 +00:00
1 parent fc33eed262
commit 2be69af92c
2 files changed
+170 -2

No files matched your search

+92 -2
View File
@@ -123,9 +123,13 @@ def _playwright_package_dir():
def build_nspawn_argv(command, network="veth", def build_nspawn_argv(command, network="veth",
workspace=WORKSPACE, codex_home=CODEX_HOME, workspace=WORKSPACE, codex_home=CODEX_HOME,
broker_dir=BROKER_DIR, broker_dir=BROKER_DIR,
playwright_package_dir=None): playwright_package_dir=None,
extra_ro_binds=None):
"""产 nspawn 参数表(纯函数,可单测)。command 是单元内要跑的命令 """产 nspawn 参数表(纯函数,可单测)。command 是单元内要跑的命令
(list,如 ["python3", "/app/host.py", ...])。""" (list,如 ["python3", "/app/host.py", ...])。
extra_ro_binds 是 [(src, dest)],只读绑进单元(如单元专属
resolv.conf/hosts;/app 与 /etc/ssl/certs 是固定只读绑定,
直接列在下面)。"""
if not command: if not command:
raise ValueError("chv-cell: 单元内命令不能为空") raise ValueError("chv-cell: 单元内命令不能为空")
if network not in ("veth", "shared"): if network not in ("veth", "shared"):
@@ -162,8 +166,25 @@ def build_nspawn_argv(command, network="veth",
"--bind=%s" % workspace, "--bind=%s" % workspace,
"--bind=%s" % codex_home, "--bind=%s" % codex_home,
"--bind=%s" % broker_dir, "--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",
"--bind-ro=/etc/ssl/certs",
"--tmpfs=/tmp", "--tmpfs=/tmp",
] ]
for src, dest in (extra_ro_binds or []):
argv.append("--bind-ro=%s:%s" % (src, dest))
if network == "veth": if network == "veth":
argv.append("--network-veth") argv.append("--network-veth")
argv += [ argv += [
@@ -317,6 +338,75 @@ def gate_wrapper_command(command, gate_path):
gate_path] + list(command) 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): def _wait_for_iface(iface, timeout=10.0):
deadline = time.time() + timeout deadline = time.time() + timeout
while time.time() < deadline: while time.time() < deadline:
+78
View File
@@ -812,5 +812,83 @@ class CellInitPidPollTest(unittest.TestCase):
self.assertLess(_time.time() - start, 5.0) self.assertLess(_time.time() - start, 5.0)
class CellPart4ArgvTest(unittest.TestCase):
"""part 4:volatile 空根补 bind + 唯一身份进 argv。"""
def test_app_and_ca_bundle_bound_read_only(self):
argv = CELL.build_nspawn_argv(["true"])
self.assertIn("--bind-ro=/app", argv)
self.assertIn("--bind-ro=/etc/ssl/certs", argv)
# /app 是只读绑:单元内 python3 /app/host.py 只读,不写。
self.assertNotIn("--bind=/app", argv)
def test_extra_ro_binds(self):
argv = CELL.build_nspawn_argv(
["true"], extra_ro_binds=[
("/run/chv/resolv.conf.chv-cell-00", "/etc/resolv.conf"),
("/run/chv/hosts.chv-cell-00", "/etc/hosts")])
self.assertIn(
"--bind-ro=/run/chv/resolv.conf.chv-cell-00:/etc/resolv.conf",
argv)
self.assertIn(
"--bind-ro=/run/chv/hosts.chv-cell-00:/etc/hosts", argv)
class CellFilesTest(unittest.TestCase):
"""part 4:单元专属 resolv.conf/hosts 生成。"""
def _write(self, path, content):
with open(path, "w") as fh:
fh.write(content)
return path
def test_resolv_filters_stub_and_falls_back(self):
with tempfile.TemporaryDirectory() as tmp:
stub = self._write(os.path.join(tmp, "resolv.conf"),
"nameserver 127.0.0.53\n"
"options edns0\n")
real = self._write(os.path.join(tmp, "resolved.conf"),
"nameserver 8.8.8.8\n"
"nameserver 1.1.1.1\n")
files = CELL.prepare_cell_files(
"chv-cell-00", run_dir=tmp, resolv_conf=stub,
resolved_conf=real,
hosts_file=os.path.join(tmp, "no-hosts"))
with open(files["resolv"]) as fh:
content = fh.read()
self.assertEqual(content,
"nameserver 8.8.8.8\nnameserver 1.1.1.1\n")
self.assertNotIn("127.0.0.53", content)
with open(files["hosts"]) as fh:
self.assertIn("127.0.0.1 localhost", fh.read())
self.assertEqual(oct(os.stat(files["resolv"]).st_mode
& 0o777), "0o644")
self.assertEqual(files["gate"],
os.path.join(tmp, ".cell-gate-chv-cell-00"))
# gate 文件本身不在这里创建(run_cell 配网后才开门)。
self.assertFalse(os.path.exists(files["gate"]))
def test_resolv_real_servers_used_directly(self):
with tempfile.TemporaryDirectory() as tmp:
resolv = self._write(os.path.join(tmp, "resolv.conf"),
"nameserver 8.8.4.4\n")
files = CELL.prepare_cell_files(
"chv-cell-01", run_dir=tmp, resolv_conf=resolv,
resolved_conf=os.path.join(tmp, "missing"),
hosts_file=os.path.join(tmp, "missing-hosts"))
with open(files["resolv"]) as fh:
self.assertEqual(fh.read(), "nameserver 8.8.4.4\n")
def test_no_dns_anywhere_is_loud(self):
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(RuntimeError):
CELL.prepare_cell_files(
"chv-cell-00", run_dir=tmp,
resolv_conf=os.path.join(tmp, "missing"),
resolved_conf=os.path.join(tmp, "missing2"),
hosts_file=os.path.join(tmp, "missing-hosts"))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()