Distinguish closed login scopes during lifecycle acceptance
This commit is contained in:
+83
-18
@@ -109,6 +109,77 @@ def verify_lifecycle_survival(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
|
||||
@@ -117,10 +188,6 @@ 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')
|
||||
@@ -142,15 +209,14 @@ if mode == 'start':
|
||||
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')
|
||||
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',
|
||||
@@ -177,18 +243,15 @@ elif mode == 'monitor':
|
||||
context = json.loads(context_path.read_text())
|
||||
try:
|
||||
deadline = time.monotonic() + 35
|
||||
while not no_user_sessions(context['uid']):
|
||||
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)
|
||||
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,
|
||||
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)})
|
||||
@@ -204,13 +267,13 @@ elif mode == 'finish':
|
||||
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))
|
||||
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_user_sessions': True,
|
||||
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:
|
||||
@@ -235,7 +298,9 @@ else:
|
||||
|
||||
|
||||
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'
|
||||
'from pathlib import Path\nsys.path.insert(0, "/usr/lib/hydra")\n'
|
||||
'from hydra.qmp import QMPClient, QMPError\n'
|
||||
@@ -273,7 +338,7 @@ def installed_logout_probe(console, password):
|
||||
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.
|
||||
# replacement login. The guest monitor independently confirms no open logins.
|
||||
time.sleep(20)
|
||||
console.send('hydratest\n')
|
||||
console.expect(rb'Password: ', 30)
|
||||
|
||||
@@ -342,6 +342,8 @@ else: print('fixture tool')
|
||||
'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)
|
||||
@@ -426,3 +428,75 @@ else: print('fixture tool')
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user