Probe installed user VM and audio continuity across logout
This commit is contained in:
+257
-1
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -32,6 +33,257 @@ class SmokeError(RuntimeError):
|
|||||||
pass
|
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')
|
||||||
|
|
||||||
|
|
||||||
|
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 no_user_sessions(uid):
|
||||||
|
rows = run('loginctl', 'list-sessions', '--no-legend', '--no-pager').splitlines()
|
||||||
|
return not any(len(row.split()) > 1 and row.split()[1] == str(uid) for row in rows)
|
||||||
|
|
||||||
|
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')
|
||||||
|
rows = run('loginctl', 'list-sessions', '--no-legend', '--no-pager').splitlines()
|
||||||
|
if sum(len(row.split()) > 1 and row.split()[1] == str(os.getuid()) for row in rows) != 1:
|
||||||
|
raise SmokeError('Lifecycle test requires exactly the serial user login')
|
||||||
|
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'))),
|
||||||
|
'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_user_sessions(context['uid']):
|
||||||
|
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)
|
||||||
|
if not no_user_sessions(context['uid']):
|
||||||
|
raise SmokeError('Another user login masked the logout interval')
|
||||||
|
during = lifecycle_snapshot(context)
|
||||||
|
verify_lifecycle_survival(context['before'], during)
|
||||||
|
write_report(observation_path, {'status': 'passed', 'no_user_sessions': 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_user_sessions') is not True:
|
||||||
|
raise SmokeError('No-session 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_user_sessions': 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)
|
||||||
|
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 sessions.
|
||||||
|
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):
|
def reserve_disk(path):
|
||||||
try:
|
try:
|
||||||
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||||
@@ -85,9 +337,12 @@ def frame_command(command, token):
|
|||||||
if not re.fullmatch(r'[a-zA-Z0-9]+', token):
|
if not re.fullmatch(r'[a-zA-Z0-9]+', token):
|
||||||
raise SmokeError('Invalid framing token')
|
raise SmokeError('Invalid framing token')
|
||||||
encoded = base64.b64encode(command.encode()).decode()
|
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.
|
# Literal control bytes appear only in output, never in the echoed command.
|
||||||
return (f"printf '\\036{token}:BEGIN\\037'; "
|
return (f"printf '\\036{token}:BEGIN\\037'; "
|
||||||
f"sh -c \"$(printf %s {shlex.quote(encoded)} | base64 -d)\"; "
|
f"sh -c \"$(printf %s {chunks} | base64 -d)\"; "
|
||||||
f"rc=$?; printf '\\036{token}:END:%s\\037' \"$rc\"")
|
f"rc=$?; printf '\\036{token}:END:%s\\037' \"$rc\"")
|
||||||
|
|
||||||
|
|
||||||
@@ -508,6 +763,7 @@ def main(argv=None):
|
|||||||
checkpoint('installed_contract', console.command(INSTALLED_CHECK, 120))
|
checkpoint('installed_contract', console.command(INSTALLED_CHECK, 120))
|
||||||
checkpoint('lan_to_vm_tcp', console.command(NETWORK_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('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')
|
console.send('poweroff\n')
|
||||||
vm.wait_poweroff(90)
|
vm.wait_poweroff(90)
|
||||||
checkpoint('installed_poweroff', b'Normal installed guest shutdown')
|
checkpoint('installed_poweroff', b'Normal installed guest shutdown')
|
||||||
|
|||||||
@@ -297,3 +297,132 @@ else: print('fixture tool')
|
|||||||
|
|
||||||
def test_tcp_probe_rejects_truncated_marker(self):
|
def test_tcp_probe_rejects_truncated_marker(self):
|
||||||
self.assertNotEqual(self.tcp_probe([b'HYDRA_LAN_TO_']).returncode, 0)
|
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}}
|
||||||
|
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')
|
||||||
|
|||||||
Reference in New Issue
Block a user