Files
scst/scripts/ci/test_vm.py
T
wenlxieandGleb Chesnokov 359dfbf11c ci: Run the scst_user Ginkgo specs on Linux 5.8 and 5.9
scst_user pins its data buffers from Linux 5.9, the first release in
which fork() gives the child a copy of a pinned page (commit
70e806e4e645, "mm: Do early cow for pinned pages during fork() for
ptes"). The Ginkgo "handler forks" spec checks this, but CI boots only
the current Fedora kernel, so the boundary itself is never tested.

Add a Ginkgo job matrix that runs the local "user" specs on Fedora 33's
Linux 5.8.18 and 5.9.16, on both sides of the boundary. On 5.9.16 the
fork spec relies on the pin alone; on 5.8.18 the handler protects its
buffers with MADV_DONTFORK, as required there. Both must keep correct
geometry and data. The jobs are named after their kernels, like the
regression jobs.

- kernel-5.8.env, kernel-5.9.env and their SHA256SUMS pin those kernels'
  rpms from Fedora's koji, like kernel.env and kernel.SHA256SUMS.
  Fedora's only 5.9.0 build is a rawhide kernel that defines RHEL_MAJOR 8
  and RHEL_MINOR 99, so SCST's backports treat it as RHEL and the build
  fails; 5.9.16 has the same fork() behavior.
- build-image takes an optional pin name, such as "kernel-5.9", for
  Ginkgo images. The image is otherwise built the same way.
- The Dockerfile downloads exactly the rpms listed in the selected
  checksum file, since 5.9 has no kernel-modules-core package.
- vm.py --local-only boots only the target guest, without the iSCSI
  initiator or the private network, and expects only the local report.
  The guest then writes a config without an iscsi section. The label
  filter is restricted to local specs: "local && (<filter>)", or "local"
  without one.
2026-10-20 08:43:01 +03:00

358 lines
19 KiB
Python

# SPDX-License-Identifier: GPL-2.0-only
"""Supervisor failure contracts; no SCST, module operations or real VM access."""
from contextlib import ExitStack, redirect_stdout
import io
import json
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from unittest.mock import Mock, patch
import vm
class SupervisorTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.output = Path(self.temporary.name)
self.manifest = dict(commit='a' * 40, suite='kunit', kernel='test-kernel')
def completion(self, **changes):
result = dict(self.manifest, mode='smoke', token='this-run', exit_code=0,
running_kernel='test-kernel')
result.update(changes)
(self.output / 'completion.json').write_text(json.dumps(result))
def test_clean_qemu_exit_without_completion_is_not_success(self):
with self.assertRaisesRegex(vm.VMError, 'completion'):
vm.validate_completion(self.output, 'smoke', 'this-run', self.manifest)
def test_report_must_match_this_boot_build_and_kernel(self):
for changes in ({'token': 'old-run'}, {'commit': 'b' * 40},
{'running_kernel': 'wrong-kernel'}, {'exit_code': 1}):
with self.subTest(changes=changes):
self.completion(**changes)
with self.assertRaises(vm.VMError):
vm.validate_completion(self.output, 'smoke', 'this-run', self.manifest)
def test_complete_smoke_boot(self):
self.completion()
vm.validate_completion(self.output, 'smoke', 'this-run', self.manifest)
def test_kernel_panic_survives_missing_guest_report(self):
(self.output / 'target.serial.log').write_text(
'[1.0] Kernel panic - not syncing: test crash\n[1.1] Call Trace:\n')
self.assertTrue(vm.diagnostics(self.output))
def test_initiator_crash_is_not_hidden_by_target(self):
(self.output / 'initiator.serial.log').write_text('[1.0] BUG: unable to handle page fault\n')
self.assertTrue(vm.diagnostics(self.output))
def test_daemon_warning_is_not_a_kernel_fault(self):
(self.output / 'initiator.serial.log').write_text(
'[120.175797] bash[481]: WARNING: daemon configuration\n')
self.assertFalse(vm.diagnostics(self.output))
def test_boot_failure_has_a_shorter_deadline(self):
target = Mock()
target.poll.return_value = None
with self.assertRaisesRegex(vm.VMError, 'boot deadline'):
vm.wait_guest(target, None, float('inf'), self.output / 'ready',
self.output / 'release', self.output / 'started', 0)
def test_deadline_does_not_depend_on_guest_response(self):
target = Mock()
target.poll.return_value = None
with self.assertRaisesRegex(vm.VMError, 'deadline'):
vm.wait_guest(target, None, 0, self.output / 'ready', self.output / 'release')
def test_peer_loss_aborts_target_wait(self):
target, peer = Mock(), Mock()
target.poll.return_value = None
peer.poll.return_value = 0
with self.assertRaisesRegex(vm.VMError, 'initiator exited'):
vm.wait_guest(target, peer, float('inf'), self.output / 'ready', self.output / 'release')
def test_stuck_qemu_is_killed_after_term(self):
process = Mock(pid=123)
process.poll.return_value = None
process.wait.side_effect = [subprocess.TimeoutExpired('qemu', 5), 0]
with patch('vm.os.killpg') as kill:
vm.stop(process)
self.assertEqual(kill.call_count, 2)
def test_all_skipped_kunit_is_not_a_pass(self):
self.completion(mode='kunit')
results = self.output / 'kunit/run'
results.mkdir(parents=True)
(results / 'result.json').write_text(json.dumps(dict(
status='SKIP', counts=dict(passed=0), not_run_suites=[], module_state='unloaded')))
with self.assertRaisesRegex(vm.VMError, 'KUnit did not complete'):
vm.validate_completion(self.output, 'kunit', 'this-run', self.manifest)
def test_ginkgo_requires_both_transport_reports(self):
self.manifest['suite'] = 'ginkgo'
self.completion(mode='ginkgo')
with self.assertRaisesRegex(vm.VMError, 'both local and iSCSI'):
vm.validate_completion(self.output, 'ginkgo', 'this-run', self.manifest)
def test_ginkgo_cannot_pass_with_empty_or_failed_transport(self):
self.manifest['suite'] = 'ginkgo'
self.completion(mode='ginkgo')
for group in ('local', 'iscsi'):
(self.output / 'ginkgo' / group).mkdir(parents=True)
for succeeded, count, state in ((True, 0, 'passed'), (False, 8, 'failed'),
(True, 8, 'skipped'), (True, 8, 'passed')):
with self.subTest(succeeded=succeeded, count=count, state=state):
for group in ('local', 'iscsi'):
(self.output / 'ginkgo' / group / 'report.json').write_text(json.dumps([
dict(SuiteSucceeded=succeeded if group == 'iscsi' else True,
SuiteConfig=dict(LabelFilter=''),
PreRunStats=dict(SpecsThatWillRun=count if group == 'iscsi' else 31),
SpecReports=[dict(LeafNodeType='BeforeSuite', State='passed'),
dict(LeafNodeType='It', State=state)])]))
if succeeded and count and state == 'passed':
vm.validate_completion(self.output, 'ginkgo', 'this-run', self.manifest)
else:
with self.assertRaisesRegex(vm.VMError, 'incomplete or failed Ginkgo'):
vm.validate_completion(self.output, 'ginkgo', 'this-run', self.manifest)
def test_ginkgo_rejects_unexpected_filter_in_either_transport(self):
self.manifest['suite'] = 'ginkgo'
self.completion(mode='ginkgo')
for group in ('local', 'iscsi'):
(self.output / 'ginkgo' / group).mkdir(parents=True)
for expected, actual in (('basic || smoke', ''), ('', 'basic || smoke')):
for mismatched in ('local', 'iscsi'):
with self.subTest(expected=expected, mismatched=mismatched):
for group in ('local', 'iscsi'):
(self.output / 'ginkgo' / group / 'report.json').write_text(json.dumps([
dict(SuiteSucceeded=True, PreRunStats=dict(SpecsThatWillRun=2),
SuiteConfig=dict(LabelFilter=actual if group == mismatched else expected),
SpecReports=[dict(LeafNodeType='It', State='passed')])]))
with self.assertRaisesRegex(vm.VMError, 'label filter differs'):
vm.validate_completion(self.output, 'ginkgo', 'this-run', self.manifest,
expected)
def test_local_only_ginkgo_requires_exactly_the_local_report(self):
self.manifest['suite'] = 'ginkgo'
self.completion(mode='ginkgo')
for groups in ((), ('iscsi',), ('local', 'iscsi'), ('local',)):
with self.subTest(groups=groups):
shutil.rmtree(self.output / 'ginkgo', ignore_errors=True)
# The runner names each report directory run-<random>.
for index, group in enumerate(groups):
(self.output / 'ginkgo' / f'run-{index}').mkdir(parents=True)
(self.output / 'ginkgo' / f'run-{index}' / 'report.json').write_text(json.dumps([
dict(SuiteSucceeded=True, SuiteLabels=[group],
SuiteConfig=dict(LabelFilter='user'),
PreRunStats=dict(SpecsThatWillRun=1),
SpecReports=[dict(LeafNodeType='It', State='passed')])]))
if groups == ('local',):
vm.validate_completion(self.output, 'ginkgo', 'this-run', self.manifest,
'user', local_only=True)
else:
with self.assertRaisesRegex(vm.VMError, 'local suite only'):
vm.validate_completion(self.output, 'ginkgo', 'this-run', self.manifest,
'user', local_only=True)
def test_local_only_marker_is_written_to_guest_input_before_boot(self):
for local_only in (False, True):
with self.subTest(local_only=local_only):
work = self.output / f'work-{local_only}'
output = self.output / f'output-{local_only}'
work.mkdir()
output.mkdir()
def boot(*args, **kwargs):
self.assertEqual((work / 'target/local-only').exists(), local_only)
return Mock()
with patch('vm.subprocess.run'), patch('vm.subprocess.Popen', side_effect=boot), \
ExitStack() as stack:
vm.launch(stack, self.output, work, output, 'target', 'ginkgo',
'this-run', 'tcg', local_only=local_only)
def test_ginkgo_filter_is_written_to_guest_input_before_boot(self):
for index, label in enumerate(('', 'basic || smoke')):
with self.subTest(label=label):
work = self.output / f'work-{index}'
output = self.output / f'output-{index}'
work.mkdir()
output.mkdir()
process = Mock()
process.poll.return_value = 0
def boot(*args, **kwargs):
self.assertEqual((work / 'target/ginkgo-label').read_text(), label)
return process
with patch('vm.subprocess.run'), patch('vm.subprocess.Popen', side_effect=boot), \
ExitStack() as stack:
vm.launch(stack, self.output, work, output, 'target', 'ginkgo',
'this-run', 'tcg', ginkgo_label=label)
class ConsoleTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.image = self.root / 'image'
self.image.mkdir()
self.output = self.root / 'results'
def run_guest(self, mode, console=None, exit_code=0, completion='valid',
error=None, serial=None, ginkgo_label='', local_only=False):
manifest = dict(commit='a' * 40, suite=mode, kernel='test-kernel')
(self.image / 'image.json').write_text(json.dumps(manifest))
self.launched = []
def launch(stack, image, work, output, role, guest_mode, token, accel, network,
ginkgo_label='', local_only=False):
self.launched.append((role, network is not None, local_only))
directory = output / role
directory.mkdir()
if network:
network.touch()
(output / f'{role}.serial.log').write_text((serial or {}).get(role, 'boot log\n'))
if role == 'target':
if console is not None:
(directory / 'tests.log').write_bytes(console)
if completion == 'valid':
(directory / 'completion.json').write_text(json.dumps(dict(
manifest, mode=mode, token=token, exit_code=exit_code,
running_kernel=manifest['kernel'])))
elif completion == 'malformed':
(directory / 'completion.json').write_text('{')
if mode == 'kunit':
report = directory / 'kunit/run/result.json'
report.parent.mkdir(parents=True)
report.write_text(json.dumps(dict(status='PASS', counts=dict(passed=2),
not_run_suites=[], module_state='unloaded')))
else:
for transport in ('local',) if local_only else ('local', 'iscsi'):
report = directory / 'ginkgo' / f'run-{transport}' / 'report.json'
report.parent.mkdir(parents=True)
report.write_text(json.dumps([dict(
SuiteSucceeded=True, SuiteLabels=[transport],
PreRunStats=dict(SpecsThatWillRun=2),
SuiteConfig=dict(LabelFilter=ginkgo_label),
SpecReports=[dict(LeafNodeType='It', State='passed')])]))
return Mock()
stdout = io.StringIO()
with patch('vm.launch', side_effect=launch), patch('vm.configure_ssh'), \
patch('vm.wait_guest', side_effect=error), redirect_stdout(stdout):
result = vm.run(self.image, self.output, mode, 10, 'tcg', ginkgo_label, local_only)
self.assertEqual(json.loads((self.output / 'result.json').read_text()), result)
if console is not None:
self.assertEqual((self.output / 'target/tests.log').read_bytes(), console)
return result, stdout.getvalue()
def test_kunit_success_prints_suites_and_counts(self):
result, output = self.run_guest('kunit', console=(
b'[PASS] scst-LIB\nTests: PASS - 2 passed, 0 failed, 1 skipped\nResult: PASS\n'))
self.assertEqual(result['status'], 'PASS')
self.assertIn('[PASS] scst-LIB', output)
self.assertIn('2 passed, 0 failed, 1 skipped', output)
self.assertIn('kunit: PASS', output)
self.assertNotIn('boot log', output)
def test_ginkgo_success_prints_both_transport_summaries(self):
result, output = self.run_guest('ginkgo', console=(
b'Running Suite: local\n2 Passed | 0 Failed\n'
b'Running Suite: iSCSI\n2 Passed | 0 Failed\n'))
self.assertEqual(result['status'], 'PASS')
self.assertIn('Running Suite: local', output)
self.assertIn('Running Suite: iSCSI', output)
self.assertEqual(output.count('2 Passed | 0 Failed'), 2)
self.assertIn('ginkgo: PASS', output)
self.assertIn('Label filter: <all>', output)
self.assertEqual(result['ginkgo_label'], '')
def test_ginkgo_label_sets_reach_both_suites_and_are_reported(self):
for label, expression in (('basic || smoke', 'basic || smoke'),
('ci-pr', 'basic || smoke'), ('ci-push', '!race')):
with self.subTest(label=label):
self.output = self.root / label
result, output = self.run_guest('ginkgo', ginkgo_label=label)
self.assertEqual(result['status'], 'PASS')
self.assertEqual(result['ginkgo_label'], expression)
self.assertIn(f'Label filter: {expression}', output)
def test_local_only_ginkgo_boots_one_guest_without_a_network(self):
for local_only, launched in ((False, [('target', True, False), ('initiator', True, False)]),
(True, [('target', False, True)])):
with self.subTest(local_only=local_only):
self.output = self.root / f'local-only-{local_only}'
result, _ = self.run_guest('ginkgo', ginkgo_label='user', local_only=local_only)
self.assertEqual(result['status'], 'PASS')
self.assertEqual(result['local_only'], local_only)
self.assertEqual(self.launched, launched)
def test_local_only_restricts_the_label_filter_to_local_specs(self):
for label, expression in (('', 'local'), ('user', 'local && (user)'),
('basic || smoke', 'local && (basic || smoke)'),
('ci-pr', 'local && (basic || smoke)')):
with self.subTest(label=label):
self.output = self.root / f'filter-{label or "all"}'
result, output = self.run_guest('ginkgo', ginkgo_label=label, local_only=True)
self.assertEqual(result['status'], 'PASS')
self.assertEqual(result['ginkgo_label'], expression)
self.assertIn(f'Label filter: {expression}', output)
def test_failed_tests_print_assertions_and_preserve_failed_verdict(self):
for mode in ('kunit', 'ginkgo'):
with self.subTest(mode=mode):
self.output = self.root / mode
result, output = self.run_guest(mode, exit_code=1, console=(
b'[FAIL] failing_test\nExpected 2 to equal 3\n'))
self.assertEqual(result['status'], 'ERROR')
self.assertIn('failing_test', output)
self.assertIn('Expected 2 to equal 3', output)
self.assertIn('Error: guest failed', output)
self.assertIn(f'{mode}: ERROR', output)
def test_crash_before_tests_prints_diagnostics_without_a_guest_report(self):
serial = '[1.0] Kernel panic - not syncing: test crash\n' + 'noise\n' * 100
result, output = self.run_guest('kunit', completion='missing', serial={'target': serial})
self.assertEqual(result['status'], 'ERROR')
self.assertIn('No guest test output was saved', output)
self.assertIn('missing or invalid guest completion report', output)
self.assertIn('Kernel panic - not syncing: test crash', output)
self.assertEqual((self.output / 'target.serial.log').read_text(), serial)
def test_malformed_completion_does_not_hide_partial_test_output(self):
result, output = self.run_guest('kunit', completion='malformed',
console=b'[PASS] scst-LIB\npartial \xff')
self.assertEqual(result['status'], 'ERROR')
self.assertIn('[PASS] scst-LIB', output)
self.assertIn('partial \ufffd', output)
self.assertIn('missing or invalid guest completion report', output)
def test_timeout_prints_bounded_tails_from_both_guests(self):
serial = {'target': 'early boot\n' + 'x' * 70000 + '\n' + 'progress\n' * 100,
'initiator': 'initiator connection lost\n'}
result, output = self.run_guest('ginkgo', console=b'Running Suite: local\n',
error=vm.VMError('guest deadline expired'), serial=serial)
self.assertEqual(result['status'], 'ERROR')
self.assertIn('Running Suite: local', output)
self.assertIn('guest deadline expired', output)
self.assertIn('target.serial.log: last 80 lines', output)
self.assertIn('initiator.serial.log: last 80 lines', output)
self.assertIn('initiator connection lost', output)
self.assertNotIn('early boot', output)
self.assertEqual(output.count(' progress\n'), 80)
self.assertLess(len(output), 2000)
for role, log in serial.items():
self.assertEqual((self.output / f'{role}.serial.log').read_text(), log)
if __name__ == '__main__':
unittest.main()