Compare commits
7
Commits
0.2.0
...
feat/validation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a636924df | ||
|
|
17ade497a4 | ||
|
|
32e9334feb | ||
|
|
c490991054 | ||
|
|
972b82c24b | ||
|
|
f46fef764b | ||
|
|
e93820bd52 |
@@ -0,0 +1,911 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Boot/install/reboot acceptance on a fresh, isolated UEFI/KVM VM only.
|
||||||
|
|
||||||
|
No existing disk option, host sudo, SSH, host TTY, or GUI is used. Artifacts and
|
||||||
|
logs remain in a new private scratch directory even after failure.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import select
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from hydra.qmp import QMPClient, QMPError
|
||||||
|
|
||||||
|
|
||||||
|
class SmokeError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def lifecycle_socket(path, *, connect_socket=True):
|
||||||
|
path = Path(path)
|
||||||
|
info = path.lstat()
|
||||||
|
if not stat.S_ISSOCK(info.st_mode) or info.st_uid != os.getuid():
|
||||||
|
raise SmokeError('Lifecycle socket is not owned by the installed user')
|
||||||
|
identity = [info.st_dev, info.st_ino, info.st_uid, info.st_mode]
|
||||||
|
if connect_socket:
|
||||||
|
with socket.socket(socket.AF_UNIX) as connection:
|
||||||
|
connection.settimeout(2)
|
||||||
|
connection.connect(str(path))
|
||||||
|
after = path.lstat()
|
||||||
|
if identity != [after.st_dev, after.st_ino, after.st_uid, after.st_mode]:
|
||||||
|
raise SmokeError('Lifecycle socket changed while connecting')
|
||||||
|
return identity
|
||||||
|
|
||||||
|
|
||||||
|
def lifecycle_snapshot(context):
|
||||||
|
"""Prove live identity and owned runtime endpoints, never trust PID alone."""
|
||||||
|
try:
|
||||||
|
if context['uid'] != os.getuid():
|
||||||
|
raise SmokeError('Lifecycle probe is not the installed normal user')
|
||||||
|
runtime = Path(context['runtime'])
|
||||||
|
run = runtime / context['name']
|
||||||
|
directories = {}
|
||||||
|
for directory in (runtime, run):
|
||||||
|
info = directory.lstat()
|
||||||
|
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid()
|
||||||
|
or stat.S_IMODE(info.st_mode) != 0o700):
|
||||||
|
raise SmokeError('Lifecycle runtime must remain private and owned')
|
||||||
|
directories[str(directory)] = [info.st_dev, info.st_ino, info.st_mode]
|
||||||
|
metadata = json.loads((run / 'identity.json').read_text())
|
||||||
|
if metadata != {'name': context['name'], 'uuid': context['uuid']}:
|
||||||
|
raise SmokeError('Lifecycle metadata is stale or foreign')
|
||||||
|
pid = int((run / 'qemu.pid').read_text())
|
||||||
|
process = Path('/proc') / str(pid)
|
||||||
|
if process.stat().st_uid != os.getuid():
|
||||||
|
raise SmokeError('Lifecycle process belongs to another user')
|
||||||
|
fields = (process / 'stat').read_text().rsplit(')', 1)[1].split()
|
||||||
|
if fields[0] == 'Z':
|
||||||
|
raise SmokeError('Lifecycle process has exited')
|
||||||
|
argv = (process / 'cmdline').read_bytes().split(b'\0')
|
||||||
|
for option, expected in ((b'-name', context['name']), (b'-uuid', context['uuid'])):
|
||||||
|
if argv[argv.index(option) + 1] != expected.encode():
|
||||||
|
raise SmokeError('Lifecycle PID is not the owned VM identity')
|
||||||
|
endpoints = {'qmp': lifecycle_socket(run / 'qmp.sock', connect_socket=False)}
|
||||||
|
with QMPClient(run / 'qmp.sock', timeout=2) as client:
|
||||||
|
if (client.command('query-name') != {'name': context['name']}
|
||||||
|
or client.command('query-uuid') != {'UUID': context['uuid']}):
|
||||||
|
raise SmokeError('Lifecycle QMP identity is foreign')
|
||||||
|
if client.command('query-status').get('status') != 'running':
|
||||||
|
raise SmokeError('Lifecycle VM is not running')
|
||||||
|
if not client.command('query-kvm').get('enabled'):
|
||||||
|
raise SmokeError('Lifecycle VM did not initialise nested KVM')
|
||||||
|
endpoints['spice'] = lifecycle_socket(run / 'spice.sock')
|
||||||
|
audio = Path(context['user_runtime'])
|
||||||
|
endpoints['pipewire'] = lifecycle_socket(audio / 'pipewire-0')
|
||||||
|
endpoints['pulse'] = lifecycle_socket(audio / 'pulse/native')
|
||||||
|
services = {}
|
||||||
|
for service in context.get('audio_services', []):
|
||||||
|
subprocess.run(['systemctl', '--user', 'is-active', service], check=True,
|
||||||
|
capture_output=True, timeout=5)
|
||||||
|
services[service] = subprocess.run(['systemctl', '--user', 'show', service,
|
||||||
|
'-p', 'InvocationID', '-p', 'ExecMainPID'], check=True,
|
||||||
|
capture_output=True, text=True, timeout=5).stdout
|
||||||
|
return {'name': context['name'], 'uuid': context['uuid'], 'pid': pid,
|
||||||
|
'process_start': fields[19], 'directories': directories,
|
||||||
|
'sockets': endpoints, 'audio_services': services}
|
||||||
|
except (OSError, ValueError, KeyError, IndexError, QMPError, subprocess.SubprocessError) as exc:
|
||||||
|
raise SmokeError('Lifecycle runtime or process was lost: ' + str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def verify_lifecycle_survival(before, after):
|
||||||
|
if before != after:
|
||||||
|
raise SmokeError('VM identity, private runtime, or audio endpoints changed across logout')
|
||||||
|
|
||||||
|
|
||||||
|
def login_session_records(uid):
|
||||||
|
"""Read logind's public properties, avoiding display-table row semantics."""
|
||||||
|
def query(*args):
|
||||||
|
return subprocess.run(['loginctl', *args], check=True, capture_output=True,
|
||||||
|
text=True, timeout=5).stdout
|
||||||
|
try:
|
||||||
|
ids = query('show-user', str(uid), '-p', 'Sessions', '--value').split()
|
||||||
|
records = []
|
||||||
|
for ident in ids:
|
||||||
|
output = query('show-session', ident, '--all', '--no-pager',
|
||||||
|
'-p', 'Id', '-p', 'User', '-p', 'State', '-p', 'Class',
|
||||||
|
'-p', 'Leader', '-p', 'TTY')
|
||||||
|
record = dict(line.split('=', 1) for line in output.splitlines())
|
||||||
|
if not {'Id', 'User', 'State', 'Class', 'Leader', 'TTY'} <= record.keys():
|
||||||
|
raise SmokeError('Incomplete logind session identity')
|
||||||
|
record['User'], record['Leader'] = int(record['User']), int(record['Leader'])
|
||||||
|
if record['Id'] != ident or record['User'] != uid:
|
||||||
|
raise SmokeError('Logind returned a foreign session identity')
|
||||||
|
records.append(record)
|
||||||
|
return records
|
||||||
|
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
||||||
|
raise SmokeError('Cannot establish guest login session state: ' + str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def is_open_login(record):
|
||||||
|
# systemd v258: closing session scopes can remain while user processes
|
||||||
|
# survive. Manager-class records describe bookkeeping, not a PAM login.
|
||||||
|
users = {'user', 'user-early', 'user-incomplete', 'user-light', 'user-early-light'}
|
||||||
|
bookkeeping = {'manager', 'manager-early', 'background', 'background-light',
|
||||||
|
'greeter', 'lock-screen', 'none'}
|
||||||
|
if record['State'] not in {'opening', 'online', 'active', 'closing'}:
|
||||||
|
raise SmokeError('Unknown logind session state')
|
||||||
|
if record['Class'] not in users | bookkeeping:
|
||||||
|
raise SmokeError('Unknown logind session class')
|
||||||
|
return record['Class'] in users and record['State'] != 'closing'
|
||||||
|
|
||||||
|
|
||||||
|
def require_initial_login(uid, ident):
|
||||||
|
opened = [record for record in login_session_records(uid) if is_open_login(record)]
|
||||||
|
if (len(opened) != 1 or opened[0]['Id'] != ident or opened[0]['TTY'] != 'ttyS0'
|
||||||
|
or opened[0]['Leader'] <= 0 or opened[0]['State'] not in {'online', 'active'}):
|
||||||
|
raise SmokeError('Lifecycle test requires exactly its actual serial PAM login')
|
||||||
|
return opened[0]
|
||||||
|
|
||||||
|
|
||||||
|
def no_open_logins(context):
|
||||||
|
original = context['login_session']
|
||||||
|
records = login_session_records(context['uid'])
|
||||||
|
for record in records:
|
||||||
|
if record['Id'] == original['Id']:
|
||||||
|
if (record['User'] != original['User'] or record['TTY'] != original['TTY']
|
||||||
|
or record['Class'] != original['Class']
|
||||||
|
or record['Leader'] not in {0, original['Leader']}):
|
||||||
|
raise SmokeError('Original PAM session identity was replaced')
|
||||||
|
if record['State'] != 'closing':
|
||||||
|
return False
|
||||||
|
if is_open_login(record):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def capture_logout_snapshot(context):
|
||||||
|
if not no_open_logins(context):
|
||||||
|
raise SmokeError('An open login overlaps the logout sample')
|
||||||
|
during = lifecycle_snapshot(context)
|
||||||
|
if not no_open_logins(context):
|
||||||
|
raise SmokeError('A login opened while the logout sample was running')
|
||||||
|
verify_lifecycle_survival(context['before'], during)
|
||||||
|
return during
|
||||||
|
|
||||||
|
|
||||||
|
LIFECYCLE_BODY = r'''
|
||||||
|
def run(*args):
|
||||||
|
return subprocess.run(args, check=True, capture_output=True, text=True, timeout=40).stdout
|
||||||
|
|
||||||
|
def cli(context, *args):
|
||||||
|
return json.loads(run('hydra', '--config', context['config'], '--state', context['state'],
|
||||||
|
'--runtime', context['runtime'], *args))
|
||||||
|
|
||||||
|
def write_report(path, data):
|
||||||
|
temporary = path.with_suffix('.tmp')
|
||||||
|
temporary.write_text(json.dumps(data, indent=2) + '\n')
|
||||||
|
temporary.chmod(0o600)
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
mode, base_arg = sys.argv[1:]
|
||||||
|
base = Path(base_arg)
|
||||||
|
context_path = base / 'context.json'
|
||||||
|
observation_path = base / 'logout-observation.json'
|
||||||
|
if mode == 'start':
|
||||||
|
if not Path('/dev/kvm').exists():
|
||||||
|
print('SKIP: installed guest nested KVM unavailable')
|
||||||
|
raise SystemExit(0)
|
||||||
|
if os.getuid() == 0 or not os.access('/dev/kvm', os.R_OK | os.W_OK):
|
||||||
|
raise SmokeError('Lifecycle test needs installed-user nested KVM access')
|
||||||
|
user_runtime = Path(os.environ['XDG_RUNTIME_DIR'])
|
||||||
|
if user_runtime != Path('/run/user') / str(os.getuid()):
|
||||||
|
raise SmokeError('Lifecycle test needs the real login runtime directory')
|
||||||
|
if run('loginctl', 'show-user', str(os.getuid()), '-p', 'Linger', '--value').strip() != 'no':
|
||||||
|
raise SmokeError('Lingering would mask the installed logout policy')
|
||||||
|
login_session = require_initial_login(os.getuid(), os.environ['XDG_SESSION_ID'])
|
||||||
|
name = 'logout-' + base.name.rsplit('-', 1)[1]
|
||||||
|
state = base / 'state'
|
||||||
|
runtime = user_runtime / base.name
|
||||||
|
context = {'uid': os.getuid(), 'name': name, 'config': str(base / 'config'),
|
||||||
|
'state': str(state), 'runtime': str(runtime), 'user_runtime': str(user_runtime),
|
||||||
|
'uuid': str(uuid.uuid5(uuid.NAMESPACE_URL, str(state / name / 'disk.qcow2'))),
|
||||||
|
'login_session': login_session,
|
||||||
|
'audio_services': ['pipewire.service', 'pipewire-pulse.service', 'wireplumber.service']}
|
||||||
|
run('systemctl', '--user', 'start', *context['audio_services'])
|
||||||
|
cli(context, 'add', name, '--memory', '256', '--cpus', '1', '--disk-size', '64M',
|
||||||
|
'--graphics', 'software', '--network', 'bridge', '--bridge', 'hydrabr0')
|
||||||
|
if cli(context, 'start', name).get('status') != 'running':
|
||||||
|
raise SmokeError('Real installed hydra CLI failed to start lifecycle VM')
|
||||||
|
try:
|
||||||
|
disk = Path(context['state']) / name / 'disk.qcow2'
|
||||||
|
info = disk.lstat()
|
||||||
|
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != os.getuid():
|
||||||
|
raise SmokeError('Lifecycle VM disk is not a new owned regular qcow2')
|
||||||
|
context['before'] = lifecycle_snapshot(context)
|
||||||
|
except Exception:
|
||||||
|
# CLI control is scoped to the newly created private identity.
|
||||||
|
cli(context, 'stop', name, '--force')
|
||||||
|
raise
|
||||||
|
write_report(context_path, context)
|
||||||
|
with open(base / 'monitor.log', 'ab', buffering=0) as log:
|
||||||
|
subprocess.Popen([sys.executable, __file__, 'monitor', str(base)],
|
||||||
|
stdin=subprocess.DEVNULL, stdout=log, stderr=log,
|
||||||
|
start_new_session=True)
|
||||||
|
print('READY: normal-user Hydra VM and audio sockets recorded before real logout')
|
||||||
|
elif mode == 'monitor':
|
||||||
|
context = json.loads(context_path.read_text())
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 35
|
||||||
|
while not no_open_logins(context):
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise SmokeError('Serial session did not actually log out')
|
||||||
|
time.sleep(.2)
|
||||||
|
# Exceed systemd's usual 10-second user-manager stop delay. No helper
|
||||||
|
# login or lingering is allowed to keep the tested user session alive.
|
||||||
|
time.sleep(15)
|
||||||
|
during = capture_logout_snapshot(context)
|
||||||
|
write_report(observation_path, {'status': 'passed', 'no_open_logins': True,
|
||||||
|
'survival_seconds': 15, 'during_logout': during})
|
||||||
|
except Exception as exc:
|
||||||
|
write_report(observation_path, {'status': 'failed', 'error': str(exc)})
|
||||||
|
raise
|
||||||
|
elif mode == 'finish':
|
||||||
|
context = json.loads(context_path.read_text())
|
||||||
|
try:
|
||||||
|
if os.environ.get('XDG_RUNTIME_DIR') != context['user_runtime']:
|
||||||
|
raise SmokeError('Relogin replaced the user runtime path')
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while not observation_path.is_file():
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise SmokeError('Logout monitor died or did not record survival')
|
||||||
|
time.sleep(.2)
|
||||||
|
observation = json.loads(observation_path.read_text())
|
||||||
|
if observation.get('status') != 'passed' or observation.get('no_open_logins') is not True:
|
||||||
|
raise SmokeError('No-open-login observation failed: ' + json.dumps(observation))
|
||||||
|
after = lifecycle_snapshot(context)
|
||||||
|
verify_lifecycle_survival(context['before'], after)
|
||||||
|
if cli(context, 'status', context['name']).get('status') != 'running':
|
||||||
|
raise SmokeError('Real installed hydra CLI could not reconnect after login')
|
||||||
|
print(json.dumps({'status': 'passed', 'real_logout': True, 'no_open_logins': True,
|
||||||
|
'survival_seconds': observation['survival_seconds'], 'same_vm': after,
|
||||||
|
'audio_scope': 'Socket and service continuity only; signal/playback untested'}))
|
||||||
|
finally:
|
||||||
|
# Real product CLI verifies metadata and live QMP name/UUID before quit.
|
||||||
|
# Never signal a saved PID or control a VM outside these private paths.
|
||||||
|
cleanup_status = cli(context, 'status', context['name']).get('status')
|
||||||
|
if cleanup_status == 'running':
|
||||||
|
run_path = Path(context['runtime']) / context['name']
|
||||||
|
if json.loads((run_path / 'identity.json').read_text()) != {'name': context['name'], 'uuid': context['uuid']}:
|
||||||
|
raise SmokeError('Refusing lifecycle cleanup for a foreign identity')
|
||||||
|
with QMPClient(run_path / 'qmp.sock', timeout=2) as client:
|
||||||
|
if (client.command('query-name') != {'name': context['name']}
|
||||||
|
or client.command('query-uuid') != {'UUID': context['uuid']}):
|
||||||
|
raise SmokeError('Refusing lifecycle cleanup for foreign live QMP')
|
||||||
|
if cli(context, 'stop', context['name'], '--force').get('status') != 'stopped':
|
||||||
|
raise SmokeError('Owned lifecycle VM did not stop')
|
||||||
|
elif cleanup_status != 'stopped':
|
||||||
|
raise SmokeError('Cannot safely verify the lifecycle VM for cleanup')
|
||||||
|
else:
|
||||||
|
raise SmokeError('Unknown lifecycle mode')
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def lifecycle_guest_script():
|
||||||
|
helpers = (lifecycle_socket, lifecycle_snapshot, verify_lifecycle_survival,
|
||||||
|
login_session_records, is_open_login, require_initial_login,
|
||||||
|
no_open_logins, capture_logout_snapshot)
|
||||||
|
return ('import json, os, socket, stat, subprocess, sys, time, uuid\n'
|
||||||
|
'from pathlib import Path\nsys.path.insert(0, "/usr/lib/hydra")\n'
|
||||||
|
'from hydra.qmp import QMPClient, QMPError\n'
|
||||||
|
'class SmokeError(RuntimeError): pass\n' +
|
||||||
|
'\n'.join(inspect.getsource(helper) for helper in helpers) + LIFECYCLE_BODY)
|
||||||
|
|
||||||
|
|
||||||
|
def installed_admin(console, password):
|
||||||
|
console.buffer = b''
|
||||||
|
prompt_encoded = base64.b64encode(b'HYDRA_SUDO_PASSWORD: ').decode()
|
||||||
|
console.send(f'sudo -k -p "$(printf %s {prompt_encoded} | base64 -d)" -i\n')
|
||||||
|
console.expect(rb'HYDRA_SUDO_PASSWORD: ', 30)
|
||||||
|
console.send(password + '\n')
|
||||||
|
console.expect(rb'root[^\r\n]*[#$] ', 30)
|
||||||
|
|
||||||
|
|
||||||
|
def installed_logout_probe(console, password):
|
||||||
|
# Exit only the guest sudo shell, then work in its actual getty PAM login.
|
||||||
|
console.send('exit\n')
|
||||||
|
console.expect(rb'hydratest[^\r\n]*[#$] ', 30)
|
||||||
|
if console.command('id -un', 10).strip() != b'hydratest':
|
||||||
|
raise SmokeError('Logout probe did not return to installed user login')
|
||||||
|
base = '/home/hydratest/hydra-logout-' + secrets.token_hex(4)
|
||||||
|
script = base + '/probe.py'
|
||||||
|
preparation = (f'umask 077; mkdir {shlex.quote(base)} && cat > {shlex.quote(script)} '
|
||||||
|
"<<'HYDRA_LIFECYCLE_SOURCE'\n" + lifecycle_guest_script() + '\nHYDRA_LIFECYCLE_SOURCE\n' +
|
||||||
|
f'python3 {shlex.quote(script)} start {shlex.quote(base)}')
|
||||||
|
ready = console.command(preparation, 120)
|
||||||
|
if ready.startswith(b'SKIP:'):
|
||||||
|
installed_admin(console, password)
|
||||||
|
return ready
|
||||||
|
if b'READY:' not in ready:
|
||||||
|
raise SmokeError('Lifecycle VM did not become ready before logout')
|
||||||
|
console.buffer = b''
|
||||||
|
console.send('exit\n')
|
||||||
|
console.expect(rb'login: ', 30)
|
||||||
|
# This is intentionally a period at the actual login prompt with no
|
||||||
|
# replacement login. The guest monitor independently confirms no open logins.
|
||||||
|
time.sleep(20)
|
||||||
|
console.send('hydratest\n')
|
||||||
|
console.expect(rb'Password: ', 30)
|
||||||
|
console.send(password + '\n')
|
||||||
|
console.expect(rb'hydratest[^\r\n]*[#$] ', 60)
|
||||||
|
result = console.command(f'python3 {shlex.quote(script)} finish {shlex.quote(base)}', 90)
|
||||||
|
installed_admin(console, password)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def reserve_disk(path):
|
||||||
|
try:
|
||||||
|
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||||
|
os.close(fd)
|
||||||
|
except OSError as exc:
|
||||||
|
raise SmokeError('Test disk must be a newly created regular file') from exc
|
||||||
|
|
||||||
|
|
||||||
|
def owned_file(path, scratch):
|
||||||
|
path, scratch = Path(path), Path(scratch)
|
||||||
|
try:
|
||||||
|
info = path.lstat()
|
||||||
|
except OSError as exc:
|
||||||
|
raise SmokeError('Missing disposable drive') from exc
|
||||||
|
if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1
|
||||||
|
or path.parent.resolve() != scratch.resolve()
|
||||||
|
or info.st_uid != os.getuid()):
|
||||||
|
raise SmokeError('Drive must be an owned, unlinked regular file directly in scratch')
|
||||||
|
|
||||||
|
|
||||||
|
def qemu_path(path):
|
||||||
|
# QEMU keyval arguments use commas as separators. Refuse ambiguous paths.
|
||||||
|
if any(c in str(path) for c in ',\n\r\x00'):
|
||||||
|
raise SmokeError('QEMU file paths cannot contain comma or control characters')
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def qemu_argv(qemu, scratch, disk, code, variables, iso, memory):
|
||||||
|
scratch = Path(scratch)
|
||||||
|
owned_file(disk, scratch)
|
||||||
|
owned_file(variables, scratch)
|
||||||
|
argv = [qemu, '-machine', 'q35,accel=kvm', '-cpu', 'host', '-smp', '2',
|
||||||
|
'-m', str(memory), '-display', 'none', '-monitor', 'none',
|
||||||
|
'-no-reboot', '-nic', 'user,model=virtio-net-pci',
|
||||||
|
'-drive', f'if=pflash,format=raw,readonly=on,file={qemu_path(code)}',
|
||||||
|
'-drive', f'if=pflash,format=raw,file={qemu_path(variables)}',
|
||||||
|
'-drive', f'if=virtio,format=qcow2,file={qemu_path(disk)}',
|
||||||
|
'-chardev', f'socket,id=console,path={qemu_path(scratch / "serial.sock")},server=on,wait=off',
|
||||||
|
'-serial', 'chardev:console', '-qmp', f'unix:{qemu_path(scratch / "qmp.sock")},server=on,wait=off']
|
||||||
|
if iso is not None:
|
||||||
|
if not Path(iso).is_file():
|
||||||
|
raise SmokeError('Trusted ISO must exist as a regular file')
|
||||||
|
argv += ['-drive', f'file={qemu_path(iso)},media=cdrom,format=raw,readonly=on',
|
||||||
|
'-boot', 'order=d,strict=on']
|
||||||
|
else:
|
||||||
|
argv += ['-boot', 'order=c,strict=on']
|
||||||
|
return argv
|
||||||
|
|
||||||
|
|
||||||
|
def frame_command(command, token):
|
||||||
|
if not re.fullmatch(r'[a-zA-Z0-9]+', token):
|
||||||
|
raise SmokeError('Invalid framing token')
|
||||||
|
encoded = base64.b64encode(command.encode()).decode()
|
||||||
|
# Linux canonical serial TTY input is bounded per physical line. Continue
|
||||||
|
# the shell expression across short lines for substantial guest probes.
|
||||||
|
chunks = ' \\\n'.join(shlex.quote(encoded[i:i + 768]) for i in range(0, len(encoded), 768)) or "''"
|
||||||
|
# Literal control bytes appear only in output, never in the echoed command.
|
||||||
|
return (f"printf '\\036{token}:BEGIN\\037'; "
|
||||||
|
f"sh -c \"$(printf %s {chunks} | base64 -d)\"; "
|
||||||
|
f"rc=$?; printf '\\036{token}:END:%s\\037' \"$rc\"")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frame(data, token):
|
||||||
|
begin = b'\x1e' + token.encode() + b':BEGIN\x1f'
|
||||||
|
start = data.find(begin)
|
||||||
|
if start < 0:
|
||||||
|
return None
|
||||||
|
end = re.search(rb'\x1e' + token.encode() + rb':END:(\d+)\x1f', data[start + len(begin):])
|
||||||
|
if end is None:
|
||||||
|
return None
|
||||||
|
output = data[start + len(begin):start + len(begin) + end.start()]
|
||||||
|
return int(end.group(1)), output
|
||||||
|
|
||||||
|
|
||||||
|
def installer_command(token):
|
||||||
|
# A literal control-byte return frame cannot be forged by terminal echo.
|
||||||
|
# Keep the installer on this controlling TTY for getpass, then mark every
|
||||||
|
# shell return, including an unexpected status zero without completion.
|
||||||
|
return ('sudo -n hydra-install --disk /dev/vda --user hydratest --execute '
|
||||||
|
'--confirm-erase /dev/vda; rc=$?; '
|
||||||
|
f"printf '\\036{token}:INSTALL_EXIT:%s\\037' \"$rc\"")
|
||||||
|
|
||||||
|
|
||||||
|
class Console:
|
||||||
|
def __init__(self, connection, logfile, passwords):
|
||||||
|
self.sock = connection
|
||||||
|
self.log = open(logfile, 'ab', buffering=0)
|
||||||
|
self.passwords = [p.encode() for p in passwords if p]
|
||||||
|
self.pending_log = b''
|
||||||
|
self.buffer = b''
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def record(self, data, final=False):
|
||||||
|
self.pending_log += data
|
||||||
|
for password in self.passwords:
|
||||||
|
self.pending_log = self.pending_log.replace(password, b'[REDACTED]')
|
||||||
|
keep = 0
|
||||||
|
if not final:
|
||||||
|
for password in self.passwords:
|
||||||
|
for length in range(1, len(password)):
|
||||||
|
if self.pending_log.endswith(password[:length]):
|
||||||
|
keep = max(keep, length)
|
||||||
|
if keep:
|
||||||
|
self.log.write(self.pending_log[:-keep])
|
||||||
|
self.pending_log = self.pending_log[-keep:]
|
||||||
|
else:
|
||||||
|
self.log.write(self.pending_log)
|
||||||
|
self.pending_log = b''
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if not self.closed:
|
||||||
|
self.record(b'', final=True)
|
||||||
|
self.log.close()
|
||||||
|
self.sock.close()
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
def send(self, data):
|
||||||
|
self.sock.sendall(data.encode() if isinstance(data, str) else data)
|
||||||
|
|
||||||
|
def read(self, timeout):
|
||||||
|
ready, _, _ = select.select([self.sock], [], [], max(0, timeout))
|
||||||
|
if not ready:
|
||||||
|
return b''
|
||||||
|
data = self.sock.recv(65536)
|
||||||
|
if not data:
|
||||||
|
raise SmokeError('Guest console closed')
|
||||||
|
self.record(data)
|
||||||
|
self.buffer += data
|
||||||
|
if len(self.buffer) > 16 * 1024 * 1024:
|
||||||
|
raise SmokeError('Console exceeded bounded capture size')
|
||||||
|
return data
|
||||||
|
|
||||||
|
def expect(self, pattern, timeout, failure_pattern=None):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
match = re.search(pattern, self.buffer)
|
||||||
|
failure = re.search(failure_pattern, self.buffer) if failure_pattern else None
|
||||||
|
if failure and (not match or failure.start() < match.start()):
|
||||||
|
evidence = failure.group()
|
||||||
|
for password in self.passwords:
|
||||||
|
evidence = evidence.replace(password, b'[REDACTED]')
|
||||||
|
raise SmokeError('Installer exited before expected console state: ' +
|
||||||
|
evidence.decode(errors='replace'))
|
||||||
|
if match:
|
||||||
|
self.buffer = self.buffer[match.end():]
|
||||||
|
return match
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise SmokeError('Timed out waiting for guest console state')
|
||||||
|
self.read(min(remaining, 1))
|
||||||
|
|
||||||
|
def expect_installer(self, pattern, timeout, token):
|
||||||
|
failure = (rb'hydra-install: [^\r\n]*[\r\n]|\x1e' +
|
||||||
|
re.escape(token.encode()) + rb':INSTALL_EXIT:[0-9]+\x1f')
|
||||||
|
return self.expect(pattern, timeout, failure_pattern=failure)
|
||||||
|
|
||||||
|
def finish_installer(self, token, timeout=30):
|
||||||
|
# Completion text can arrive before the process exits and readline
|
||||||
|
# resumes. Input sent during that gap can be discarded by the TTY.
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
returned = self.expect(rb'\x1e' + re.escape(token.encode()) +
|
||||||
|
rb':INSTALL_EXIT:([0-9]+)\x1f', timeout)
|
||||||
|
status = int(returned.group(1))
|
||||||
|
if status:
|
||||||
|
raise SmokeError(f'Installer returned status {status} after completion')
|
||||||
|
self.expect(rb'hydra-live[^\r\n]*[#$] ', max(0, deadline - time.monotonic()))
|
||||||
|
|
||||||
|
def command(self, command, timeout=60):
|
||||||
|
token = secrets.token_hex(12)
|
||||||
|
self.buffer = b''
|
||||||
|
self.send(frame_command(command, token) + '\n')
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
parsed = parse_frame(self.buffer, token)
|
||||||
|
if parsed is not None:
|
||||||
|
status, output = parsed
|
||||||
|
self.buffer = b''
|
||||||
|
if status:
|
||||||
|
raise SmokeError(f'Guest probe failed with status {status}: {output.decode(errors="replace")[-3000:]}')
|
||||||
|
return output.replace(b'\r\n', b'\n')
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise SmokeError('Timed out waiting for framed guest command')
|
||||||
|
self.read(min(remaining, 1))
|
||||||
|
|
||||||
|
|
||||||
|
def connect(path, process, timeout=30):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if process.poll() is not None:
|
||||||
|
raise SmokeError(f'Test QEMU exited with status {process.returncode}')
|
||||||
|
connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
try:
|
||||||
|
connection.connect(str(path))
|
||||||
|
return connection
|
||||||
|
except OSError:
|
||||||
|
connection.close()
|
||||||
|
time.sleep(0.1)
|
||||||
|
raise SmokeError('Timed out connecting to private test socket')
|
||||||
|
|
||||||
|
|
||||||
|
class VM:
|
||||||
|
def __init__(self, args, scratch, disk, iso, password, phase):
|
||||||
|
self.scratch = scratch
|
||||||
|
for name in ('serial.sock', 'qmp.sock'):
|
||||||
|
(scratch / name).unlink(missing_ok=True)
|
||||||
|
self.stderr = open(scratch / f'{phase}-qemu.log', 'ab', buffering=0)
|
||||||
|
self.process = subprocess.Popen(qemu_argv(args.qemu, scratch, disk,
|
||||||
|
args.ovmf_code, scratch / 'OVMF_VARS.fd', iso, args.memory),
|
||||||
|
stdin=subprocess.DEVNULL, stdout=self.stderr, stderr=self.stderr)
|
||||||
|
self.console = None
|
||||||
|
self.qmp = None
|
||||||
|
try:
|
||||||
|
self.console = Console(connect(scratch / 'serial.sock', self.process),
|
||||||
|
scratch / f'{phase}-serial.log', [password])
|
||||||
|
ready = connect(scratch / 'qmp.sock', self.process)
|
||||||
|
ready.close()
|
||||||
|
self.qmp = QMPClient(scratch / 'qmp.sock', timeout=5)
|
||||||
|
self.kvm = self.qmp.command('query-kvm')
|
||||||
|
if not self.kvm.get('enabled'):
|
||||||
|
raise SmokeError('Disposable QEMU is not using KVM')
|
||||||
|
except BaseException:
|
||||||
|
self.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def wait_poweroff(self, timeout):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
serial_open = True
|
||||||
|
while self.process.poll() is None:
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise SmokeError('Guest failed to power off in time')
|
||||||
|
if serial_open:
|
||||||
|
try:
|
||||||
|
self.console.read(min(remaining, 0.25))
|
||||||
|
except SmokeError as exc:
|
||||||
|
if str(exc) != 'Guest console closed':
|
||||||
|
raise
|
||||||
|
serial_open = False
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self.process.wait(timeout=remaining)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise SmokeError('Guest failed to power off in time') from exc
|
||||||
|
# Capture bytes queued immediately before exit, even if poll observed
|
||||||
|
# the child exiting before the first serial read.
|
||||||
|
while serial_open:
|
||||||
|
try:
|
||||||
|
if not self.console.read(0):
|
||||||
|
break
|
||||||
|
except SmokeError as exc:
|
||||||
|
if str(exc) != 'Guest console closed':
|
||||||
|
raise
|
||||||
|
serial_open = False
|
||||||
|
rc = self.process.returncode
|
||||||
|
if rc:
|
||||||
|
raise SmokeError(f'Test QEMU failed during poweroff ({rc})')
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
# Only the Popen child owned by this harness is signalled.
|
||||||
|
if self.qmp:
|
||||||
|
if self.process.poll() is None:
|
||||||
|
try:
|
||||||
|
self.qmp.command('quit')
|
||||||
|
self.process.wait(timeout=5)
|
||||||
|
except (QMPError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
self.qmp.close()
|
||||||
|
if self.process.poll() is None:
|
||||||
|
self.process.terminate()
|
||||||
|
try:
|
||||||
|
self.process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.process.kill()
|
||||||
|
self.process.wait(timeout=10)
|
||||||
|
if self.console:
|
||||||
|
self.console.close()
|
||||||
|
self.stderr.close()
|
||||||
|
|
||||||
|
|
||||||
|
LIVE_CHECK = r'''set -eu
|
||||||
|
[ "$(id -un)" = hydra-live ]
|
||||||
|
test -e /sys/firmware/efi
|
||||||
|
test -f /etc/hydra/live-mode/active
|
||||||
|
findmnt -n -o FSTYPE / | grep -qx overlay
|
||||||
|
test -f /run/initramfs/live/LiveOS/squashfs.img
|
||||||
|
sudo -n true
|
||||||
|
for tool in hydra hydra-install qemu-system-x86_64 qemu-img remote-viewer foot labwc; do command -v "$tool"; done
|
||||||
|
for service in NetworkManager hydra-network hydra-dnsmasq; do systemctl is-active "$service"; done
|
||||||
|
'''
|
||||||
|
|
||||||
|
INSTALLED_CHECK = r'''set -eu
|
||||||
|
assert_no_match() {
|
||||||
|
if grep "$@"; then
|
||||||
|
printf 'Forbidden live configuration remains\n' >&2
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
status=$?
|
||||||
|
[ "$status" -eq 1 ] || exit "$status"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
[ "$(findmnt -n -o FSTYPE /)" = ext4 ]
|
||||||
|
[ "$(readlink -f "$(findmnt -n -o SOURCE /)")" = /dev/vda2 ]
|
||||||
|
assert_no_match -Eq 'root=live:|rd\.live' /proc/cmdline
|
||||||
|
test ! -e /etc/hydra/live-mode
|
||||||
|
test ! -L /etc/hydra/live-mode
|
||||||
|
if getent passwd hydra-live; then
|
||||||
|
printf 'Live account remains\n' >&2
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
status=$?
|
||||||
|
[ "$status" -eq 2 ] || exit "$status"
|
||||||
|
fi
|
||||||
|
remaining=$(find /etc/systemd/system /etc/sudoers.d \( -name 90-hydra-live.conf -o -name 90-hydra-live \) -print)
|
||||||
|
[ -z "$remaining" ]
|
||||||
|
test ! -e /etc/greetd/live.toml
|
||||||
|
test ! -L /etc/greetd/live.toml
|
||||||
|
for directory in /etc/systemd/system/getty@*.service.d /etc/systemd/system/serial-getty@*.service.d /etc/sudoers.d; do
|
||||||
|
if [ -d "$directory" ]; then
|
||||||
|
assert_no_match -R -E -- '--autologin|NOPASSWD' "$directory"
|
||||||
|
elif [ -e "$directory" ] || [ -L "$directory" ]; then
|
||||||
|
printf 'Unexpected non-directory configuration path\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
getent shadow root | cut -d: -f2 | grep -Eq '^[!*]'
|
||||||
|
getent shadow hydratest | cut -d: -f2 | grep -Eq '^\$'
|
||||||
|
for service in NetworkManager greetd hydra-network hydra-dnsmasq; do systemctl is-active "$service"; done
|
||||||
|
[ "$(systemctl show hydra-bridge-permissions -p Result --value)" = success ]
|
||||||
|
[ "$(systemctl show hydra-bridge-permissions -p ExecMainStatus --value)" = 0 ]
|
||||||
|
ip -4 -o addr show hydrabr0 | grep -F '10.203.0.1/24'
|
||||||
|
ip -4 route show default | grep '^default '
|
||||||
|
nft list table ip hydra_nat
|
||||||
|
nmcli -t -f STATE general | grep -E '^connected'
|
||||||
|
getent ahostsv4 gentoo.org
|
||||||
|
for tool in hydra hydra-install qemu-system-x86_64 qemu-img remote-viewer foot labwc; do command -v "$tool"; done
|
||||||
|
hydra --help
|
||||||
|
qemu-system-x86_64 --version
|
||||||
|
qemu-img --version
|
||||||
|
test -r /etc/greetd/config.toml
|
||||||
|
find /usr/share -name OVMF_CODE.fd -print -quit | grep .
|
||||||
|
'''
|
||||||
|
|
||||||
|
NETWORK_CHECK = r'''set -eu
|
||||||
|
[ "$(cat /proc/sys/net/ipv4/ip_forward)" = 1 ]
|
||||||
|
python3 - <<'NETWORK'
|
||||||
|
import pathlib, secrets, subprocess, time
|
||||||
|
tag = secrets.token_hex(3)
|
||||||
|
lan, guest = 'hl' + tag, 'hv' + tag
|
||||||
|
uplink, lanpeer = 'hu' + tag, 'hp' + tag
|
||||||
|
bridgepeer, guestpeer = 'hb' + tag, 'hg' + tag
|
||||||
|
created = []
|
||||||
|
links = []
|
||||||
|
server = None
|
||||||
|
def run(*args):
|
||||||
|
return subprocess.run(args, check=True, capture_output=True, text=True, timeout=20).stdout
|
||||||
|
try:
|
||||||
|
for name in (lan, guest):
|
||||||
|
run('ip', 'netns', 'add', name)
|
||||||
|
created.append(name)
|
||||||
|
run('ip', 'link', 'add', uplink, 'type', 'veth', 'peer', 'name', lanpeer)
|
||||||
|
links.append(uplink)
|
||||||
|
run('ip', 'link', 'set', lanpeer, 'netns', lan)
|
||||||
|
run('ip', 'addr', 'add', '198.18.0.1/30', 'dev', uplink)
|
||||||
|
run('ip', 'link', 'set', uplink, 'up')
|
||||||
|
run('ip', '-n', lan, 'addr', 'add', '198.18.0.2/30', 'dev', lanpeer)
|
||||||
|
run('ip', '-n', lan, 'link', 'set', lanpeer, 'up')
|
||||||
|
run('ip', '-n', lan, 'link', 'set', 'lo', 'up')
|
||||||
|
run('ip', '-n', lan, 'route', 'add', '10.203.0.0/24', 'via', '198.18.0.1')
|
||||||
|
run('ip', 'link', 'add', bridgepeer, 'type', 'veth', 'peer', 'name', guestpeer)
|
||||||
|
links.append(bridgepeer)
|
||||||
|
run('ip', 'link', 'set', guestpeer, 'netns', guest)
|
||||||
|
run('ip', 'link', 'set', bridgepeer, 'master', 'hydrabr0')
|
||||||
|
run('ip', 'link', 'set', bridgepeer, 'up')
|
||||||
|
run('ip', '-n', guest, 'addr', 'add', '10.203.0.250/24', 'dev', guestpeer)
|
||||||
|
run('ip', '-n', guest, 'link', 'set', guestpeer, 'up')
|
||||||
|
run('ip', '-n', guest, 'link', 'set', 'lo', 'up')
|
||||||
|
run('ip', '-n', guest, 'route', 'add', 'default', 'via', '10.203.0.1')
|
||||||
|
port = 43117
|
||||||
|
server_code = "import socket; s=socket.socket(); s.settimeout(15); s.bind(('10.203.0.250',43117)); s.listen(1); c,a=s.accept(); c.sendall(b'HYDRA_LAN_TO_VM_OK'); c.close(); s.close()"
|
||||||
|
server = subprocess.Popen(['ip', 'netns', 'exec', guest, 'python3', '-c', server_code], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||||
|
client_code = "import socket,time; deadline=time.monotonic()+10\nwhile True:\n try:\n s=socket.create_connection(('10.203.0.250',43117),1); break\n except OSError:\n if time.monotonic()>deadline: raise\n time.sleep(.1)\nexpected=b'HYDRA_LAN_TO_VM_OK'; received=b''\nwhile len(received)<len(expected):\n remaining=deadline-time.monotonic()\n if remaining<=0: raise TimeoutError('TCP marker deadline exceeded')\n s.settimeout(remaining)\n part=s.recv(len(expected)-len(received))\n if not part: raise RuntimeError('Truncated TCP marker')\n received+=part\nassert received==expected; s.close(); print('PASS: routed new LAN-to-VM TCP connection on arbitrary port 43117')"
|
||||||
|
print(run('ip', 'netns', 'exec', lan, 'python3', '-c', client_code), end='')
|
||||||
|
assert server.wait(timeout=10) == 0
|
||||||
|
finally:
|
||||||
|
if server is not None and server.poll() is None:
|
||||||
|
server.terminate()
|
||||||
|
try: server.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired: server.kill(); server.wait(timeout=5)
|
||||||
|
for link in reversed(links):
|
||||||
|
subprocess.run(['ip', 'link', 'del', link], check=False, capture_output=True, timeout=10)
|
||||||
|
for name in reversed(created):
|
||||||
|
subprocess.run(['ip', 'netns', 'del', name], check=False, capture_output=True, timeout=10)
|
||||||
|
NETWORK
|
||||||
|
'''
|
||||||
|
|
||||||
|
# A nested VM hardware smoke proves KVM initialisation/QMP, not guest OS boot.
|
||||||
|
NESTED_CHECK = r'''set -eu
|
||||||
|
if ! test -c /dev/kvm; then printf 'SKIP: guest /dev/kvm unavailable\n'; exit 0; fi
|
||||||
|
python3 - <<'NESTED'
|
||||||
|
import json, pathlib, socket, subprocess, tempfile, time
|
||||||
|
with tempfile.TemporaryDirectory(prefix='hydra-nested-') as tmp:
|
||||||
|
root = pathlib.Path(tmp)
|
||||||
|
disk = root / 'fresh.qcow2'
|
||||||
|
subprocess.run(['qemu-img', 'create', '-f', 'qcow2', str(disk), '64M'], check=True, timeout=30)
|
||||||
|
log = open(root / 'qemu.log', 'wb')
|
||||||
|
proc = subprocess.Popen(['qemu-system-x86_64', '-machine', 'q35,accel=kvm', '-cpu', 'host', '-m', '128', '-S', '-display', 'none', '-nodefaults', '-nic', 'bridge,br=hydrabr0,model=virtio-net-pci', '-drive', 'if=virtio,format=qcow2,file=' + str(disk), '-qmp', 'unix:' + str(root / 'qmp') + ',server=on,wait=off'], stdin=subprocess.DEVNULL, stdout=log, stderr=log)
|
||||||
|
sock = socket.socket(socket.AF_UNIX)
|
||||||
|
sock.settimeout(10)
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
while True:
|
||||||
|
try: sock.connect(str(root / 'qmp')); break
|
||||||
|
except OSError:
|
||||||
|
if proc.poll() is not None or time.monotonic() >= deadline: raise RuntimeError('Nested KVM start failed')
|
||||||
|
time.sleep(.1)
|
||||||
|
stream = sock.makefile('rwb', buffering=0)
|
||||||
|
assert 'QMP' in json.loads(stream.readline())
|
||||||
|
for request in ({'execute':'qmp_capabilities'}, {'execute':'query-kvm'}, {'execute':'quit'}):
|
||||||
|
stream.write(json.dumps(request).encode() + b'\n')
|
||||||
|
while True:
|
||||||
|
response = json.loads(stream.readline())
|
||||||
|
if 'return' in response or 'error' in response: break
|
||||||
|
assert 'error' not in response, response
|
||||||
|
if request['execute'] == 'query-kvm':
|
||||||
|
assert response['return']['enabled'], response
|
||||||
|
print('PASS: normal-user nested KVM VM and bridge helper; fresh qcow2; private QMP')
|
||||||
|
assert proc.wait(timeout=10) == 0
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
|
try: proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired: proc.kill(); proc.wait(timeout=5)
|
||||||
|
log.close()
|
||||||
|
NESTED
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def digest(path):
|
||||||
|
result = hashlib.sha256()
|
||||||
|
with open(path, 'rb') as source:
|
||||||
|
for block in iter(lambda: source.read(1024 * 1024), b''):
|
||||||
|
result.update(block)
|
||||||
|
return result.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--iso', required=True, type=Path)
|
||||||
|
parser.add_argument('--ovmf-code', required=True, type=Path)
|
||||||
|
parser.add_argument('--ovmf-vars', required=True, type=Path)
|
||||||
|
parser.add_argument('--qemu', default='qemu-system-x86_64')
|
||||||
|
parser.add_argument('--qemu-img', default='qemu-img')
|
||||||
|
parser.add_argument('--scratch-parent', type=Path, default=Path(tempfile.gettempdir()))
|
||||||
|
parser.add_argument('--memory', type=int, default=4096)
|
||||||
|
parser.add_argument('--boot-timeout', type=int, default=360)
|
||||||
|
parser.add_argument('--install-timeout', type=int, default=1800)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
for path in (args.iso, args.ovmf_code, args.ovmf_vars):
|
||||||
|
if not path.is_file():
|
||||||
|
parser.error(f'Missing input file: {path}')
|
||||||
|
if args.memory < 2048 or args.boot_timeout <= 0 or args.install_timeout <= 0:
|
||||||
|
parser.error('Memory must be >=2048 MiB and timeouts positive')
|
||||||
|
args.iso = args.iso.resolve()
|
||||||
|
args.ovmf_code = args.ovmf_code.resolve()
|
||||||
|
args.ovmf_vars = args.ovmf_vars.resolve()
|
||||||
|
scratch = Path(tempfile.mkdtemp(prefix='hydra-iso-smoke-', dir=args.scratch_parent))
|
||||||
|
if len(str(scratch / 'serial.sock').encode()) >= 104:
|
||||||
|
parser.error('Scratch path too long for Unix sockets; use /tmp')
|
||||||
|
print(f'Acceptance artifacts: {scratch}', flush=True)
|
||||||
|
report = {'schema': 1, 'status': 'running', 'scratch': str(scratch),
|
||||||
|
'iso': str(args.iso), 'iso_sha256': digest(args.iso),
|
||||||
|
'checks': {}, 'physical_hardware': 'pending',
|
||||||
|
'nested_scope': 'Normal-user KVM VM launch, bridge helper and QMP; no nested guest OS boot',
|
||||||
|
'ovmf_code_sha256': digest(args.ovmf_code),
|
||||||
|
'ovmf_vars_input_sha256': digest(args.ovmf_vars)}
|
||||||
|
password = secrets.token_urlsafe(24)
|
||||||
|
sentinel = secrets.token_hex(32)
|
||||||
|
vm = None
|
||||||
|
def checkpoint(name, output):
|
||||||
|
report['checks'][name] = {'status': 'skipped' if output.startswith(b'SKIP:') else 'passed',
|
||||||
|
'output': output.decode(errors='replace')}
|
||||||
|
(scratch / 'report.json').write_text(json.dumps(report, indent=2) + '\n')
|
||||||
|
try:
|
||||||
|
disk = scratch / 'fresh-install.qcow2'
|
||||||
|
reserve_disk(disk)
|
||||||
|
subprocess.run([args.qemu_img, 'create', '-f', 'qcow2', str(disk), '32G'], check=True, timeout=30,
|
||||||
|
stdout=subprocess.DEVNULL)
|
||||||
|
shutil.copyfile(args.ovmf_vars, scratch / 'OVMF_VARS.fd')
|
||||||
|
os.chmod(scratch / 'OVMF_VARS.fd', 0o600)
|
||||||
|
vm = VM(args, scratch, disk, args.iso, password, 'live')
|
||||||
|
checkpoint('live_qmp_kvm', json.dumps(vm.kvm).encode())
|
||||||
|
console = vm.console
|
||||||
|
console.expect(rb'hydra-live[^\r\n]*[#$] ', args.boot_timeout)
|
||||||
|
checkpoint('live_boot', console.command(LIVE_CHECK, 90))
|
||||||
|
console.buffer = b''
|
||||||
|
installer_token = secrets.token_hex(12)
|
||||||
|
console.send(installer_command(installer_token) + '\n')
|
||||||
|
console.expect_installer(rb'Installed account password: ', 120, installer_token)
|
||||||
|
console.send(password + '\n')
|
||||||
|
console.expect_installer(rb'Repeat password: ', 30, installer_token)
|
||||||
|
console.send(password + '\n')
|
||||||
|
console.expect_installer(rb'Installation completed\. Diagnostic log: [^\r\n]+',
|
||||||
|
args.install_timeout, installer_token)
|
||||||
|
console.finish_installer(installer_token)
|
||||||
|
checkpoint('real_installer', b'Installer completed via guest controlling TTY')
|
||||||
|
console.command("sudo -n mkdir -p /mnt/hydra-validation && sudo -n mount /dev/vda2 /mnt/hydra-validation && printf %s " +
|
||||||
|
shlex.quote(sentinel) + " | sudo -n tee /mnt/hydra-validation/var/lib/hydra-acceptance-sentinel >/dev/null && sudo -n umount /mnt/hydra-validation", 60)
|
||||||
|
console.send('sudo -n poweroff\n')
|
||||||
|
vm.wait_poweroff(90)
|
||||||
|
vm.close()
|
||||||
|
vm = None
|
||||||
|
checkpoint('live_poweroff', b'QEMU exited before ISO-free reboot')
|
||||||
|
vm = VM(args, scratch, disk, None, password, 'installed')
|
||||||
|
checkpoint('installed_qmp_kvm', json.dumps(vm.kvm).encode())
|
||||||
|
console = vm.console
|
||||||
|
console.expect(rb'login: ', args.boot_timeout)
|
||||||
|
console.send('hydratest\n')
|
||||||
|
console.expect(rb'Password: ', 30)
|
||||||
|
console.send(password + '\n')
|
||||||
|
console.expect(rb'hydratest[^\r\n]*[#$] ', 60)
|
||||||
|
output = console.command('id -un; if sudo -n true 2>/dev/null; then exit 1; fi', 30)
|
||||||
|
if output.strip() != b'hydratest':
|
||||||
|
raise SmokeError('Installed account identity mismatch')
|
||||||
|
checkpoint('installed_password_login', output)
|
||||||
|
console.buffer = b''
|
||||||
|
prompt_encoded = base64.b64encode(b'HYDRA_SUDO_PASSWORD: ').decode()
|
||||||
|
console.send(f'sudo -p "$(printf %s {prompt_encoded} | base64 -d)" -i\n')
|
||||||
|
console.expect(rb'HYDRA_SUDO_PASSWORD: ', 30)
|
||||||
|
console.send(password + '\n')
|
||||||
|
console.expect(rb'root[^\r\n]*[#$] ', 30)
|
||||||
|
observed = console.command('cat /var/lib/hydra-acceptance-sentinel', 30)
|
||||||
|
if observed != sentinel.encode():
|
||||||
|
raise SmokeError('Persistence sentinel did not survive installed disk boot')
|
||||||
|
checkpoint('disk_persistence', b'Sentinel content verified from command output frame')
|
||||||
|
checkpoint('installed_contract', console.command(INSTALLED_CHECK, 120))
|
||||||
|
checkpoint('lan_to_vm_tcp', console.command(NETWORK_CHECK, 120))
|
||||||
|
checkpoint('nested_kvm', console.command('runuser -u hydratest -- sh -c ' + shlex.quote(NESTED_CHECK), 90))
|
||||||
|
checkpoint('installed_user_logout', installed_logout_probe(console, password))
|
||||||
|
console.send('poweroff\n')
|
||||||
|
vm.wait_poweroff(90)
|
||||||
|
checkpoint('installed_poweroff', b'Normal installed guest shutdown')
|
||||||
|
report['status'] = 'passed'
|
||||||
|
return 0
|
||||||
|
except (SmokeError, QMPError, OSError, subprocess.SubprocessError) as exc:
|
||||||
|
report['status'] = 'failed'
|
||||||
|
report['error'] = str(exc).replace(password, '[REDACTED]')
|
||||||
|
print(f'Acceptance failed: {report["error"]}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
if vm:
|
||||||
|
vm.close()
|
||||||
|
report['completed_utc'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||||
|
(scratch / 'report.json').write_text(json.dumps(report, indent=2) + '\n')
|
||||||
|
print(f'Report: {scratch / "report.json"}', flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Install HydraOS
|
||||||
|
|
||||||
|
HydraOS targets x86_64 UEFI systems. Disable Secure Boot for this first image.
|
||||||
|
Boot the ISO and open the recovery terminal. The live session uses a locked
|
||||||
|
`hydra-live` account with local sudo access; changes in the live filesystem are
|
||||||
|
volatile. The image provides no default SSH login or universal installed password.
|
||||||
|
|
||||||
|
`hydra-install` lists disks without selecting one. Inspect the devices, then ask
|
||||||
|
for a plan using the whole disk you intend to erase:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
hydra-install
|
||||||
|
hydra-install --disk /dev/CHOSEN_DISK --user YOUR_USER
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the placeholders with an actual whole disk and your desired account
|
||||||
|
name. The installer rejects mounted devices, the running root, the live medium,
|
||||||
|
and their backing disks. These checks help prevent mistakes; verify the chosen
|
||||||
|
disk and preserve backups before executing the plan. Physical disk installation
|
||||||
|
requires the operator's explicit choice.
|
||||||
|
|
||||||
|
To execute, enter the identical resolved whole-disk path in the confirmation:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo hydra-install --disk /dev/CHOSEN_DISK --user YOUR_USER \
|
||||||
|
--execute --confirm-erase /dev/CHOSEN_DISK
|
||||||
|
```
|
||||||
|
|
||||||
|
Execution erases the disk, creates a 1 GiB FAT32 EFI partition and an ext4 root,
|
||||||
|
extracts the prepared payload, and prompts twice on the local terminal for your
|
||||||
|
installed account password. Passwords have no command-line or configuration
|
||||||
|
option. The installed root account stays locked. Defaults are Europe/Moscow and
|
||||||
|
English UTF-8, with Russian UTF-8 also available; `--timezone` accepts an IANA
|
||||||
|
zone present in the payload.
|
||||||
|
|
||||||
|
The installer removes the live account, local password-free sudo rule, and
|
||||||
|
live autologin overrides, then builds a normal disk initramfs and fallback UEFI
|
||||||
|
loader. Wait for `Installation completed`, power off, remove the ISO, and boot
|
||||||
|
the installed disk. Log in with the account and password you supplied. On
|
||||||
|
failure, read the printed live diagnostic path; when a target root was mounted,
|
||||||
|
a nonsecret snapshot is also saved at `/var/log/hydra-install.log` on that disk.
|
||||||
|
|
||||||
|
## Disposable VM acceptance
|
||||||
|
|
||||||
|
Run the repository harness with QEMU/KVM, `qemu-img`, Python 3, and an OVMF
|
||||||
|
CODE/VARS pair. Use trusted image and firmware files. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 dev/iso-smoke.py --iso /absolute/path/to/hydra-os.iso \
|
||||||
|
--ovmf-code /absolute/path/to/OVMF_CODE.fd \
|
||||||
|
--ovmf-vars /absolute/path/to/OVMF_VARS.fd
|
||||||
|
```
|
||||||
|
|
||||||
|
The harness creates a private directory under `/tmp`, a new 32 GiB sparse
|
||||||
|
qcow2 disk, and a private copy of OVMF variables. There is no existing disk
|
||||||
|
argument. QEMU attaches the ISO read-only; serial and QMP use private Unix
|
||||||
|
sockets. Tool overrides `--qemu` and `--qemu-img` support temporary development
|
||||||
|
environments. `--scratch-parent`, `--memory`, `--boot-timeout`, and
|
||||||
|
`--install-timeout` control retained artifacts and bounded resource use.
|
||||||
|
|
||||||
|
The test boots the live image, runs the real installer through the guest's
|
||||||
|
controlling terminal using a generated password, writes a persistence sentinel,
|
||||||
|
shuts down, and launches a second QEMU with the ISO absent. It verifies password
|
||||||
|
login, disk root, removed live overrides, services, bridge/NAT configuration,
|
||||||
|
DNS, recovery tools, and a routed new TCP connection from a synthetic LAN to a
|
||||||
|
synthetic VM endpoint. Network namespaces and veth interfaces exist only inside
|
||||||
|
the disposable HydraOS guest. When guest KVM is available, it launches a further
|
||||||
|
fresh-disk QEMU as the installed account to exercise KVM, the bridge helper,
|
||||||
|
and private QMP; this checks VM launch, not a nested operating system boot.
|
||||||
|
|
||||||
|
`report.json` records each observed check, ISO/firmware SHA256, failures, and
|
||||||
|
skipped nested virtualization. Serial/QEMU logs and the qcow2 remain in the
|
||||||
|
printed scratch directory for review; passwords are redacted even across serial
|
||||||
|
read boundaries. Cleanup controls only the QEMU process created by this run.
|
||||||
|
A passing unit suite establishes harness behavior, not ISO acceptance. An
|
||||||
|
actual successful report from the built ISO is required for boot/install/reboot
|
||||||
|
acceptance.
|
||||||
|
|
||||||
|
## Checks requiring the laptop
|
||||||
|
|
||||||
|
Disposable QEMU results cannot establish ASUS FX517ZC hardware support. Before
|
||||||
|
using HydraOS daily, boot the live ISO and observe Wi-Fi, touchpad, audio,
|
||||||
|
brightness, suspend/resume, USB device selection, input capture, and actual
|
||||||
|
display refresh. Physical installation and these checks remain pending until
|
||||||
|
explicitly performed and recorded.
|
||||||
@@ -0,0 +1,634 @@
|
|||||||
|
"""Regression tests for disposable-VM isolation and trustworthy serial evidence."""
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
import select
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).resolve().parents[1] / 'dev/iso-smoke.py'
|
||||||
|
|
||||||
|
|
||||||
|
class SmokeTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.assertTrue(SCRIPT.is_file(), 'disposable ISO acceptance harness is missing')
|
||||||
|
spec = importlib.util.spec_from_file_location('iso_smoke', SCRIPT)
|
||||||
|
self.mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(self.mod)
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.code = self.root / 'code.fd'
|
||||||
|
self.variables = self.root / 'vars.fd'
|
||||||
|
self.code.write_bytes(b'firmware')
|
||||||
|
self.variables.write_bytes(b'fresh vars')
|
||||||
|
|
||||||
|
def test_existing_disk_or_symlink_cannot_be_a_test_drive(self):
|
||||||
|
disk = self.root / 'disk.qcow2'
|
||||||
|
disk.write_bytes(b'original')
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
self.mod.reserve_disk(disk)
|
||||||
|
link = self.root / 'link.qcow2'
|
||||||
|
link.symlink_to(disk)
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
self.mod.reserve_disk(link)
|
||||||
|
self.assertEqual(disk.read_bytes(), b'original')
|
||||||
|
|
||||||
|
def test_qemu_only_accepts_owned_regular_disk_and_readonly_iso(self):
|
||||||
|
disk = self.root / 'disk.qcow2'
|
||||||
|
self.mod.reserve_disk(disk)
|
||||||
|
iso = self.root / 'trusted.iso'
|
||||||
|
iso.write_bytes(b'ISO')
|
||||||
|
argv = self.mod.qemu_argv('qemu', self.root, disk, self.code,
|
||||||
|
self.variables, iso, 2048)
|
||||||
|
drives = [argv[i + 1] for i, value in enumerate(argv[:-1]) if value == '-drive']
|
||||||
|
self.assertTrue(any('format=qcow2' in item and str(disk) in item for item in drives))
|
||||||
|
self.assertTrue(any('media=cdrom' in item and 'readonly=on' in item for item in drives))
|
||||||
|
self.assertIn('accel=kvm', ' '.join(argv))
|
||||||
|
disk.unlink()
|
||||||
|
disk.symlink_to(iso)
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
self.mod.qemu_argv('qemu', self.root, disk, self.code,
|
||||||
|
self.variables, iso, 2048)
|
||||||
|
|
||||||
|
def test_installed_reboot_has_no_optical_medium(self):
|
||||||
|
disk = self.root / 'disk.qcow2'
|
||||||
|
self.mod.reserve_disk(disk)
|
||||||
|
argv = self.mod.qemu_argv('qemu', self.root, disk, self.code,
|
||||||
|
self.variables, None, 2048)
|
||||||
|
self.assertNotIn('media=cdrom', ' '.join(argv))
|
||||||
|
self.assertNotIn('-cdrom', argv)
|
||||||
|
|
||||||
|
def test_hardlinked_disk_is_refused(self):
|
||||||
|
disk = self.root / 'disk.qcow2'
|
||||||
|
self.mod.reserve_disk(disk)
|
||||||
|
(self.root / 'alias').hardlink_to(disk)
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
self.mod.qemu_argv('qemu', self.root, disk, self.code,
|
||||||
|
self.variables, None, 2048)
|
||||||
|
|
||||||
|
def test_result_frame_ignores_command_echo_and_preserves_failure(self):
|
||||||
|
command = self.mod.frame_command('printf sentinel; false', 'testtoken')
|
||||||
|
result = subprocess.run(['/bin/sh', '-c', command], capture_output=True)
|
||||||
|
echo = command.encode() + b'\r\n'
|
||||||
|
self.assertIsNone(self.mod.parse_frame(echo, 'testtoken'))
|
||||||
|
self.assertEqual(self.mod.parse_frame(echo + result.stdout, 'testtoken'),
|
||||||
|
(1, b'sentinel'))
|
||||||
|
|
||||||
|
def test_console_redacts_password_even_when_split_across_reads(self):
|
||||||
|
one, two = socket.socketpair()
|
||||||
|
self.addCleanup(one.close)
|
||||||
|
self.addCleanup(two.close)
|
||||||
|
log = self.root / 'serial.log'
|
||||||
|
console = self.mod.Console(one, log, ['split-secret'])
|
||||||
|
self.addCleanup(console.close)
|
||||||
|
two.sendall(b'hello split-')
|
||||||
|
console.read(0.1)
|
||||||
|
two.sendall(b'secret goodbye')
|
||||||
|
console.read(0.1)
|
||||||
|
console.close()
|
||||||
|
self.assertNotIn(b'split-secret', log.read_bytes())
|
||||||
|
self.assertIn(b'[REDACTED]', log.read_bytes())
|
||||||
|
|
||||||
|
def test_console_timeout_is_bounded_and_partial_frame_not_success(self):
|
||||||
|
one, two = socket.socketpair()
|
||||||
|
self.addCleanup(one.close)
|
||||||
|
self.addCleanup(two.close)
|
||||||
|
console = self.mod.Console(one, self.root / 'serial.log', [])
|
||||||
|
self.addCleanup(console.close)
|
||||||
|
two.sendall(b'\\036token:END:0\\037')
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
console.command('true', timeout=0.05)
|
||||||
|
|
||||||
|
def installer_console(self):
|
||||||
|
one, two = socket.socketpair()
|
||||||
|
self.addCleanup(two.close)
|
||||||
|
console = self.mod.Console(one, self.root / 'installer.log', ['test-secret'])
|
||||||
|
self.addCleanup(console.close)
|
||||||
|
return console, two
|
||||||
|
|
||||||
|
def test_captured_installer_refusal_fails_before_password_timeout(self):
|
||||||
|
console, guest = self.installer_console()
|
||||||
|
guest.sendall(b'\x1b]3008;type=command\x1b\\hydra-install: Command failed (1): '
|
||||||
|
b'findmnt --json --target /LiveOS/squashfs.img '
|
||||||
|
b'--output SOURCE,TARGET,FSTYPE,MAJ:MIN,OPTIONS: \r\n')
|
||||||
|
with self.assertRaisesRegex(self.mod.SmokeError, 'Installer.*findmnt'):
|
||||||
|
console.expect_installer(rb'Installed account password: ', 0.05, 'testtoken')
|
||||||
|
|
||||||
|
def test_installer_shell_return_without_completion_is_failure_even_zero(self):
|
||||||
|
for status in (0, 1, 127):
|
||||||
|
with self.subTest(status=status):
|
||||||
|
console, guest = self.installer_console()
|
||||||
|
guest.sendall(b'\x1etesttoken:INSTALL_EXIT:' + str(status).encode() + b'\x1f')
|
||||||
|
with self.assertRaisesRegex(self.mod.SmokeError, 'Installer.*INSTALL_EXIT'):
|
||||||
|
console.expect_installer(rb'Installation completed\. Diagnostic log: [^\r\n]+',
|
||||||
|
0.05, 'testtoken')
|
||||||
|
|
||||||
|
def test_installer_echo_cannot_fake_exit_and_complete_prompt_still_required(self):
|
||||||
|
console, guest = self.installer_console()
|
||||||
|
guest.sendall(self.mod.installer_command('testtoken').encode() + b'\r\n'
|
||||||
|
b'Installed account password: ')
|
||||||
|
self.assertEqual(console.expect_installer(rb'Installed account password: ',
|
||||||
|
0.05, 'testtoken').group(), b'Installed account password: ')
|
||||||
|
guest.sendall(b'Installation completed. Diagnostic')
|
||||||
|
with self.assertRaisesRegex(self.mod.SmokeError, 'Timed out'):
|
||||||
|
console.expect_installer(rb'Installation completed\. Diagnostic log: [^\r\n]+',
|
||||||
|
0.05, 'testtoken')
|
||||||
|
guest.sendall(b' log: /var/log/hydra-install.log\r\n')
|
||||||
|
self.assertIn(b'/var/log/hydra-install.log', console.expect_installer(
|
||||||
|
rb'Installation completed\. Diagnostic log: [^\r\n]+', 0.05, 'testtoken').group())
|
||||||
|
|
||||||
|
def test_installer_wrapper_marks_actual_shell_return_and_redacts_error(self):
|
||||||
|
console, guest = self.installer_console()
|
||||||
|
fake_sudo = self.root / 'sudo'
|
||||||
|
fake_sudo.write_text('#!/bin/sh\nprintf "hydra-install: test-secret refused\\n"\nexit 1\n')
|
||||||
|
fake_sudo.chmod(0o700)
|
||||||
|
result = subprocess.run(['/bin/sh', '-c', self.mod.installer_command('testtoken')],
|
||||||
|
env={'PATH': str(self.root)}, capture_output=True, check=True)
|
||||||
|
self.assertIn(b'\x1etesttoken:INSTALL_EXIT:1\x1f', result.stdout)
|
||||||
|
guest.sendall(result.stdout)
|
||||||
|
with self.assertRaisesRegex(self.mod.SmokeError, r'Installer.*\[REDACTED\] refused') as caught:
|
||||||
|
console.expect_installer(rb'Repeat password: ', 0.05, 'testtoken')
|
||||||
|
self.assertNotIn('test-secret', str(caught.exception))
|
||||||
|
|
||||||
|
def test_installer_followup_waits_for_exit_and_new_shell_prompt(self):
|
||||||
|
console, guest = self.installer_console()
|
||||||
|
premature = []
|
||||||
|
def respond():
|
||||||
|
guest.sendall(b'Installation completed. Diagnostic log: /run/installer.log\r\n')
|
||||||
|
premature.append(bool(select.select([guest], [], [], 0.05)[0]))
|
||||||
|
guest.sendall(b'\x1etesttoken:INSTALL_EXIT:0\x1f')
|
||||||
|
premature.append(bool(select.select([guest], [], [], 0.05)[0]))
|
||||||
|
guest.sendall(b'\x1b[?2004hhydra-live@hydra-os ~ $ ')
|
||||||
|
data = b''
|
||||||
|
while not data.endswith(b'\n'):
|
||||||
|
data += guest.recv(65536)
|
||||||
|
result = subprocess.run(['/bin/sh', '-c', data.decode()], capture_output=True)
|
||||||
|
guest.sendall(result.stdout)
|
||||||
|
worker = threading.Thread(target=respond, daemon=True)
|
||||||
|
worker.start()
|
||||||
|
console.expect_installer(rb'Installation completed\. Diagnostic log: [^\r\n]+',
|
||||||
|
1, 'testtoken')
|
||||||
|
console.finish_installer('testtoken', timeout=1)
|
||||||
|
self.assertEqual(console.command('printf ready', timeout=1), b'ready')
|
||||||
|
worker.join(timeout=1)
|
||||||
|
self.assertEqual(premature, [False, False], 'command reached installer before shell was ready')
|
||||||
|
|
||||||
|
def test_installer_completion_does_not_override_nonzero_return(self):
|
||||||
|
console, guest = self.installer_console()
|
||||||
|
guest.sendall(b'\x1etesttoken:INSTALL_EXIT:1\x1fhydra-live@hydra-os ~ $ ')
|
||||||
|
with self.assertRaisesRegex(self.mod.SmokeError, 'Installer.*status 1'):
|
||||||
|
console.finish_installer('testtoken', timeout=0.05)
|
||||||
|
|
||||||
|
def shutdown_vm(self, program):
|
||||||
|
one, two = socket.socketpair()
|
||||||
|
vm = self.mod.VM.__new__(self.mod.VM)
|
||||||
|
vm.console = self.mod.Console(one, self.root / 'shutdown.log', ['split-secret'])
|
||||||
|
self.addCleanup(vm.console.close)
|
||||||
|
code = ('import socket,sys,time; s=socket.socket(fileno=int(sys.argv[1])); ' + program)
|
||||||
|
vm.process = subprocess.Popen([sys.executable, '-c', code, str(two.fileno())],
|
||||||
|
pass_fds=(two.fileno(),), stdin=subprocess.DEVNULL)
|
||||||
|
two.close()
|
||||||
|
def reap():
|
||||||
|
if vm.process.poll() is None:
|
||||||
|
vm.process.terminate()
|
||||||
|
vm.process.wait(timeout=2)
|
||||||
|
self.addCleanup(reap)
|
||||||
|
return vm
|
||||||
|
|
||||||
|
def test_poweroff_drains_and_redacts_serial_until_owned_process_exits(self):
|
||||||
|
vm = self.shutdown_vm("s.sendall(b'Stopping split-'); time.sleep(.02); "
|
||||||
|
"s.sendall(b'secret service\\nPower down\\n'); s.close()")
|
||||||
|
vm.wait_poweroff(1)
|
||||||
|
output = (self.root / 'shutdown.log').read_bytes()
|
||||||
|
self.assertIn(b'Stopping [REDACTED] service\nPower down\n', output)
|
||||||
|
self.assertNotIn(b'split-secret', output)
|
||||||
|
|
||||||
|
def test_poweroff_preserves_failure_cause_and_original_timeout(self):
|
||||||
|
for program, error in (("s.sendall(b'Stop job stuck\\n'); s.close(); time.sleep(1)",
|
||||||
|
'Guest failed to power off in time'),
|
||||||
|
("s.sendall(b'QEMU shutdown failed\\n'); s.close(); sys.exit(7)",
|
||||||
|
'Test QEMU failed during poweroff')):
|
||||||
|
with self.subTest(program=program):
|
||||||
|
vm = self.shutdown_vm(program)
|
||||||
|
started = time.monotonic()
|
||||||
|
with self.assertRaisesRegex(self.mod.SmokeError, error):
|
||||||
|
vm.wait_poweroff(.15)
|
||||||
|
self.assertLess(time.monotonic() - started, .5)
|
||||||
|
output = (self.root / 'shutdown.log').read_bytes()
|
||||||
|
self.assertIn(b'Stop job stuck' if 'time.sleep' in program else b'QEMU shutdown failed', output)
|
||||||
|
if 'time.sleep' in program:
|
||||||
|
self.assertIsNone(vm.process.poll(), 'timeout must not kill or falsely pass the owned process')
|
||||||
|
|
||||||
|
def test_frame_real_shell_roundtrip_does_not_accept_sentinel_in_echo(self):
|
||||||
|
one, two = socket.socketpair()
|
||||||
|
self.addCleanup(one.close)
|
||||||
|
self.addCleanup(two.close)
|
||||||
|
console = self.mod.Console(one, self.root / 'serial.log', [])
|
||||||
|
self.addCleanup(console.close)
|
||||||
|
def guest():
|
||||||
|
data = b''
|
||||||
|
while not data.endswith(b'\n'):
|
||||||
|
data += two.recv(65536)
|
||||||
|
two.sendall(data)
|
||||||
|
result = subprocess.run(['/bin/sh', '-c', data.decode()], capture_output=True)
|
||||||
|
two.sendall(result.stdout)
|
||||||
|
thread = threading.Thread(target=guest)
|
||||||
|
thread.start()
|
||||||
|
self.assertEqual(console.command("printf 'persisted-data'", timeout=2), b'persisted-data')
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
def test_firmware_vars_must_be_private_copy_in_scratch(self):
|
||||||
|
disk = self.root / 'disk.qcow2'
|
||||||
|
self.mod.reserve_disk(disk)
|
||||||
|
self.variables.unlink()
|
||||||
|
self.variables.symlink_to(self.code)
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
self.mod.qemu_argv('qemu', self.root, disk, self.code,
|
||||||
|
self.variables, None, 2048)
|
||||||
|
|
||||||
|
def test_drive_outside_scratch_and_ambiguous_qemu_paths_are_refused(self):
|
||||||
|
disk = self.root / 'disk.qcow2'
|
||||||
|
self.mod.reserve_disk(disk)
|
||||||
|
other = self.root / 'other'
|
||||||
|
other.mkdir()
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
self.mod.qemu_argv('qemu', other, disk, self.code,
|
||||||
|
self.variables, None, 2048)
|
||||||
|
comma = self.root / 'disk,cache=unsafe.qcow2'
|
||||||
|
self.mod.reserve_disk(comma)
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
self.mod.qemu_argv('qemu', self.root, comma, self.code,
|
||||||
|
self.variables, None, 2048)
|
||||||
|
|
||||||
|
def probe_fixture(self, case, live=False):
|
||||||
|
# Run the production shell against ordinary scratch files. Commands that
|
||||||
|
# query guest state are boundary fixtures; grep/find/test are real tools.
|
||||||
|
import os
|
||||||
|
root = self.root / 'guest'
|
||||||
|
for directory in ('etc/hydra', 'etc/greetd', 'etc/sudoers.d',
|
||||||
|
'etc/systemd/system/getty@tty2.service.d',
|
||||||
|
'etc/systemd/system/serial-getty@ttyS0.service.d',
|
||||||
|
'usr/share/firmware', 'proc', 'sys/firmware/efi',
|
||||||
|
'run/initramfs/live/LiveOS'):
|
||||||
|
(root / directory).mkdir(parents=True, exist_ok=True)
|
||||||
|
(root / 'etc/greetd/config.toml').write_text('normal authentication')
|
||||||
|
(root / 'usr/share/firmware/OVMF_CODE.fd').write_text('firmware')
|
||||||
|
(root / 'proc/cmdline').write_text('root=UUID=disk ro' if case != 'live-cmdline' else 'root=live:CDLABEL=HYDRA_OS rd.live.image')
|
||||||
|
if live or case == 'live-marker':
|
||||||
|
(root / 'etc/hydra/live-mode').mkdir(exist_ok=True)
|
||||||
|
(root / 'etc/hydra/live-mode/active').touch()
|
||||||
|
if live:
|
||||||
|
(root / 'run/initramfs/live/LiveOS/squashfs.img').touch()
|
||||||
|
files = {
|
||||||
|
'live-dropin': ('etc/systemd/system/getty@tty2.service.d/90-hydra-live.conf', 'live'),
|
||||||
|
'live-sudoers': ('etc/sudoers.d/90-hydra-live', 'live'),
|
||||||
|
'autologin': ('etc/systemd/system/serial-getty@ttyS0.service.d/other.conf', 'ExecStart=agetty --autologin hydratest'),
|
||||||
|
'nopasswd': ('etc/sudoers.d/other', 'hydratest ALL=(ALL) NOPASSWD: ALL'),
|
||||||
|
'live-greetd': ('etc/greetd/live.toml', 'live'),
|
||||||
|
}
|
||||||
|
if case in files:
|
||||||
|
path, text = files[case]
|
||||||
|
(root / path).write_text(text)
|
||||||
|
if case == 'config-read-error':
|
||||||
|
unreadable = root / 'etc/sudoers.d/unreadable'
|
||||||
|
unreadable.write_text('configuration')
|
||||||
|
unreadable.chmod(0)
|
||||||
|
if case == 'cmdline-read-error':
|
||||||
|
(root / 'proc/cmdline').unlink()
|
||||||
|
commands = self.root / 'fixture-bin'
|
||||||
|
commands.mkdir(exist_ok=True)
|
||||||
|
dispatcher = commands / 'fixture'
|
||||||
|
dispatcher.write_text('''#!/usr/bin/env python3
|
||||||
|
import os, pathlib, sys
|
||||||
|
name = pathlib.Path(sys.argv[0]).name
|
||||||
|
args = sys.argv[1:]
|
||||||
|
case = os.environ['SMOKE_CASE']
|
||||||
|
if name == 'id': print('hydra-live')
|
||||||
|
elif name == 'findmnt': print('/dev/vda2' if 'SOURCE' in args else ('overlay' if os.environ['SMOKE_LIVE']=='1' else 'ext4'))
|
||||||
|
elif name == 'systemctl':
|
||||||
|
if args[0] == 'is-active':
|
||||||
|
states = [unit != case for unit in args[1:]]
|
||||||
|
print('\\n'.join('active' if state else 'failed' for state in states))
|
||||||
|
sys.exit(0 if any(states) else 3)
|
||||||
|
print('success' if 'Result' in args else '0')
|
||||||
|
elif name == 'getent':
|
||||||
|
if args[:2] == ['passwd','hydra-live']:
|
||||||
|
if case == 'account-lookup-error': sys.exit(3)
|
||||||
|
if case != 'live-account': sys.exit(2)
|
||||||
|
print('hydra-live:x:1000:1000::/home/hydra-live:/bin/bash')
|
||||||
|
elif args[0] == 'shadow': print(args[1] + ':' + ('!' if args[1]=='root' else '$6$hash') + ':1::::::')
|
||||||
|
else: print('127.0.0.1 STREAM example')
|
||||||
|
elif name == 'ip': print('10.203.0.1/24' if 'addr' in args else 'default via 10.0.2.2')
|
||||||
|
elif name == 'nmcli': print('connected')
|
||||||
|
else: print('fixture tool')
|
||||||
|
''')
|
||||||
|
dispatcher.chmod(0o755)
|
||||||
|
for command in ('id', 'findmnt', 'systemctl', 'getent', 'ip', 'nft',
|
||||||
|
'nmcli', 'sudo', 'hydra', 'hydra-install',
|
||||||
|
'qemu-system-x86_64', 'qemu-img', 'remote-viewer', 'foot', 'labwc'):
|
||||||
|
path = commands / command
|
||||||
|
if not path.exists():
|
||||||
|
path.symlink_to(dispatcher)
|
||||||
|
probe = self.mod.LIVE_CHECK if live else self.mod.INSTALLED_CHECK
|
||||||
|
for path in ('/etc/', '/usr/share', '/proc/cmdline', '/sys/firmware/efi', '/run/initramfs/live/LiveOS/squashfs.img'):
|
||||||
|
probe = probe.replace(path, str(root) + path)
|
||||||
|
import shutil
|
||||||
|
for command in ('python3', 'sh', 'grep', 'find', 'cut', 'readlink', 'base64'):
|
||||||
|
target = shutil.which(command)
|
||||||
|
self.assertIsNotNone(target)
|
||||||
|
(commands / command).symlink_to(target) if not (commands / command).exists() else None
|
||||||
|
env = dict(os.environ, PATH=str(commands),
|
||||||
|
SMOKE_CASE=case, SMOKE_LIVE='1' if live else '0')
|
||||||
|
result = subprocess.run(['/bin/sh', '-c', self.mod.frame_command(probe, 'probecheck')],
|
||||||
|
env=env, capture_output=True, timeout=10)
|
||||||
|
parsed = self.mod.parse_frame(result.stdout, 'probecheck')
|
||||||
|
self.assertIsNotNone(parsed, result.stderr)
|
||||||
|
return parsed[0]
|
||||||
|
|
||||||
|
def test_installed_probe_rejects_each_retained_live_artifact(self):
|
||||||
|
self.assertEqual(self.probe_fixture('clean'), 0)
|
||||||
|
for case in ('live-cmdline', 'live-account', 'live-dropin', 'live-sudoers',
|
||||||
|
'autologin', 'nopasswd', 'live-marker', 'live-greetd'):
|
||||||
|
with self.subTest(case=case):
|
||||||
|
# Give each negative case an independent clean filesystem.
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self.root / 'guest')
|
||||||
|
self.assertNotEqual(self.probe_fixture(case), 0)
|
||||||
|
|
||||||
|
def test_installed_probe_does_not_treat_account_query_error_as_absence(self):
|
||||||
|
self.assertNotEqual(self.probe_fixture('account-lookup-error'), 0)
|
||||||
|
|
||||||
|
def test_installed_probe_rejects_unreadable_configuration(self):
|
||||||
|
self.assertNotEqual(self.probe_fixture('config-read-error'), 0)
|
||||||
|
|
||||||
|
def test_installed_probe_rejects_unreadable_boot_arguments(self):
|
||||||
|
self.assertNotEqual(self.probe_fixture('cmdline-read-error'), 0)
|
||||||
|
|
||||||
|
def test_installed_probe_rejects_each_failed_required_service(self):
|
||||||
|
self.assertEqual(self.probe_fixture('clean'), 0)
|
||||||
|
for service in ('NetworkManager', 'greetd', 'hydra-network', 'hydra-dnsmasq'):
|
||||||
|
with self.subTest(service=service):
|
||||||
|
self.assertNotEqual(self.probe_fixture(service), 0)
|
||||||
|
|
||||||
|
def test_live_probe_rejects_each_failed_required_service(self):
|
||||||
|
self.assertEqual(self.probe_fixture('clean', live=True), 0)
|
||||||
|
for service in ('NetworkManager', 'hydra-network', 'hydra-dnsmasq'):
|
||||||
|
with self.subTest(service=service):
|
||||||
|
self.assertNotEqual(self.probe_fixture(service, live=True), 0)
|
||||||
|
|
||||||
|
def tcp_probe(self, fragments):
|
||||||
|
# Execute the exact Python client embedded in the guest network probe,
|
||||||
|
# redirecting only its address to a fresh loopback socket.
|
||||||
|
import ast
|
||||||
|
import time
|
||||||
|
source = self.mod.NETWORK_CHECK.split("<<'NETWORK'\n", 1)[1].rsplit('\nNETWORK\n', 1)[0]
|
||||||
|
assignments = (node for node in ast.walk(ast.parse(source)) if isinstance(node, ast.Assign))
|
||||||
|
value = next(node.value for node in assignments
|
||||||
|
if any(isinstance(target, ast.Name) and target.id == 'client_code' for target in node.targets))
|
||||||
|
client = ast.parse(ast.literal_eval(value))
|
||||||
|
listener = socket.socket()
|
||||||
|
self.addCleanup(listener.close)
|
||||||
|
listener.bind(('127.0.0.1', 0))
|
||||||
|
listener.listen(1)
|
||||||
|
listener.settimeout(3)
|
||||||
|
host, port = listener.getsockname()
|
||||||
|
class Redirect(ast.NodeTransformer):
|
||||||
|
def visit_Tuple(self, node):
|
||||||
|
if isinstance(node.elts[0], ast.Constant) and node.elts[0].value == '10.203.0.250':
|
||||||
|
return ast.Tuple(elts=[ast.Constant(host), ast.Constant(port)], ctx=ast.Load())
|
||||||
|
return self.generic_visit(node)
|
||||||
|
client = ast.fix_missing_locations(Redirect().visit(client))
|
||||||
|
def server():
|
||||||
|
connection, _ = listener.accept()
|
||||||
|
with connection:
|
||||||
|
for fragment in fragments:
|
||||||
|
try:
|
||||||
|
connection.sendall(fragment)
|
||||||
|
except BrokenPipeError:
|
||||||
|
break
|
||||||
|
time.sleep(.05)
|
||||||
|
thread = threading.Thread(target=server)
|
||||||
|
thread.start()
|
||||||
|
result = subprocess.run(['python3', '-c', ast.unparse(client)], capture_output=True, timeout=5)
|
||||||
|
thread.join(timeout=3)
|
||||||
|
self.assertFalse(thread.is_alive())
|
||||||
|
return result
|
||||||
|
|
||||||
|
def test_tcp_probe_accepts_complete_marker_across_fragmented_reads(self):
|
||||||
|
result = self.tcp_probe([b'HYDRA_', b'LAN_TO_', b'VM_OK'])
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|
||||||
|
def test_tcp_probe_rejects_truncated_marker(self):
|
||||||
|
self.assertNotEqual(self.tcp_probe([b'HYDRA_LAN_TO_']).returncode, 0)
|
||||||
|
|
||||||
|
def lifecycle_fixture(self):
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
name = 'logout-test'
|
||||||
|
runtime = self.root / 'private'
|
||||||
|
run = runtime / name
|
||||||
|
run.mkdir(parents=True, mode=0o700)
|
||||||
|
runtime.chmod(0o700)
|
||||||
|
audio = self.root / 'user-runtime'
|
||||||
|
(audio / 'pulse').mkdir(parents=True)
|
||||||
|
context = {'name': name, 'uuid': str(uuid.uuid4()), 'runtime': str(runtime),
|
||||||
|
'user_runtime': str(audio), 'uid': os.getuid()}
|
||||||
|
process = subprocess.Popen(['python3', '-c', 'import time; time.sleep(30)',
|
||||||
|
'-name', name, '-uuid', context['uuid']])
|
||||||
|
self.addCleanup(lambda: process.wait(timeout=3))
|
||||||
|
self.addCleanup(lambda: process.poll() is None and process.kill())
|
||||||
|
(run / 'qemu.pid').write_text(str(process.pid))
|
||||||
|
(run / 'identity.json').write_text(json.dumps({'name': name, 'uuid': context['uuid']}))
|
||||||
|
listeners = []
|
||||||
|
for path in (run / 'qmp.sock', run / 'spice.sock', audio / 'pipewire-0', audio / 'pulse/native'):
|
||||||
|
listener = socket.socket(socket.AF_UNIX)
|
||||||
|
listener.bind(str(path))
|
||||||
|
listener.listen(5)
|
||||||
|
listener.settimeout(.1)
|
||||||
|
listeners.append(listener)
|
||||||
|
self.addCleanup(listener.close)
|
||||||
|
stopped = threading.Event()
|
||||||
|
def qmp_server():
|
||||||
|
while not stopped.is_set():
|
||||||
|
try:
|
||||||
|
connection, _ = listeners[0].accept()
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
with connection, connection.makefile('rwb', buffering=0) as stream:
|
||||||
|
stream.write(b'{"QMP": {}}\n')
|
||||||
|
for line in stream:
|
||||||
|
request = json.loads(line)
|
||||||
|
replies = {'query-name': {'name': context.get('qmp_name', name)},
|
||||||
|
'query-uuid': {'UUID': context['uuid']},
|
||||||
|
'query-status': {'status': 'running'},
|
||||||
|
'query-kvm': {'enabled': True, 'present': True}}
|
||||||
|
if request['execute'] == 'query-kvm' and 'on_snapshot' in context:
|
||||||
|
context.pop('on_snapshot')()
|
||||||
|
stream.write(json.dumps({'return': replies.get(request['execute'], {}),
|
||||||
|
'id': request['id']}).encode() + b'\n')
|
||||||
|
thread = threading.Thread(target=qmp_server)
|
||||||
|
thread.start()
|
||||||
|
self.addCleanup(lambda: thread.join(timeout=3))
|
||||||
|
self.addCleanup(stopped.set)
|
||||||
|
return context, process, run
|
||||||
|
|
||||||
|
def test_logout_probe_detects_lost_private_socket(self):
|
||||||
|
context, process, run = self.lifecycle_fixture()
|
||||||
|
before = self.mod.lifecycle_snapshot(context)
|
||||||
|
self.assertEqual(self.mod.lifecycle_snapshot(context), before)
|
||||||
|
(run / 'spice.sock').unlink()
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
self.mod.lifecycle_snapshot(context)
|
||||||
|
|
||||||
|
def test_logout_probe_rejects_stale_metadata_and_foreign_qmp_identity(self):
|
||||||
|
import json
|
||||||
|
context, process, run = self.lifecycle_fixture()
|
||||||
|
self.mod.lifecycle_snapshot(context)
|
||||||
|
(run / 'identity.json').write_text(json.dumps({'name': context['name'], 'uuid': 'stale'}))
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
self.mod.lifecycle_snapshot(context)
|
||||||
|
(run / 'identity.json').write_text(json.dumps({'name': context['name'], 'uuid': context['uuid']}))
|
||||||
|
context['qmp_name'] = 'foreign-process'
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
self.mod.lifecycle_snapshot(context)
|
||||||
|
|
||||||
|
def test_logout_probe_rejects_dead_process_and_changed_runtime_socket(self):
|
||||||
|
context, process, run = self.lifecycle_fixture()
|
||||||
|
before = self.mod.lifecycle_snapshot(context)
|
||||||
|
audio = Path(context['user_runtime']) / 'pipewire-0'
|
||||||
|
audio.unlink()
|
||||||
|
replacement = socket.socket(socket.AF_UNIX)
|
||||||
|
self.addCleanup(replacement.close)
|
||||||
|
replacement.bind(str(audio))
|
||||||
|
replacement.listen(1)
|
||||||
|
after = self.mod.lifecycle_snapshot(context)
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
self.mod.verify_lifecycle_survival(before, after)
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=3)
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
self.mod.lifecycle_snapshot(context)
|
||||||
|
|
||||||
|
def test_logout_probe_accepts_verified_same_runtime_and_rejects_process_restart(self):
|
||||||
|
context, process, run = self.lifecycle_fixture()
|
||||||
|
before = self.mod.lifecycle_snapshot(context)
|
||||||
|
self.mod.verify_lifecycle_survival(before, self.mod.lifecycle_snapshot(context))
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=3)
|
||||||
|
replacement = subprocess.Popen(['python3', '-c', 'import time; time.sleep(30)',
|
||||||
|
'-name', context['name'], '-uuid', context['uuid']])
|
||||||
|
self.addCleanup(lambda: replacement.wait(timeout=3))
|
||||||
|
self.addCleanup(lambda: replacement.poll() is None and replacement.kill())
|
||||||
|
(run / 'qemu.pid').write_text(str(replacement.pid))
|
||||||
|
changed = self.mod.lifecycle_snapshot(context)
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
self.mod.verify_lifecycle_survival(before, changed)
|
||||||
|
|
||||||
|
def test_large_guest_probe_survives_serial_tty_canonical_line_limit(self):
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import termios
|
||||||
|
master, slave = pty.openpty()
|
||||||
|
self.addCleanup(os.close, master)
|
||||||
|
self.addCleanup(os.close, slave)
|
||||||
|
options = termios.tcgetattr(slave)
|
||||||
|
options[3] &= ~termios.ECHO
|
||||||
|
termios.tcsetattr(slave, termios.TCSANOW, options)
|
||||||
|
process = subprocess.Popen(['/bin/sh'], stdin=slave, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE)
|
||||||
|
self.addCleanup(process.stdout.close)
|
||||||
|
self.addCleanup(process.stderr.close)
|
||||||
|
self.addCleanup(lambda: process.wait(timeout=3))
|
||||||
|
self.addCleanup(lambda: process.poll() is None and process.kill())
|
||||||
|
payload = 'guest-proof-' * 1200
|
||||||
|
command = self.mod.frame_command('printf %s ' + payload, 'longprobe')
|
||||||
|
os.write(master, (command + '\nexit\n').encode())
|
||||||
|
output, error = process.communicate(timeout=3)
|
||||||
|
self.assertEqual(self.mod.parse_frame(output, 'longprobe'), (0, payload.encode()), error)
|
||||||
|
|
||||||
|
def test_generated_guest_lifecycle_probe_compiles(self):
|
||||||
|
compile(self.mod.lifecycle_guest_script(), '<guest-lifecycle-probe>', 'exec')
|
||||||
|
|
||||||
|
def session_fixture(self, records):
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
source = self.root / 'session-records.json'
|
||||||
|
source.write_text(json.dumps(records))
|
||||||
|
commands = self.root / 'session-fixture-bin'
|
||||||
|
commands.mkdir()
|
||||||
|
command = commands / 'loginctl'
|
||||||
|
command.write_text('''#!/usr/bin/env python3
|
||||||
|
import json, os, sys
|
||||||
|
records = json.load(open(os.environ['SMOKE_SESSION_RECORDS']))
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if args[0] == 'show-user':
|
||||||
|
print(' '.join(record['Id'] for record in records if str(record['User']) == args[1]))
|
||||||
|
elif args[0] == 'show-session':
|
||||||
|
record = next((record for record in records if record['Id'] == args[1]), None)
|
||||||
|
if record is None: sys.exit(1)
|
||||||
|
for key, value in record.items(): print(key + '=' + str(value))
|
||||||
|
else: sys.exit(1)
|
||||||
|
''')
|
||||||
|
command.chmod(0o755)
|
||||||
|
environment = patch.dict(os.environ, {'PATH': str(commands) + ':' + os.environ['PATH'],
|
||||||
|
'SMOKE_SESSION_RECORDS': str(source)})
|
||||||
|
environment.start()
|
||||||
|
self.addCleanup(environment.stop)
|
||||||
|
return source
|
||||||
|
|
||||||
|
def session_record(self, ident='serial1', state='active', cls='user', leader=1234):
|
||||||
|
import os
|
||||||
|
return {'Id': ident, 'User': os.getuid(), 'State': state, 'Class': cls,
|
||||||
|
'Leader': leader, 'TTY': 'ttyS0' if cls == 'user' else ''}
|
||||||
|
|
||||||
|
def test_logout_sessions_accept_closing_scope_and_manager_with_surviving_vm(self):
|
||||||
|
context, process, run = self.lifecycle_fixture()
|
||||||
|
original = self.session_record()
|
||||||
|
context['login_session'] = original
|
||||||
|
context['before'] = self.mod.lifecycle_snapshot(context)
|
||||||
|
self.session_fixture([dict(original, State='closing'),
|
||||||
|
self.session_record('manager1', 'active', 'manager')])
|
||||||
|
self.assertTrue(self.mod.no_open_logins(context))
|
||||||
|
self.assertEqual(self.mod.capture_logout_snapshot(context), context['before'])
|
||||||
|
|
||||||
|
def test_logout_sessions_initial_login_ignores_manager_but_rejects_open_replacement(self):
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
original = self.session_record()
|
||||||
|
context = {'uid': os.getuid(), 'login_session': original}
|
||||||
|
source = self.session_fixture([original, self.session_record('manager1', 'active', 'manager')])
|
||||||
|
self.assertEqual(self.mod.require_initial_login(os.getuid(), 'serial1'), original)
|
||||||
|
for state in ('online', 'active'):
|
||||||
|
with self.subTest(state=state):
|
||||||
|
source.write_text(json.dumps([dict(original, State='closing'),
|
||||||
|
self.session_record('new-login', state)]))
|
||||||
|
self.assertFalse(self.mod.no_open_logins(context))
|
||||||
|
source.write_text(json.dumps([dict(original, State='closing', Leader=9999)]))
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
self.mod.no_open_logins(context)
|
||||||
|
|
||||||
|
def test_logout_sessions_reject_login_appearing_during_snapshot(self):
|
||||||
|
import json
|
||||||
|
context, process, run = self.lifecycle_fixture()
|
||||||
|
original = self.session_record()
|
||||||
|
context['login_session'] = original
|
||||||
|
context['before'] = self.mod.lifecycle_snapshot(context)
|
||||||
|
source = self.session_fixture([dict(original, State='closing')])
|
||||||
|
context['on_snapshot'] = lambda: source.write_text(json.dumps([
|
||||||
|
dict(original, State='closing'), self.session_record('relogin')]))
|
||||||
|
self.assertTrue(self.mod.no_open_logins(context))
|
||||||
|
with self.assertRaises(self.mod.SmokeError):
|
||||||
|
self.mod.capture_logout_snapshot(context)
|
||||||
|
|
||||||
|
def test_guest_tools_accept_gentoo_remote_viewer_without_virt_viewer(self):
|
||||||
|
self.assertEqual(self.probe_fixture('clean'), 0)
|
||||||
|
self.assertEqual(self.probe_fixture('clean', live=True), 0)
|
||||||
Reference in New Issue
Block a user