Files
archy/scripts/tests/os_ota_installed_vm.py

307 lines
17 KiB
Python

#!/usr/bin/env python3
"""Disposable installed Debian disks. Run in a durable CPU2/8G/no-swap job.
No host chroot, host package installation, containers, or writable host shares.
Direct-kernel cold boots are reported as such, never as GRUB/ISO acceptance.
All heavyweight work, including hashing/copying, is inside release-heavy.lock.
"""
import argparse
import fcntl
import hashlib
import io
import json
import os
import re
from pathlib import Path
import shutil
import subprocess
import tarfile
import time
ENTRY_SCRIPT = '''#!/bin/bash
set -u
ip link set ens3 up 2>/dev/null || ip link set eth0 up
iface=$(ls /sys/class/net | grep -v '^lo$' | head -1)
ip addr add 10.0.2.15/24 dev "$iface" || true
ip route add default via 10.0.2.2 || true
status=0
if [ -f /opt/os-ota-payload.tar ]; then
mkdir -p /opt/ota-source
tar -xf /opt/os-ota-payload.tar -C /opt/ota-source || status=$?
fi
if [ "$status" -eq 0 ]; then
python3 -B /opt/os-ota-guest.py > /var/log/os-ota-guest.log 2>&1
status=$?
fi
echo "$status" > /var/log/os-ota-guest.exit
sync
echo "OS_OTA_GUEST_EXIT=$status"
cat /var/log/os-ota-guest.log
sync
systemctl poweroff
'''
def sha(path):
with Path(path).open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
HARNESS_SHA256 = sha(__file__)
def run(cmd, log, timeout=1800):
with log.open('xb') as stream:
result = subprocess.run([str(x) for x in cmd], stdout=stream,
stderr=subprocess.STDOUT, timeout=timeout)
if result.returncode:
raise RuntimeError(f'{cmd[0]} exit {result.returncode}: {log}')
def inject(disk, source, target, log, private=False):
# debugfs commands are not shell commands; restrict their grammar nevertheless.
for value in (str(source), target):
if any(c.isspace() or c in '\"\\' for c in value):
raise ValueError('unsupported debugfs path')
run(['/usr/sbin/debugfs', '-w', '-R', f'rm {target}', disk], log.with_suffix('.rm.log'))
run(['/usr/sbin/debugfs', '-w', '-R', f'write {source} {target}', disk], log)
verified=log.with_suffix('.readback')
if private:
verified.touch(mode=0o600,exist_ok=False)
run(['/usr/sbin/debugfs','-R',f'dump {target} {verified}',disk],log.with_suffix('.readback.log'))
if sha(verified) != sha(source):
raise ValueError('offline disk injection readback mismatch')
def owned_disk(path):
if path.is_symlink():
raise ValueError('symlink disk refused')
disk = path.resolve(strict=True)
if not disk.is_file() or disk.name != 'installed.raw' or not (disk.parent/'result.json').is_file():
raise ValueError('only harness-owned regular installed.raw disks accepted')
preparation = json.loads((disk.parent/'result.json').read_text())
if preparation.get('action') != 'prepare' or not preparation.get('passed') or preparation.get('disk') != str(disk):
raise ValueError('missing successful disk ownership receipt')
return disk
def source_archive(root, destination):
manifest = {}
with tarfile.open(destination, 'x') as archive:
for directory in ('scripts', 'image-recipe/configs', 'tests/regression'):
for path in sorted((root/directory).rglob('*')):
if path.is_file() and not path.is_symlink() and '__pycache__' not in str(path) and path.suffix in ('.py','.json','.sh','.service','.conf','.in'):
relative=str(path.relative_to(root))
raw=path.read_bytes()
manifest[relative]=hashlib.sha256(raw).hexdigest()
info=archive.gettarinfo(path,arcname=relative)
info.size=len(raw)
info.uid=info.gid=0
info.uname=info.gname='root'
info.mode=0o755 if info.mode & 0o111 else 0o644
archive.addfile(info,io.BytesIO(raw))
public=root/'release-manifest.json'
if public.is_file() and not public.is_symlink():
raw=public.read_bytes()
manifest['release-manifest.json']=hashlib.sha256(raw).hexdigest()
info=tarfile.TarInfo('release-manifest.json')
info.size=len(raw)
info.mode=0o644
archive.addfile(info,io.BytesIO(raw))
return manifest
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--output', type=Path, required=True)
sub = p.add_subparsers(dest='action', required=True)
prep = sub.add_parser('prepare')
prep.add_argument('--rootfs', type=Path, required=True)
prep.add_argument('--receipt', type=Path, required=True)
prep.add_argument('--kernel-release', required=True)
boot = sub.add_parser('boot')
boot.add_argument('--disk', type=Path, required=True)
boot.add_argument('--kernel', type=Path, required=True)
boot.add_argument('--initrd', type=Path, required=True)
boot.add_argument('--guest', type=Path, required=True)
boot.add_argument('--payload', type=Path)
boot.add_argument('--source-root', type=Path)
boot.add_argument('--native-artifact',type=Path,help='retained real non-libtest candidate artifact directory')
boot.add_argument('--crypto-fixture',type=Path,help='separate compile-time disposable test-root artifact; NOT candidate')
boot.add_argument('--preboot-evidence',type=Path,help='external retained completed ledger before guest reboot')
boot.add_argument('--timeout', type=int, default=1800)
boot.add_argument('--offline', action='store_true')
boot.add_argument('--firmware', action='store_true', help='boot private GRUB disk instead of direct kernel')
boot.add_argument('--scenario', default='none', choices=['none', 'locked-apt', 'offline', 'space', 'space-prepare', 'bad-hash', 'missing-dependency', 'partial-dpkg', 'interrupted-before'])
export = sub.add_parser('export')
export.add_argument('--disk', type=Path, required=True)
export.add_argument('--kernel-release', required=True)
clone = sub.add_parser('clone')
clone.add_argument('--disk', type=Path, required=True)
args = p.parse_args()
if os.geteuid() != 0:
p.error('requires root only for owned disk preparation; run in durable limited service')
# Refuse accidentally unrestricted execution even when root.
cg = Path('/proc/self/cgroup').read_text().strip().split('::')[-1]
cgroup = Path('/sys/fs/cgroup') / cg.lstrip('/')
cpu = (cgroup / 'cpu.max').read_text().split()
memory = (cgroup / 'memory.max').read_text().strip()
swap = (cgroup / 'memory.swap.max').read_text().strip()
if cpu[0] == 'max' or int(cpu[0]) > 2 * int(cpu[1]) or memory == 'max' or int(memory) > 8*1024**3 or swap != '0':
p.error('job must enforce CPUQuota<=200%, MemoryMax<=8G, MemorySwapMax=0')
with open('/tmp/opencode/release-heavy.lock', 'a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
if shutil.disk_usage(args.output.parent).free < 24*1024**3:
p.error('24 GiB free required; preserve previous receipts')
args.output.mkdir(mode=0o755, exist_ok=False)
out = args.output.resolve()
result = {'scope': 'disposable-installed-Debian; unsigned root-appointed fixture only',
'production_approved': False, 'harness_sha256': HARNESS_SHA256,
'action': args.action, 'started': time.time(), 'passed': False}
try:
if args.action == 'prepare':
receipt = json.loads(args.receipt.read_text())
expected = receipt['files'][args.rootfs.name]['sha256']
if sha(args.rootfs) != expected:
raise ValueError('rootfs receipt hash mismatch')
result['rootfs_sha256'] = expected
root = out / 'rootfs'
root.mkdir()
run(['tar', '--numeric-owner', '-xf', args.rootfs, '-C', root], out/'extract.log')
release = args.kernel_release
if '/' in release or not release.startswith('6.'):
raise ValueError('invalid kernel release')
shutil.copytree('/usr/lib/modules/'+release, root/'usr/lib/modules'/release, symlinks=True)
for name in ('vmlinuz-', 'initrd.img-'):
shutil.copyfile('/boot/'+name+release, out/(name+release))
(root/'etc/fstab').write_text('/dev/vda / ext4 defaults 0 1\n')
(root/'etc/resolv.conf').unlink(missing_ok=True)
(root/'etc/resolv.conf').write_text('nameserver 10.0.2.3\n')
(root/'etc/systemd/system/os-ota-fixture.service').write_text('''[Unit]
Description=Disposable OS OTA qualification
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/bin/bash /opt/os-ota-entry.sh
StandardOutput=journal+console
StandardError=journal+console
[Install]
WantedBy=multi-user.target
''')
wants = root/'etc/systemd/system/multi-user.target.wants'
wants.mkdir(exist_ok=True)
(wants/'os-ota-fixture.service').symlink_to('../os-ota-fixture.service')
(root/'opt/os-ota-entry.sh').write_text(ENTRY_SCRIPT)
disk = out/'installed.raw'
with disk.open('xb') as stream:
stream.truncate(8*1024**3)
run(['/usr/sbin/mkfs.ext4', '-F', '-L', 'os-ota-private', '-d', root, disk], out/'mkfs.log')
result.update(disk=str(disk), boot_mode='direct kernel; ext4 installed root')
elif args.action == 'clone':
disk = owned_disk(args.disk)
destination = out/'installed.raw'
run(['cp', '--reflink=auto', '--sparse=always', disk, destination], out/'clone.log')
if (disk.parent/'boot.raw').is_file():
run(['cp','--reflink=auto','--sparse=always',disk.parent/'boot.raw',out/'boot.raw'],out/'clone-boot.log')
result.update(action='prepare', disk=str(destination), clone_origin=str(disk),
scope='independent disposable fault disk; never restore onto source VM')
elif args.action == 'export':
disk = owned_disk(args.disk)
release = args.kernel_release
if '/' in release or not release.startswith('6.') or any(c.isspace() for c in release):
raise ValueError('invalid kernel release')
result['files'] = {}
for prefix in ('vmlinuz-', 'initrd.img-'):
name = prefix + release
run(['/usr/sbin/debugfs', '-R', f'dump /boot/{name} {out}/{name}', disk], out/(name+'.log'))
result['files'][name] = sha(out/name)
run(['/usr/sbin/debugfs', '-R', f'dump /var/log/os-ota-baseline/receipt.json {out}/baseline.json', disk], out/'baseline-export.log')
run(['/usr/sbin/debugfs', '-R', f'dump /boot/grub/grub.cfg {out}/grub.cfg', disk], out/'grub-export.log')
result['disk'] = str(disk)
else:
disk = owned_disk(args.disk)
(out/'entry.sh').write_text(ENTRY_SCRIPT)
inject(disk,out/'entry.sh','/opt/os-ota-entry.sh',out/'entry.log')
# Never accept a previous boot's exit after an interrupted run.
(out/'exit.pending').write_text('PENDING\n')
inject(disk,out/'exit.pending','/var/log/os-ota-guest.exit',out/'exit-reset.log')
inject(disk, args.guest.resolve(), '/opt/os-ota-guest.py', out/'inject.log')
(out/'scenario').write_text(args.scenario)
inject(disk, out/'scenario', '/opt/os-ota-scenario', out/'scenario.log')
if args.preboot_evidence:
inject(disk,args.preboot_evidence,'/opt/os-ota-external-preboot-ledger.json',out/'preboot-ledger.log')
result['external_preboot_ledger_sha256']=sha(args.preboot_evidence)
if args.crypto_fixture:
proof=json.loads((args.crypto_fixture/'fixture-proof.json').read_text())
if proof['scope']!='ISOLATED_COMPILETIME_TEST_ROOT_NOT_CANDIDATE' or proof['binary_sha256']!=sha(args.crypto_fixture/'fixture-native'):
raise ValueError('isolated crypto fixture identity mismatch')
if (args.crypto_fixture/'fixture.seed').stat().st_size!=32:
raise ValueError('disposable seed must be exactly 32 bytes')
result['crypto_fixture']=proof
inject(disk,args.crypto_fixture/'fixture-native','/opt/os-ota-crypto-native',out/'crypto-native.log')
inject(disk,args.crypto_fixture/'fixture-proof.json','/opt/os-ota-crypto-fixture.json',out/'crypto-proof.log')
inject(disk,args.crypto_fixture/'fixture.seed','/root/os-ota-crypto.seed',out/'crypto-seed.log',private=True)
if args.native_artifact:
proof=json.loads((args.native_artifact/'native-derivation.json').read_text())
if proof['packaged_sha256']!=sha(args.native_artifact/'normal-elf') or proof['source_manifest_sha256']!=sha(args.native_artifact/'source-sha256.json'):
raise ValueError('native artifact/source proof mismatch')
if json.loads((args.native_artifact/'compile-result.json').read_text())['exit']!=0:
raise ValueError('native build failed')
result['native_artifact']=proof
inject(disk,args.native_artifact/'normal-elf','/opt/os-ota-native',out/'native.log')
(out/'native-proof.json').write_text(json.dumps(proof,indent=2)+'\n')
inject(disk,out/'native-proof.json','/opt/os-ota-native-proof.json',out/'native-proof.log')
if args.source_root:
if args.payload:
raise ValueError('choose source-root or payload')
args.payload = out/'source.tar'
manifest = source_archive(args.source_root,args.payload)
(out/'source-sha256.json').write_text(json.dumps(manifest, indent=2)+'\n')
if args.payload:
inject(disk, args.payload.resolve(), '/opt/os-ota-payload.tar', out/'payload.log')
result['payload_sha256'] = sha(args.payload)
result.update(guest_sha256=sha(args.guest), kernel_sha256=sha(args.kernel),
initrd_sha256=sha(args.initrd), disk=str(disk))
bootdisk = disk.parent/'boot.raw'
if bootdisk.is_symlink():
raise ValueError('boot disk symlink refused')
if not bootdisk.exists():
with bootdisk.open('xb') as stream:
stream.truncate(64*1024**2)
if not bootdisk.is_file() or bootdisk.stat().st_size != 64*1024**2:
raise ValueError('only owned 64 MiB regular boot disk accepted')
cmd = ['qemu-system-x86_64', '-machine', 'pc', '-accel', 'kvm',
'-m', '2048', '-smp', '2', '-display', 'none', '-serial', 'stdio',
'-monitor', 'none', '-no-reboot', '-drive', f'file={disk},format=raw,if=virtio',
'-drive', f'file={bootdisk},format=raw,if=ide,index=0',
'-nic', 'none' if args.offline else 'user,model=virtio-net-pci']
if args.firmware:
cmd += ['-boot','c']
else:
cmd += ['-kernel',str(args.kernel),'-initrd',str(args.initrd),
'-append','root=/dev/vda rw console=ttyS0 net.ifnames=0 noresume systemd.unit=multi-user.target']
result['boot_mode'] = 'BIOS/GRUB private boot disk' if args.firmware else 'direct kernel'
result['command'] = cmd
run(cmd, out/'serial.log', args.timeout)
text = (out/'serial.log').read_text(errors='replace')
run(['/usr/sbin/debugfs', '-R', f'dump /var/log/os-ota-guest.log {out}/guest.log', disk], out/'extract-guest.log')
run(['/usr/sbin/debugfs', '-R', f'rdump /var/log/os-ota-stage {out}', disk], out/'extract-evidence.log')
exits = re.findall(r'OS_OTA_GUEST_EXIT=(\d+)', text)
result['serial_guest_exit'] = int(exits[-1]) if exits else None
run(['/usr/sbin/debugfs','-R',f'dump /var/log/os-ota-guest.exit {out}/durable-guest-exit.txt',disk],out/'extract-exit.log')
raw_exit=(out/'durable-guest-exit.txt').read_text().strip()
result['guest_exit']=int(raw_exit) if raw_exit.isdigit() else None
result['guest_exit_source']='private disk durable exit file; reset before boot'
if result['guest_exit'] != 0:
raise RuntimeError('guest did not report success; inspect serial.log')
result['passed'] = True
except Exception as error:
result['error'] = str(error)
raise
finally:
result['ended'] = time.time()
(out/'result.json').write_text(json.dumps(result, indent=2)+'\n')
if __name__ == '__main__':
main()