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')
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user