Distinguish closed login scopes during lifecycle acceptance

This commit is contained in:
Codex
2026-10-13 04:58:09 +03:00
parent 2902058e7e
commit 050c7b8d5a
2 changed files with 157 additions and 18 deletions
+83 -18
View File
@@ -109,6 +109,77 @@ def verify_lifecycle_survival(before, after):
raise SmokeError('VM identity, private runtime, or audio endpoints changed across logout') 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''' LIFECYCLE_BODY = r'''
def run(*args): def run(*args):
return subprocess.run(args, check=True, capture_output=True, text=True, timeout=40).stdout return subprocess.run(args, check=True, capture_output=True, text=True, timeout=40).stdout
@@ -117,10 +188,6 @@ def cli(context, *args):
return json.loads(run('hydra', '--config', context['config'], '--state', context['state'], return json.loads(run('hydra', '--config', context['config'], '--state', context['state'],
'--runtime', context['runtime'], *args)) '--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): def write_report(path, data):
temporary = path.with_suffix('.tmp') temporary = path.with_suffix('.tmp')
temporary.write_text(json.dumps(data, indent=2) + '\n') temporary.write_text(json.dumps(data, indent=2) + '\n')
@@ -142,15 +209,14 @@ if mode == 'start':
raise SmokeError('Lifecycle test needs the real login runtime directory') raise SmokeError('Lifecycle test needs the real login runtime directory')
if run('loginctl', 'show-user', str(os.getuid()), '-p', 'Linger', '--value').strip() != 'no': if run('loginctl', 'show-user', str(os.getuid()), '-p', 'Linger', '--value').strip() != 'no':
raise SmokeError('Lingering would mask the installed logout policy') raise SmokeError('Lingering would mask the installed logout policy')
rows = run('loginctl', 'list-sessions', '--no-legend', '--no-pager').splitlines() login_session = require_initial_login(os.getuid(), os.environ['XDG_SESSION_ID'])
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] name = 'logout-' + base.name.rsplit('-', 1)[1]
state = base / 'state' state = base / 'state'
runtime = user_runtime / base.name runtime = user_runtime / base.name
context = {'uid': os.getuid(), 'name': name, 'config': str(base / 'config'), context = {'uid': os.getuid(), 'name': name, 'config': str(base / 'config'),
'state': str(state), 'runtime': str(runtime), 'user_runtime': str(user_runtime), 'state': str(state), 'runtime': str(runtime), 'user_runtime': str(user_runtime),
'uuid': str(uuid.uuid5(uuid.NAMESPACE_URL, str(state / name / 'disk.qcow2'))), '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']} 'audio_services': ['pipewire.service', 'pipewire-pulse.service', 'wireplumber.service']}
run('systemctl', '--user', 'start', *context['audio_services']) run('systemctl', '--user', 'start', *context['audio_services'])
cli(context, 'add', name, '--memory', '256', '--cpus', '1', '--disk-size', '64M', cli(context, 'add', name, '--memory', '256', '--cpus', '1', '--disk-size', '64M',
@@ -177,18 +243,15 @@ elif mode == 'monitor':
context = json.loads(context_path.read_text()) context = json.loads(context_path.read_text())
try: try:
deadline = time.monotonic() + 35 deadline = time.monotonic() + 35
while not no_user_sessions(context['uid']): while not no_open_logins(context):
if time.monotonic() >= deadline: if time.monotonic() >= deadline:
raise SmokeError('Serial session did not actually log out') raise SmokeError('Serial session did not actually log out')
time.sleep(.2) time.sleep(.2)
# Exceed systemd's usual 10-second user-manager stop delay. No helper # Exceed systemd's usual 10-second user-manager stop delay. No helper
# login or lingering is allowed to keep the tested user session alive. # login or lingering is allowed to keep the tested user session alive.
time.sleep(15) time.sleep(15)
if not no_user_sessions(context['uid']): during = capture_logout_snapshot(context)
raise SmokeError('Another user login masked the logout interval') write_report(observation_path, {'status': 'passed', 'no_open_logins': True,
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}) 'survival_seconds': 15, 'during_logout': during})
except Exception as exc: except Exception as exc:
write_report(observation_path, {'status': 'failed', 'error': str(exc)}) write_report(observation_path, {'status': 'failed', 'error': str(exc)})
@@ -204,13 +267,13 @@ elif mode == 'finish':
raise SmokeError('Logout monitor died or did not record survival') raise SmokeError('Logout monitor died or did not record survival')
time.sleep(.2) time.sleep(.2)
observation = json.loads(observation_path.read_text()) observation = json.loads(observation_path.read_text())
if observation.get('status') != 'passed' or observation.get('no_user_sessions') is not True: if observation.get('status') != 'passed' or observation.get('no_open_logins') is not True:
raise SmokeError('No-session observation failed: ' + json.dumps(observation)) raise SmokeError('No-open-login observation failed: ' + json.dumps(observation))
after = lifecycle_snapshot(context) after = lifecycle_snapshot(context)
verify_lifecycle_survival(context['before'], after) verify_lifecycle_survival(context['before'], after)
if cli(context, 'status', context['name']).get('status') != 'running': if cli(context, 'status', context['name']).get('status') != 'running':
raise SmokeError('Real installed hydra CLI could not reconnect after login') raise SmokeError('Real installed hydra CLI could not reconnect after login')
print(json.dumps({'status': 'passed', 'real_logout': True, 'no_user_sessions': True, print(json.dumps({'status': 'passed', 'real_logout': True, 'no_open_logins': True,
'survival_seconds': observation['survival_seconds'], 'same_vm': after, 'survival_seconds': observation['survival_seconds'], 'same_vm': after,
'audio_scope': 'Socket and service continuity only; signal/playback untested'})) 'audio_scope': 'Socket and service continuity only; signal/playback untested'}))
finally: finally:
@@ -235,7 +298,9 @@ else:
def lifecycle_guest_script(): def lifecycle_guest_script():
helpers = (lifecycle_socket, lifecycle_snapshot, verify_lifecycle_survival) 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' return ('import json, os, socket, stat, subprocess, sys, time, uuid\n'
'from pathlib import Path\nsys.path.insert(0, "/usr/lib/hydra")\n' 'from pathlib import Path\nsys.path.insert(0, "/usr/lib/hydra")\n'
'from hydra.qmp import QMPClient, QMPError\n' 'from hydra.qmp import QMPClient, QMPError\n'
@@ -273,7 +338,7 @@ def installed_logout_probe(console, password):
console.send('exit\n') console.send('exit\n')
console.expect(rb'login: ', 30) console.expect(rb'login: ', 30)
# This is intentionally a period at the actual login prompt with no # This is intentionally a period at the actual login prompt with no
# replacement login. The guest monitor independently confirms no sessions. # replacement login. The guest monitor independently confirms no open logins.
time.sleep(20) time.sleep(20)
console.send('hydratest\n') console.send('hydratest\n')
console.expect(rb'Password: ', 30) console.expect(rb'Password: ', 30)
+74
View File
@@ -342,6 +342,8 @@ else: print('fixture tool')
'query-uuid': {'UUID': context['uuid']}, 'query-uuid': {'UUID': context['uuid']},
'query-status': {'status': 'running'}, 'query-status': {'status': 'running'},
'query-kvm': {'enabled': True, 'present': True}} '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'], {}), stream.write(json.dumps({'return': replies.get(request['execute'], {}),
'id': request['id']}).encode() + b'\n') 'id': request['id']}).encode() + b'\n')
thread = threading.Thread(target=qmp_server) thread = threading.Thread(target=qmp_server)
@@ -426,3 +428,75 @@ else: print('fixture tool')
def test_generated_guest_lifecycle_probe_compiles(self): def test_generated_guest_lifecycle_probe_compiles(self):
compile(self.mod.lifecycle_guest_script(), '<guest-lifecycle-probe>', 'exec') 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)