2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-10-21 15:18:19 +00:00

Merge pull request #7958 from VersatusHPC/fix/genesis-journal-selection

fix(genesis): retain the selected journal entry across history rollover
This commit is contained in:
Daniel Hilst
2026-10-19 16:14:11 -03:00
committed by GitHub
3 changed files with 213 additions and 59 deletions
@@ -266,19 +266,36 @@ static bool genesis_journal_entry(sd_journal *journal, char *source, size_t sour
return true;
}
static void format_logs(char *text, size_t size) {
struct log_record {
char text[LOG_LINE_SIZE];
char *cursor;
unsigned int first_row;
unsigned int row_count;
bool selected;
};
struct log_view {
struct log_record records[LOG_LINE_COUNT];
unsigned int count;
unsigned int start;
};
static void free_log_view(struct log_view *view) {
for (unsigned int index = 0; index < LOG_LINE_COUNT; index++)
free(view->records[index].cursor);
}
static void load_logs(struct log_view *view, const char *selected_cursor) {
sd_journal *journal = NULL;
char lines[LOG_LINE_COUNT][LOG_LINE_SIZE] = {{0}};
unsigned int count = 0;
unsigned int next = 0;
int result;
if (size == 0)
return;
text[0] = '\0';
result = sd_journal_open(&journal, SD_JOURNAL_LOCAL_ONLY | SD_JOURNAL_SYSTEM);
if (result < 0) {
xcat_set_text(text, size, "Genesis journal is unavailable: %s", strerror(-result));
xcat_set_text(view->records[0].text, LOG_LINE_SIZE, "Genesis journal is unavailable: %s",
strerror(-result));
view->count = 1;
return;
}
@@ -289,6 +306,7 @@ static void format_logs(char *text, size_t size) {
char source[96] = "";
char timestamp[16] = "--:--:--";
uint64_t realtime;
struct log_record *record = &view->records[next];
if (!genesis_journal_entry(journal, source, sizeof(source)) ||
!journal_value(journal, "MESSAGE", message, sizeof(message)))
@@ -300,32 +318,25 @@ static void format_logs(char *text, size_t size) {
if (localtime_r(&seconds, &local_time) != NULL)
strftime(timestamp, sizeof(timestamp), "%H:%M:%S", &local_time);
}
snprintf(lines[next], sizeof(lines[next]), "%s %-24.24s %s", timestamp, source, message);
free(record->cursor);
record->cursor = NULL;
sd_journal_get_cursor(journal, &record->cursor);
record->selected =
selected_cursor != NULL && sd_journal_test_cursor(journal, selected_cursor) > 0;
snprintf(record->text, sizeof(record->text), "%s %-24.24s %s", timestamp, source, message);
next = (next + 1) % LOG_LINE_COUNT;
count++;
}
sd_journal_close(journal);
if (count == 0) {
xcat_set_text(text, size, "No Genesis journal entries are available.");
xcat_set_text(view->records[0].text, LOG_LINE_SIZE,
"No Genesis journal entries are available.");
view->count = 1;
return;
}
{
unsigned int available = count < LOG_LINE_COUNT ? count : LOG_LINE_COUNT;
unsigned int start = count < LOG_LINE_COUNT ? 0 : next;
unsigned int index;
size_t used = 0;
for (index = 0; index < available && used + 1 < size; index++) {
unsigned int line = (start + index) % LOG_LINE_COUNT;
int written =
snprintf(text + used, size - used, "%s%s", index > 0 ? "\n" : "", lines[line]);
if (written < 0 || (size_t)written >= size - used)
break;
used += (size_t)written;
}
}
view->count = count < LOG_LINE_COUNT ? count : LOG_LINE_COUNT;
view->start = count < LOG_LINE_COUNT ? 0 : next;
}
static unsigned int append_wrapped_log_record(newtComponent list_box, const char *record,
@@ -365,30 +376,48 @@ static unsigned int append_wrapped_log_record(newtComponent list_box, const char
return count;
}
static unsigned int populate_log_listbox(newtComponent list_box, const char *text) {
const char *record = text;
static unsigned int populate_log_listbox(newtComponent list_box, struct log_view *view) {
unsigned int count = 0;
while (*record != '\0') {
const char *end = strchr(record, '\n');
size_t length = end != NULL ? (size_t)(end - record) : strlen(record);
for (unsigned int index = 0; index < view->count; index++) {
struct log_record *record = &view->records[(view->start + index) % LOG_LINE_COUNT];
count = append_wrapped_log_record(list_box, record, length, count);
if (end == NULL)
break;
record = end + 1;
record->first_row = count + 1;
count = append_wrapped_log_record(list_box, record->text, strlen(record->text), count);
record->row_count = count + 1 - record->first_row;
}
if (count == 0)
count = append_wrapped_log_record(list_box, "", 0, count);
return count;
}
static const struct log_record *selected_log_record(const struct log_view *view,
unsigned int selected) {
for (unsigned int index = 0; index < view->count; index++) {
const struct log_record *record = &view->records[(view->start + index) % LOG_LINE_COUNT];
if (selected >= record->first_row && selected - record->first_row < record->row_count)
return record;
}
return NULL;
}
static unsigned int retained_log_row(const struct log_view *view, unsigned int offset) {
for (unsigned int index = 0; index < view->count; index++) {
const struct log_record *record = &view->records[(view->start + index) % LOG_LINE_COUNT];
if (record->selected) {
if (offset >= record->row_count)
offset = record->row_count - 1;
return record->first_row + offset;
}
}
return 1;
}
static void show_logs(void) {
struct newtExitStruct event;
newtComponent form;
newtComponent list_box;
char text[LOG_LINE_COUNT * LOG_LINE_SIZE];
char previous[LOG_LINE_COUNT * LOG_LINE_SIZE] = "";
struct log_view previous = {0};
unsigned int item_count = 0;
unsigned int selected = 0;
bool follow = true;
@@ -410,15 +439,17 @@ static void show_logs(void) {
newtPushHelpLine(" F3 Back Esc Back Up/Down Scroll End Follow");
while (!xcat_console_stop_requested) {
format_logs(text, sizeof(text));
if (previous[0] == '\0' || strcmp(previous, text) != 0) {
newtListboxClear(list_box);
item_count = populate_log_listbox(list_box, text);
if (follow || selected == 0 || selected > item_count)
selected = item_count;
newtListboxSetCurrentByKey(list_box, (void *)(uintptr_t)selected);
xcat_copy_printable(previous, sizeof(previous), text);
}
struct log_view current = {0};
const struct log_record *record = selected_log_record(&previous, selected);
unsigned int offset = record != NULL ? selected - record->first_row : 0;
load_logs(&current, !follow && record != NULL ? record->cursor : NULL);
newtListboxClear(list_box);
item_count = populate_log_listbox(list_box, &current);
selected = follow ? item_count : retained_log_row(&current, offset);
newtListboxSetCurrentByKey(list_box, (void *)(uintptr_t)selected);
free_log_view(&previous);
previous = current;
newtRefresh();
newtFormRun(form, &event);
if (event.reason == NEWT_EXIT_ERROR ||
@@ -452,6 +483,7 @@ static void show_logs(void) {
newtListboxSetCurrentByKey(list_box, (void *)(uintptr_t)selected);
}
free_log_view(&previous);
newtFormDestroy(form);
newtPopHelpLine();
newtPopWindow();
+6 -3
View File
@@ -278,14 +278,17 @@ dependencies plus the `pyte` Python package, bubblewrap,
systemd-journal-remote and journalctl. It runs the linked console in a
pseudo-terminal and checks help and diagnostics navigation, scrolling,
return to the main screen, and F3 journal filtering, order and history limits.
Rollover cases check paused selection, expired entries, duplicate messages,
wrapped lines and returning to follow mode.
It checks termination from the main screen, not from inside a dialog.
The journal cases need working Linux user and mount namespaces. On Ubuntu
24.04, the CI job enables them with
`sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`.
They import private journal files and verify their contents with journalctl
before opening F3. They do not start a journal service or read the host
journal. Missing tools or namespace support fail the tests; they do not skip.
They import private journal files, including appends while F3 is open, and
verify each import with journalctl. They do not start a journal service or
read the host journal. Missing tools or namespace support fail the tests;
they do not skip.
The Genesis runtime BATS cases need Bash, Perl, OpenSSL, coreutils, diffutils
and bubblewrap. The credential-file cases use a private `/etc` in a Linux
+129 -10
View File
@@ -38,7 +38,9 @@ class ConsoleTerminal(unittest.TestCase):
self.addCleanup(temporary.cleanup)
root = Path(temporary.name)
self.root = root
environment = {"PATH": os.environ["PATH"], "LC_ALL": "C.UTF-8", "TERM": "xterm"}
environment = {"PATH": os.environ["PATH"], "LC_ALL": "C.UTF-8", "TERM": "xterm",
"NEWT_COLORS": "listbox=black,white\nactlistbox=black,white\n"
"sellistbox=white,red\nactsellistbox=white,red"}
for name in ("CMDLINE_FILE", "UPTIME_FILE", "OS_RELEASE", "STATUS_DIR", "STATE_FILE",
"DESTINY_FILE", "RESPONSE_FILE", "SYS_ROOT", "PROC_ROOT", "EXTENSION_DIR", "PROVIDER_DIR"):
environment["XCAT_" + name] = str(root / name.lower())
@@ -75,13 +77,16 @@ class ConsoleTerminal(unittest.TestCase):
return "\n".join(self.screen.display)
def wait_text(self, text):
return self.wait_screen(lambda display: text in display, repr(text))
def wait_screen(self, predicate, description):
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
display = self.read_screen(0.1)
if text in display:
if predicate(display):
return display
self.assertIsNone(self.process.poll(), display)
self.fail(f"Terminal did not show {text!r}:\n{display}")
self.fail(f"Terminal did not show {description}:\n{display}")
def key(self, sequence):
os.write(self.master, sequence)
@@ -117,7 +122,8 @@ class ConsoleJournal(ConsoleTerminal):
"""Read real journal files in a private machine and journal namespace.
Requires bubblewrap, systemd-journal-remote, journalctl and user namespaces.
The host filesystem is read-only; /run and /var/log are private mounts.
The host filesystem is read-only except for the fixture directory.
/run and /var/log are private tmpfs mounts with the journal bound read-only.
No journal service is started and no host journal is read or changed.
"""
@@ -128,11 +134,13 @@ class ConsoleJournal(ConsoleTerminal):
journal_directory = journal_root / self.machine_id
journal_directory.mkdir(parents=True)
self.journal = journal_directory / "system.journal"
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",
"--bind", str(self.root), str(self.root),
"--ro-bind", str(machine_id_file), "/etc/machine-id",
"--ro-bind", str(journal_root), "/var/log/journal",
]
@@ -140,34 +148,145 @@ class ConsoleJournal(ConsoleTerminal):
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)]
def write_journal(self, entries):
def write_journal(self, entries, interval=1000000):
records = []
for sequence, (message, unit, identifier) in enumerate(entries, 1):
for sequence, (message, unit, identifier) in enumerate(entries, len(self.entries) + 1):
records.append(
f"__REALTIME_TIMESTAMP={1700000000000000 + sequence * 1000000}\n"
f"__MONOTONIC_TIMESTAMP={sequence * 1000000}\n"
f"__REALTIME_TIMESTAMP={1700000000000000 + sequence * interval}\n"
f"__MONOTONIC_TIMESTAMP={sequence * interval}\n"
f"_MACHINE_ID={self.machine_id}\n_BOOT_ID={self.boot_id}\n"
f"_SYSTEMD_UNIT={unit}\nSYSLOG_IDENTIFIER={identifier}\n"
f"MESSAGE={message}\n\n")
result = subprocess.run(
["/usr/lib/systemd/systemd-journal-remote", "--split-mode=none",
self.sandbox + ["/usr/lib/systemd/systemd-journal-remote", "--split-mode=none",
"--output=" + str(self.journal), "-"],
input="".join(records), text=True, capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
self.entries.extend(entries)
exported = subprocess.check_output(
["journalctl", "--file=" + str(self.journal), "--output=json", "--no-pager"],
text=True, timeout=10)
actual = [json.loads(line) for line in exported.splitlines()]
self.assertEqual([(entry["MESSAGE"], entry["_SYSTEMD_UNIT"], entry["SYSLOG_IDENTIFIER"])
for entry in actual], entries)
for entry in actual], self.entries)
def open_logs(self):
self.key(b"\x1bOR")
self.wait_text("Genesis logs")
self.wait_text("F3 Back")
def selected_text(self):
return "\n".join("".join(self.screen.buffer[row][column].data
for column in range(self.screen.columns)
if self.screen.buffer[row][column].bg == "red").strip()
for row in range(self.screen.lines)).strip()
def wait_selected(self, text):
return self.wait_screen(lambda display: self.selected_text().endswith(text),
f"selected log entry {text!r}")
def assert_selection_stays(self, text):
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
self.read_screen(0.1)
self.assertEqual(self.selected_text(), text)
self.assertIsNone(self.process.poll())
def test_paused_entry_survives_history_rollover(self):
self.write_journal([(f"RECORD-{number:04d}", "other.service", "xcat-genesis-register")
for number in range(1, 130)])
self.open_logs()
self.wait_selected("RECORD-0129")
self.key(b"\x1b[H")
self.wait_selected("RECORD-0002")
self.key(b"\x1b[B" * 60)
self.wait_selected("RECORD-0062")
selected = self.selected_text()
for number in (130, 131):
self.write_journal([(f"RECORD-{number:04d}", "other.service", "xcat-genesis-register")])
self.assert_selection_stays(selected)
self.key(b"\x1b[F")
self.wait_selected("RECORD-0131")
self.write_journal([("FOLLOWED", "other.service", "xcat-genesis-register")])
self.wait_selected("FOLLOWED")
def test_expired_selection_stays_paused_at_oldest_entry(self):
self.write_journal([(f"RECORD-{number:04d}", "other.service", "xcat-genesis-register")
for number in range(1, 130)])
self.open_logs()
self.wait_selected("RECORD-0129")
self.key(b"\x1b[H")
self.wait_selected("RECORD-0002")
for first, last, oldest in ((130, 140, 12), (140, 270, 142)):
self.write_journal([(f"RECORD-{number:04d}", "other.service", "xcat-genesis-register")
for number in range(first, last)])
self.wait_selected(f"RECORD-{oldest:04d}")
self.assert_selection_stays(self.selected_text())
if first == 130:
self.key(b"\x1b[B" * 50)
self.wait_selected("RECORD-0062")
self.write_journal([("STILL-PAUSED", "other.service", "xcat-genesis-register")])
self.wait_selected("RECORD-0143")
self.key(b"\x1b[F")
self.wait_selected("STILL-PAUSED")
def test_duplicate_text_keeps_the_selected_record(self):
self.write_journal([("DUPLICATE" if number in (60, 62, 63) else f"RECORD-{number:04d}",
"other.service", "xcat-genesis-register")
for number in range(1, 130)], interval=1)
self.open_logs()
self.wait_selected("RECORD-0129")
self.key(b"\x1b[H")
self.wait_selected("RECORD-0002")
self.key(b"\x1b[B" * 58)
self.wait_selected("DUPLICATE")
duplicate = self.selected_text()
self.key(b"\x1b[B")
self.wait_selected("RECORD-0061")
self.key(b"\x1b[B")
self.wait_selected("DUPLICATE")
self.assertEqual(self.selected_text(), duplicate)
self.write_journal([("APPENDED", "other.service", "xcat-genesis-register")], interval=1)
self.assert_selection_stays(duplicate)
self.key(b"\x1b[A")
self.wait_selected("RECORD-0061")
self.key(b"\x1b[H")
self.wait_selected("RECORD-0003")
def test_wrapped_selection_survives_history_rollover(self):
self.write_journal([
(" ".join(f"OLD-{number:02d}" for number in range(20)),
"other.service", "xcat-genesis-register"),
(" ".join(f"CONTEXT-{number:02d}" for number in range(16)) + " SELECTED-TAIL",
"other.service", "xcat-genesis-register"),
] + [(f"RECORD-{number:04d}", "other.service", "xcat-genesis-register")
for number in range(3, 129)])
self.open_logs()
self.wait_selected("RECORD-0128")
self.key(b"\x1b[H")
self.wait_screen(lambda display: "OLD-00" in self.selected_text(), "oldest wrapped entry")
for _ in range(12):
if self.selected_text().endswith("CONTEXT-08"):
break
previous = self.selected_text()
self.key(b"\x1b[B")
self.wait_screen(lambda display: self.selected_text() != previous, "next wrapped row")
self.assertTrue(self.selected_text().endswith("CONTEXT-08"), self.selected_text())
selected = self.selected_text()
self.assertIn("OLD-00", self.read_screen(0))
self.write_journal([("APPENDED", "other.service", "xcat-genesis-register")])
self.wait_screen(lambda display: "OLD-00" not in display, "expired wrapped entry removed")
self.assert_selection_stays(selected)
self.key(b"\x1b[B")
self.wait_selected("CONTEXT-14")
self.key(b"\x1b[B")
self.wait_selected("SELECTED-TAIL")
self.key(b"\x1b[B")
self.wait_selected("RECORD-0003")
def test_empty_journal(self):
self.open_logs()
self.wait_text("No Genesis journal entries are available.")