scripts/disk-image.sh ahora arma una imagen GPT de 3 particiones ext4 en vez
de una sola: vda1=/ , vda2=/store (CAS inmutable), vda3=/var/lib/hammer (estado
mutable). Construcción sin root ni loopback: cp -al stagea el rootfs por
hardlinks (vacía store/ y var/lib/hammer/, instala el wrapper /sbin/init),
mke2fs -d puebla cada ext4 bajo unshare -r (root-owned), sfdisk escribe la GPT
y dd conv=sparse,notrunc empalma cada fs en su offset (imagen sparse, ~2G
reales). El wrapper /sbin/init monta vda2/vda3 y hace exec de arje-zero (el
kernel sólo monta vda1). drive-rebuild.py: root=/dev/vda1 en modo DISK.
Consecuencia resuelta: con /store en su propia partición el sellado cruza
filesystems y rename(2) da EXDEV. Store::seal cae a copia recursiva a un
staging dentro del store (preserva symlinks+modos) + rename store-interno
(atómico, mismo FS) + borrado del origen. Test copy_tree añadido.
Verificado in-VM (kernel hammer, KVM): vda{1,2,3} montados dedicados, 0
errores Cross-device, stage1' == stage1 ✓ REPRODUCIBLE. Cierra el ☐ de
SDD 11 §6 (particionado/montaje en la imagen destino).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
152 lines
6.8 KiB
Python
Executable File
152 lines
6.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# drive-rebuild.py — Driver NO-interactivo del rebuild in-rootfs (auto-alojamiento, SDD 11 §7;
|
|
# runbook docs/runbooks/stage1-vm-boot.md §8c).
|
|
#
|
|
# Bootea el builder en QEMU, espera la shell de arje-zero (PID 1), manda `rebuild-stage1` y captura
|
|
# todo hasta el veredicto (✓ REPRODUCIBLE / ✗ DIVERGENTE), el RC o el timeout. Imprime un resumen y
|
|
# sale con 0 si REPRODUCIBLE, 1 si DIVERGENTE/otro.
|
|
#
|
|
# Variables de entorno (todas opcionales):
|
|
# BUILDER_CPIO initramfs del builder (default work/builder.cpio.gz)
|
|
# DISK imagen de disco PARTICIONADA (GPT) a bootear como ROOT real (Etapa B3). Si está
|
|
# seteada, se ignora el initramfs: QEMU monta la imagen como disco virtio y el kernel
|
|
# arranca con `root=/dev/vda1 rw rdinit=/sbin/init` — sin initramfs ni switch_root (/ ya
|
|
# es un mount ext4 real, pivotable). La partición 1 es /, y el wrapper /sbin/init monta
|
|
# las particiones dedicadas /store (vda2) y /var/lib/hammer (vda3) antes del init real.
|
|
# La arma scripts/disk-image.sh. Requiere kernel con VIRTIO_BLK+EXT4 (recipes/linux.toml).
|
|
# KERNEL bzImage/vmlinuz a bootear (default /boot/vmlinuz-linux)
|
|
# MEM RAM de la VM en MiB (default 6144)
|
|
# CPU modelo de CPU TCG (default Broadwell; AVX2 sin KVM)
|
|
# KVM 1 ⇒ -enable-kvm -cpu host si hay /dev/kvm (mucho más rápido; recomendado)
|
|
# DEADLINE techo en segundos (default 14400 = 4h)
|
|
# LOG fichero de captura (default work/rebuild-run.log)
|
|
# NET 1 ⇒ NIC e1000 user-mode para el vendoring Rust (default 1)
|
|
#
|
|
# El rebuild Rust (hammerd/arje-zero) hace `cargo vendor` desde crates.io ⇒ necesita red: por eso la
|
|
# NIC e1000 (qemu user-mode 10.0.2.0/24). Sin red, sólo sellan los componentes C.
|
|
import os, pty, select, subprocess, sys, time, re
|
|
|
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
|
|
def envpath(name, default):
|
|
v = os.environ.get(name, default)
|
|
return v if os.path.isabs(v) else os.path.join(ROOT, v)
|
|
|
|
|
|
CPIO = envpath("BUILDER_CPIO", "work/builder.cpio.gz")
|
|
DISK = os.environ.get("DISK", "") # Etapa B: si está, booteamos de disco real en vez de initramfs.
|
|
if DISK and not os.path.isabs(DISK):
|
|
DISK = os.path.join(ROOT, DISK)
|
|
KERNEL = os.environ.get("KERNEL", "/boot/vmlinuz-linux")
|
|
LOG = envpath("LOG", "work/rebuild-run.log")
|
|
MEM = os.environ.get("MEM", "6144")
|
|
CPU = os.environ.get("CPU", "Broadwell")
|
|
DEADLINE = int(os.environ.get("DEADLINE", "14400"))
|
|
WANT_KVM = os.environ.get("KVM", "0") == "1"
|
|
WANT_NET = os.environ.get("NET", "1") == "1"
|
|
|
|
_needed = [(DISK, "imagen de disco")] if DISK else [(CPIO, "initramfs del builder")]
|
|
for p, what in _needed + [(KERNEL, "kernel")]:
|
|
if not os.path.exists(p):
|
|
sys.exit(f"no existe {what}: {p}")
|
|
|
|
accel = ["-cpu", CPU]
|
|
if WANT_KVM and os.access("/dev/kvm", os.W_OK):
|
|
accel = ["-enable-kvm", "-cpu", "host"]
|
|
print("==> KVM activo (-cpu host)")
|
|
else:
|
|
print(f"==> TCG (sin KVM); -cpu {CPU}. Lento — el rebuild Rust puede tardar.")
|
|
|
|
net = ["-netdev", "user,id=n0", "-device", "e1000,netdev=n0"] if WANT_NET else []
|
|
|
|
# APPEND override: por defecto consola serie + initramfs. Útil para experimentar con params del kernel
|
|
# (p.ej. el frente kernel-from-source: el bzImage hammer bootea pero bwrap falla en pivot_root porque /
|
|
# es el rootfs absoluto del namespace; `rootfstype=tmpfs` NO lo arregla — la causa es que / no tiene
|
|
# mount padre, no el tipo de fs). Override con APPEND="…".
|
|
# Boot de disco (Etapa B3) vs initramfs. En disco / ya es un mount ext4 real ⇒ rdinit=/sbin/init directo,
|
|
# sin el wrapper switch_root del path initramfs. La GPT tiene 3 particiones: vda1=/ , vda2=/store ,
|
|
# vda3=/var/lib/hammer; el kernel monta vda1 y el wrapper /sbin/init monta las otras dos.
|
|
if DISK:
|
|
APPEND = os.environ.get("APPEND", "console=ttyS0 root=/dev/vda1 rw rdinit=/sbin/init")
|
|
media = ["-drive", f"file={DISK},if=virtio,format=raw", "-append", APPEND]
|
|
else:
|
|
APPEND = os.environ.get("APPEND", "console=ttyS0 rdinit=/sbin/init")
|
|
media = ["-initrd", CPIO, "-append", APPEND]
|
|
QEMU = (["qemu-system-x86_64", "-m", MEM, "-no-reboot", "-nographic"] + accel + net +
|
|
["-kernel", KERNEL] + media)
|
|
|
|
print(f"==> kernel : {KERNEL}")
|
|
print(f"==> {'disco ' if DISK else 'initramfs '}: {DISK or CPIO}")
|
|
print(f"==> mem : {MEM} MiB deadline: {DEADLINE}s net: {'sí' if WANT_NET else 'no'}")
|
|
print(f"==> log : {LOG}")
|
|
|
|
ansi = re.compile(rb'\x1b\[[0-9;?]*[a-zA-Z]')
|
|
strip = lambda b: ansi.sub(b'', b)
|
|
|
|
master, slave = pty.openpty()
|
|
p = subprocess.Popen(QEMU, stdin=slave, stdout=slave, stderr=slave, close_fds=True)
|
|
os.close(slave)
|
|
log = open(LOG, "wb")
|
|
buf = b""
|
|
sent = False
|
|
start = time.time()
|
|
send = lambda s: os.write(master, s.encode())
|
|
|
|
while True:
|
|
if time.time() - start > DEADLINE:
|
|
log.write(b"\n[DRIVER] DEADLINE\n")
|
|
break
|
|
r, _, _ = select.select([master], [], [], 10)
|
|
if r:
|
|
try:
|
|
data = os.read(master, 8192)
|
|
except OSError:
|
|
break
|
|
if not data:
|
|
break
|
|
log.write(data)
|
|
log.flush()
|
|
buf = strip(buf + data)[-20000:]
|
|
# Boot listo: arje-zero levantó hammerd (fanotify) o apareció la shell.
|
|
if not sent and (b"watcher fanotify activo" in buf or b"\n~ #" in buf or b"\r~ #" in buf):
|
|
time.sleep(3)
|
|
send("echo DRIVER_BEGIN; rebuild-stage1; echo DRIVER_RC=$?\n")
|
|
sent = True
|
|
log.write(b"\n[DRIVER] enviado rebuild-stage1\n")
|
|
log.flush()
|
|
# OJO: el comando tecleado ECHOA "DRIVER_RC=$?"; el marcador real es la SALIDA
|
|
# "DRIVER_RC=<digito>". Matchear sólo eso (o el veredicto) evita salir antes de tiempo.
|
|
if sent and (re.search(rb'DRIVER_RC=[0-9]', buf) or b"REPRODUCIBLE" in buf or b"DIVERGENTE" in buf):
|
|
time.sleep(2)
|
|
try:
|
|
log.write(os.read(master, 8192))
|
|
except Exception:
|
|
pass
|
|
log.write(b"\n[DRIVER] FIN (marcador)\n")
|
|
break
|
|
else:
|
|
if p.poll() is not None:
|
|
log.write(b"\n[DRIVER] qemu salio\n")
|
|
break
|
|
|
|
try:
|
|
p.terminate()
|
|
time.sleep(2)
|
|
p.kill()
|
|
except Exception:
|
|
pass
|
|
log.close()
|
|
|
|
# Veredicto a partir de lo capturado.
|
|
blob = strip(open(LOG, "rb").read())
|
|
ok = (b"REPRODUCIBLE" in blob) and (b"DIVERGENTE" not in blob)
|
|
print(f"\nDRIVER done sent={sent} elapsed={int(time.time() - start)}s")
|
|
if ok:
|
|
print("RESULTADO: ✓ REPRODUCIBLE — auto-alojamiento bit a bit verificado")
|
|
elif b"DIVERGENTE" in blob:
|
|
print("RESULTADO: ✗ DIVERGENTE — hay no-determinismo que cazar (SDD 09 §2)")
|
|
else:
|
|
print("RESULTADO: ? incompleto (timeout/boot/OOM) — revisá el log")
|
|
sys.exit(0 if ok else 1)
|