2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-10-22 07:38:08 +00:00

test(genesis): exercise maintenance shell confirmation and return

This commit is contained in:
Vinícius Ferrão
2026-10-16 21:51:02 -03:00
parent 385f641e03
commit 8dab5e4989
2 changed files with 123 additions and 11 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y \
build-essential meson ninja-build pkg-config libnewt-dev \
libsystemd-dev python3-pyte bubblewrap systemd-journal-remote
- name: Enable isolated journal test namespaces
- name: Enable isolated console test namespaces
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Exercise the newt dialogs
run: /usr/bin/python3 xCAT-test/native/genesis_console_dialogs.py
+122 -10
View File
@@ -7,6 +7,7 @@ import os
from pathlib import Path
import pty
import select
import shlex
import signal
import struct
import subprocess
@@ -61,6 +62,16 @@ class ConsoleTerminal(unittest.TestCase):
def console_command(self):
return [str(self.binary)]
def isolated_prefix(self, mounts):
command = ["bwrap", "--unshare-all", "--die-with-parent", "--ro-bind", "/", "/",
"--dev", "/dev", "--proc", "/proc", "--tmpfs", "/run", "--tmpfs", "/var/log",
*mounts]
result = subprocess.run(command + ["/bin/true"], text=True,
capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0,
"Console tests require working Bubblewrap namespaces: " + result.stderr)
return command
def stop(self):
if self.process.poll() is None:
self.process.terminate()
@@ -196,19 +207,12 @@ class ConsoleJournal(ConsoleTerminal):
self.entries = []
machine_id_file = self.root / "machine-id"
machine_id_file.write_text(self.machine_id + "\n")
sandbox = [
"bwrap", "--unshare-all", "--die-with-parent", "--ro-bind", "/", "/",
"--dev", "/dev", "--proc", "/proc", "--tmpfs", "/run", "--tmpfs", "/var/log",
self.sandbox = self.isolated_prefix([
"--bind", str(self.root), str(self.root),
"--ro-bind", str(machine_id_file), "/etc/machine-id",
"--ro-bind", str(journal_root), "/var/log/journal",
]
result = subprocess.run(sandbox + ["/bin/true"], text=True,
capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0,
"Journal tests require working Bubblewrap namespaces: " + result.stderr)
self.sandbox = sandbox
return sandbox + [str(self.binary)]
])
return self.sandbox + [str(self.binary)]
def write_journal(self, entries, interval=1000000):
records = []
@@ -474,5 +478,113 @@ class ConsoleJournal(ConsoleTerminal):
self.wait_text("RECORD-0129")
class ConsoleShell(ConsoleTerminal):
"""Mount a fixture libexec directory containing the external shell launcher.
The linked console still owns confirmation, fork/exec, waiting and redraw.
The per-test temporary root is the only writable host-backed directory.
The libexec mount is read-only.
/run and /var/log are private tmpfs mounts.
"""
def console_command(self):
directory = self.root / "libexec"
(directory / "xcat").mkdir(parents=True)
self.launcher = directory / "xcat/genesis-maintenance-shell"
self.calls = self.root / "shell-calls"
self.input = self.root / "shell-input"
self.write_launcher(0)
return self.isolated_prefix([
"--bind", str(self.root), str(self.root),
"--ro-bind", str(directory), "/usr/libexec",
]) + [str(self.binary)]
def write_launcher(self, exit_status):
self.launcher.write_text(
"#!/bin/sh\n"
f"printf '%s\\n' started >> {shlex.quote(str(self.calls))}\n"
"printf 'SHELL-RUNNING\\n'\n"
"IFS= read -r answer\n"
f"printf '%s\\n' \"$answer\" > {shlex.quote(str(self.input))}\n"
f"exit {exit_status}\n")
self.launcher.chmod(0o755)
def shell_dialog_text(self, display):
lines = display.splitlines()
for row, line in enumerate(lines):
if "Maintenance shell" not in line:
continue
left, right = line.rfind("┌"), line.find("┐")
if left < 0 or right <= left:
continue
content = []
for line in lines[row + 1:]:
if line[left:right + 1].startswith("└") and line[right] == "┘":
return self.visible_text("\n".join(content))
content.append(line[left + 1:right])
return ""
def confirm_shell(self):
self.key(b"\x1b[24~")
display = self.wait_screen(
lambda text: "Open a root maintenance shell?" in self.shell_dialog_text(text),
"maintenance shell confirmation")
self.assertIn("Provisioning services continue in the background.",
self.shell_dialog_text(display))
def wait_main(self):
def is_main(display):
return ("Maintenance shell" not in display
and "SHELL-RUNNING" not in display
and "Genesis diagnostics" not in display
and "Genesis status" in display)
self.wait_screen(is_main, "the restored status console")
self.assert_screen_stays(is_main, "The restored status console changed", 1)
def test_cancel_does_not_launch_shell(self):
self.confirm_shell()
self.assertFalse(self.calls.exists())
self.key(b"\t\r")
self.wait_main()
self.assertFalse(self.calls.exists())
self.key(b"\x1bOP")
self.wait_text("Genesis help")
def test_confirmed_shell_accepts_input_and_returns_to_console(self):
for count, exit_status in enumerate((0, 7), 1):
with self.subTest(launch=count, exit_status=exit_status):
self.write_launcher(exit_status)
self.confirm_shell()
self.assertEqual(self.calls.read_text() if self.calls.exists() else "",
"started\n" * (count - 1))
self.key(b"\r")
self.wait_text("SHELL-RUNNING")
self.assertEqual(self.calls.read_text(), "started\n" * count)
self.key(f"operator-input-{count}\r".encode())
self.wait_main()
self.assertEqual(self.input.read_text(), f"operator-input-{count}\n")
self.assertIsNone(self.process.poll())
self.key(b"\x1bOQ")
self.wait_text("Genesis diagnostics")
self.key(b"\x1bOQ")
self.wait_main()
def test_shell_exec_error_is_reported_and_console_recovers(self):
self.launcher.unlink()
self.confirm_shell()
self.key(b"\r")
display = self.wait_screen(
lambda text: "No such file or directory" in self.shell_dialog_text(text),
"the maintenance shell executable error")
self.assertIn("The maintenance shell could not be opened: No such file or directory",
self.shell_dialog_text(display))
self.assertFalse(self.calls.exists())
self.key(b"\r")
self.wait_main()
self.key(b"\x1bOP")
self.wait_text("Genesis help")
if __name__ == "__main__":
unittest.main(verbosity=2)