#!/usr/bin/env perl use strict; use warnings; use File::Path qw(make_path); use File::Spec; use File::Temp qw(tempdir); use FindBin; use Test::More; my $repo_root = File::Spec->catdir( $FindBin::Bin, '..', '..' ); my $source_dir = File::Spec->catdir( $repo_root, qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-console files xcat-genesis-console src) ); my @plain_sources = map { File::Spec->catfile( $source_dir, $_ ) } qw(main.c plain_ui.c shell.c state.c support.c); my $root = tempdir( CLEANUP => 1 ); my $binary = File::Spec->catfile( $root, 'xcat-genesis-console' ); my $header_test_source = File::Spec->catfile( $root, 'header-test.c' ); my $header_test_binary = File::Spec->catfile( $root, 'header-test' ); my $compiler = $ENV{CC} || 'cc'; is( system( $compiler, '-D_POSIX_C_SOURCE=200809L', '-DXCAT_CONSOLE_PLAIN_ONLY', '-std=c17', '-Wall', '-Wextra', '-Wpedantic', '-Werror', @plain_sources, '-o', $binary ) >> 8, 0, 'plain console builds with strict warnings' ) or BAIL_OUT('unable to build the console test binary'); sub write_file { my ( $path, $contents ) = @_; open( my $stream, '>', $path ) or die "Unable to write $path: $!"; print {$stream} $contents; close($stream); } sub read_file { my ($path) = @_; open( my $stream, '<', $path ) or die "Unable to read $path: $!"; my $contents = do { local $/; <$stream> }; close($stream); return $contents; } write_file( $header_test_source, <<'C' #include "console.h" #include int main(void) { assert(xcat_header_context_columns(0) == 0); assert(xcat_header_context_columns(19) == 0); assert(xcat_header_context_columns(20) == 0); assert(xcat_header_context_columns(21) == 1); assert(xcat_header_context_columns(80) == 60); return 0; } C ); is( system( $compiler, '-D_POSIX_C_SOURCE=200809L', '-std=c17', '-Wall', '-Wextra', '-Wpedantic', '-Werror', '-I', $source_dir, $header_test_source, File::Spec->catfile( $source_dir, 'support.c' ), '-o', $header_test_binary ) >> 8, 0, 'header bounds test builds with strict warnings' ); is( system($header_test_binary) >> 8, 0, 'narrow terminals leave no writable header context' ); my $cmdline = File::Spec->catfile( $root, 'cmdline' ); my $uptime = File::Spec->catfile( $root, 'uptime' ); my $os_release_real = File::Spec->catfile( $root, 'os-release.real' ); my $os_release = File::Spec->catfile( $root, 'os-release' ); my $state_dir = File::Spec->catdir( $root, 'status' ); my $genesis_env = File::Spec->catfile( $root, 'genesis.env' ); my $destiny = File::Spec->catfile( $root, 'destiny' ); my $response = File::Spec->catfile( $root, 'xcat-response.env' ); my $sys_root = File::Spec->catdir( $root, 'sys' ); my $proc_root = File::Spec->catdir( $root, 'proc' ); my $net_root = File::Spec->catdir( $sys_root, 'class', 'net', 'eth0' ); my $dmi_root = File::Spec->catdir( $sys_root, 'class', 'dmi', 'id' ); my $extensions = File::Spec->catdir( $root, 'extensions' ); my $providers = File::Spec->catdir( $root, 'providers' ); make_path( $state_dir, $net_root, $dmi_root, $proc_root, $extensions, $providers, File::Spec->catdir( $sys_root, 'firmware', 'efi' ) ); write_file( $cmdline, "xcatd=192.0.2.10:3001 BOOTIF=01-52-54-00-00-00-01 gateway=192.0.2.1\n" ); write_file( $uptime, "125.90 200.00\n" ); write_file( $os_release_real, "NAME=\"xCAT Genesis\"\nVERSION_ID=\"0.1\"\n" ); symlink( $os_release_real, $os_release ) or die "Unable to create os-release link: $!"; write_file( File::Spec->catfile( $state_dir, 'network.env' ), "SCHEMA=1\nSTATE=READY\nDETAIL=Management network ready on eth0\n" . "STARTED_SECONDS=100\nUPDATED_SECONDS=100\nVERIFIED_SECONDS=100\n" ); write_file( File::Spec->catfile( $state_dir, 'registration.env' ), "SCHEMA=1\nSTATE=ACTION_RECEIVED\nDETAIL=Action osimage received\n" . "STARTED_SECONDS=110\nUPDATED_SECONDS=120\nVERIFIED_SECONDS=120\n" . "NODE_NAME=compute01\nACTION=osimage\nTARGET=rocky9\n" ); my $extension_ready = "SCHEMA=1\nSTATE=READY\nDETAIL=Genesis extensions loaded\n" . "STARTED_SECONDS=105\nUPDATED_SECONDS=106\n"; write_file( File::Spec->catfile( $state_dir, 'extensions.env' ), $extension_ready ); my $ipv4_network_state = "XCATDEST=192.0.2.10:3001\nXCAT_INTERFACE=eth0\n" . "XCAT_SOURCE_ADDRESS=192.0.2.20\n" . "XCAT_SOURCE_PREFIXED_ADDRESS=192.0.2.20/24\n" . "XCAT_GATEWAY=192.0.2.1\nXCAT_DNS_SERVERS=192.0.2.53\n" . "XCAT_NETWORK_METHOD=auto\nXCAT_LINK_STATE=up\n" . "XCAT_MAC_ADDRESS=52:54:00:00:00:01\n"; write_file( $genesis_env, $ipv4_network_state ); write_file( $destiny, "osimage=rocky9\n" ); write_file( $response, "XCAT_NODE_NAME=compute01\n" ); write_file( File::Spec->catfile( $net_root, 'operstate' ), "up\n" ); write_file( File::Spec->catfile( $net_root, 'address' ), "52:54:00:00:00:01\n" ); write_file( File::Spec->catfile( $dmi_root, 'product_serial' ), "TEST-SERIAL-001\n" ); write_file( File::Spec->catfile( $dmi_root, 'product_uuid' ), "11111111-2222-3333-4444-555555555555\n" ); write_file( File::Spec->catfile( $extensions, 'one.raw' ), '' ); write_file( File::Spec->catfile( $extensions, 'two.raw' ), '' ); symlink( File::Spec->catfile( $extensions, 'one.raw' ), File::Spec->catfile( $extensions, 'linked.raw' ) ) or die "Unable to create extension link: $!"; write_file( File::Spec->catfile( $providers, 'one.json' ), "{}\n" ); write_file( File::Spec->catfile( $providers, 'two.json' ), "{}\n" ); symlink( File::Spec->catfile( $providers, 'one.json' ), File::Spec->catfile( $providers, 'linked.json' ) ) or die "Unable to create provider link: $!"; my %environment = ( XCAT_CMDLINE_FILE => $cmdline, XCAT_UPTIME_FILE => $uptime, XCAT_OS_RELEASE => $os_release, XCAT_STATUS_DIR => $state_dir, XCAT_STATE_FILE => $genesis_env, XCAT_DESTINY_FILE => $destiny, XCAT_RESPONSE_FILE => $response, XCAT_SYS_ROOT => $sys_root, XCAT_PROC_ROOT => $proc_root, XCAT_EXTENSION_DIR => $extensions, XCAT_PROVIDER_DIR => $providers, ); sub run_console { local %ENV = ( %ENV, %environment ); open( my $stream, '-|', $binary, '--once' ) or die "Unable to run $binary: $!"; my $output = do { local $/; <$stream> }; close($stream); return ( $? >> 8, $output ); } my ( $status, $output ) = run_console(); is( $status, 0, 'plain console renders a status snapshot' ); like( $output, qr/^xCAT Genesis \| ACTION_RECEIVED \| in stage 00:00:15$/m, 'header shows the actual state and stage duration' ); like( $output, qr/^node: compute01\nserial: TEST-SERIAL-001$/m, 'node and hardware serial are separate fields' ); like( $output, qr/^interface: eth0\nlink: up\nmethod: DHCP\naddress: 192\.0\.2\.20\/24\nMAC: 52:54:00:00:00:01$/m, 'network fields use explicit labels and describe automatic setup as DHCP' ); like( $output, qr/^xCAT server: 192\.0\.2\.10:3001\nxCAT contact: Action received$/m, 'xCAT endpoint and contact result are separate fields' ); like( $output, qr/^action: Boot assigned image\ntarget: rocky9\nprogress: none$/m, 'action and target remain distinct' ); unlike( $output, qr/last contact/i, 'main status omits the redundant contact timer' ); unlike( $output, qr/extensions:|providers:|Linux /, 'inventory details stay off the main page' ); unlike( $output, qr/\e/, 'plain output has no terminal escapes' ); my $cmdline_without_xcat = $ipv4_network_state; $cmdline_without_xcat =~ s/^XCATDEST=.*\n//m; write_file( $genesis_env, $cmdline_without_xcat ); write_file( $cmdline, ( 'quiet ' x 100 ) . "xcatd=198.51.100.10:3001 BOOTIF=01-52-54-00-00-00-01\n" ); ( $status, $output ) = run_console(); like( $output, qr/^xCAT server: 198\.51\.100\.10:3001$/m, 'console reads xCAT parameters after byte 512' ); write_file( $genesis_env, $ipv4_network_state ); write_file( $cmdline, "xcatd=192.0.2.10:3001 BOOTIF=01-52-54-00-00-00-01 gateway=192.0.2.1\n" ); write_file( File::Spec->catfile( $state_dir, 'extensions.env' ), "SCHEMA=1\nSTATE=FAILED\nDETAIL=extension signature verification failed\n" . "STARTED_SECONDS=123\nUPDATED_SECONDS=124\n" . "CODE=EXTENSION_VERIFICATION_FAILED\n" . "RECOVERY=Check extension images, manifests, signatures, and trusted keys\n" ); ( $status, $output ) = run_console(); is( $status, 0, 'plain console renders an extension failure' ); like( $output, qr/^error: EXTENSION_VERIFICATION_FAILED: extension signature verification failed$/m, 'extension verification failures stop the main status' ); like( $output, qr/^recovery: Check extension images, manifests, signatures, and trusted keys$/m, 'extension failures include a recovery hint' ); write_file( File::Spec->catfile( $state_dir, 'extensions.env' ), $extension_ready ); write_file( $genesis_env, "XCATDEST=[2001:db8::10]:3001\nXCAT_INTERFACE=eth0\n" . "XCAT_SOURCE_ADDRESS=2001:db8::20\n" . "XCAT_SOURCE_PREFIXED_ADDRESS=2001:db8::20/64\n" . "XCAT_GATEWAY=2001:db8::1\nXCAT_DNS_SERVERS=2001:db8::53\n" . "XCAT_NETWORK_METHOD=auto\nXCAT_LINK_STATE=up\n" . "XCAT_MAC_ADDRESS=52:54:00:00:00:01\n" ); ( $status, $output ) = run_console(); is( $status, 0, 'plain console renders an automatic IPv6 network' ); like( $output, qr/^method: SLAAC\/DHCPv6\naddress: 2001:db8::20\/64$/m, 'automatic IPv6 setup has an accurate method label' ); my $static_network_state = $ipv4_network_state; $static_network_state =~ s/XCAT_NETWORK_METHOD=auto/XCAT_NETWORK_METHOD=manual/; write_file( $genesis_env, $static_network_state ); ( $status, $output ) = run_console(); is( $status, 0, 'plain console renders a static network' ); like( $output, qr/^method: Static$/m, 'manual network setup is labeled Static' ); write_file( $genesis_env, $ipv4_network_state ); write_file( File::Spec->catfile( $state_dir, 'action.env' ), "SCHEMA=1\nSTATE=RUNNING\nDETAIL=Rebooting into the assigned image\n" . "STARTED_SECONDS=122\nUPDATED_SECONDS=124\nVERIFIED_SECONDS=124\n" . "ACTION=install\nTARGET=rocky9 install image\n" ); ( $status, $output ) = run_console(); is( $status, 0, 'plain console renders action execution' ); like( $output, qr/^xCAT Genesis \| RUNNING \| in stage 00:00:03$/m, 'action execution becomes the overall state' ); like( $output, qr/^action: Install assigned image\ntarget: rocky9 install image\nprogress: none$/m, 'action status overrides the registration snapshot' ); write_file( File::Spec->catfile( $state_dir, 'action.env' ), "SCHEMA=1\nSTATE=FAILED\nDETAIL=Unsigned runimage actions are not supported\n" . "STARTED_SECONDS=124\nUPDATED_SECONDS=124\n" . "CODE=UNSAFE_LEGACY_ACTION\n" . "RECOVERY=Package the operation as a signed Genesis system extension\n" . "ACTION=runimage\nTARGET=legacy.tgz\n" ); ( $status, $output ) = run_console(); is( $status, 0, 'plain console renders an action failure' ); like( $output, qr/^error: UNSAFE_LEGACY_ACTION: Unsigned runimage actions are not supported$/m, 'action failures remain visible on the main page' ); unlike( $output, qr/^(?:target|progress):/m, 'failure output uses the same detail rows as the status screen' ); unlink( File::Spec->catfile( $state_dir, 'action.env' ) ); write_file( File::Spec->catfile( $state_dir, 'registration.env' ), "SCHEMA=1\nSTATE=FAILED\nDETAIL=No valid response from xCAT\n" . "STARTED_SECONDS=120\nUPDATED_SECONDS=124\n" . "CODE=XCAT_RESPONSE_UNAVAILABLE\n" . "RECOVERY=Check xcatd and the management network\n" ); ( $status, $output ) = run_console(); is( $status, 0, 'plain console renders a failed state' ); like( $output, qr/^xCAT Genesis \| FAILED \| in stage 00:00:05$/m, 'failed component becomes the overall state' ); like( $output, qr/^error: XCAT_RESPONSE_UNAVAILABLE: No valid response from xCAT$/m, 'failed component supplies an exact error' ); like( $output, qr/^xCAT contact: Failed: No valid response from xCAT$/m, 'xCAT failure appears on the contact line' ); like( $output, qr/^recovery: Check xcatd and the management network$/m, 'failed component supplies a recovery hint' ); write_file( File::Spec->catfile( $state_dir, 'registration.env' ), "SCHEMA=1\nSTATE=CONTACTING_XCAT\nDETAIL=xCAT has not answered yet\n" . "STARTED_SECONDS=120\nUPDATED_SECONDS=124\n" . "ATTEMPT=2\nATTEMPT_LIMIT=6\nNEXT_RETRY_SECONDS=5\n" ); ( $status, $output ) = run_console(); is( $status, 0, 'plain console renders retry progress' ); like( $output, qr/^action: Boot assigned image\ntarget: rocky9\nprogress: Attempt 2 of 6; retry in 4s$/m, 'retry countdown uses structured status fields' ); write_file( $response, "XCAT_NODE_NAME=\n" ); ( $status, $output ) = run_console(); is( $status, 0, 'plain console renders an empty node response' ); like( $output, qr/^node: unassigned$/m, 'an empty node response is shown as unassigned' ); my $state_source = read_file( File::Spec->catfile( $source_dir, 'state.c' ) ); my $plain_source = read_file( File::Spec->catfile( $source_dir, 'plain_ui.c' ) ); my $newt_source = read_file( File::Spec->catfile( $source_dir, 'newt_ui.c' ) ); my $shell_launcher = read_file( File::Spec->catfile( $source_dir, 'shell.c' ) ); my $header = read_file( File::Spec->catfile( $source_dir, 'console.h' ) ); my $console_source = join "\n", $state_source, $plain_source, $newt_source, $shell_launcher, $header; my ($view_builder) = $state_source =~ /(void xcat_build_status_view.*?)(?=\nbool xcat_status_view_changed)/s; ok( defined($view_builder), 'console builds one shared main status view' ); foreach my $field (qw/STATE STAGE_TIME ACTIVITY NODE SERIAL INTERFACE LINK METHOD ADDRESS MAC XCAT_SERVER XCAT_STATUS ACTION DETAIL_ONE DETAIL_TWO/) { like( $view_builder, qr/STATUS_FIELD_\Q$field\E/, "shared view defines the $field field" ); } my ($plain_renderer) = $plain_source =~ /(static void print_plain\(.*?)(?=\nstatic bool read_plain_command)/s; ok( defined($plain_renderer), 'console defines the plain renderer' ); like( $plain_renderer, qr/const struct status_view \*view.*?STATUS_FIELD_COUNT/s, 'plain output consumes the shared view' ); unlike( $plain_renderer, qr/struct console_state|state->/, 'plain output cannot select fields from raw state' ); my ($newt_renderer) = $newt_source =~ /(static void update_form\(.*?)(?=\nstatic const char help_text)/s; ok( defined($newt_renderer), 'console defines the Newt renderer' ); like( $newt_renderer, qr/const struct status_view \*view.*?STATUS_FIELD_COUNT/s, 'Newt output consumes the shared view' ); unlike( $newt_renderer, qr/struct console_state|state->/, 'Newt output cannot select fields from raw state' ); my ($view_change_detection) = $state_source =~ /(bool xcat_status_view_changed.*?)(?=\nvoid xcat_format_diagnostics)/s; ok( defined($view_change_detection), 'console compares shared status views' ); like( $view_change_detection, qr/STATUS_FIELD_STATE.*?STATUS_FIELD_COUNT.*?fields\[field\]\.value/s, 'every shared field participates in change detection' ); like( $view_change_detection, qr/field == STATUS_FIELD_STAGE_TIME/, 'the one-second stage timer remains the only cadence exception' ); my ($help_source) = $newt_source =~ /(static const char help_text\[\].*?)(?=\nstatic void show_help)/s; ok( defined($help_source), 'console defines its interface help text' ); like( $help_source, qr/Screen fields/, 'help explains the visible fields' ); like( $help_source, qr/xCAT contact.*Not configured.*Action received.*Failed/s, 'help explains each xCAT contact result' ); like( $help_source, qr/Method is DHCP, Static, or.*SLAAC\/DHCPv6/s, 'help explains the network-method labels' ); like( $help_source, qr/F3.*follows new entries.*End to resume/s, 'help explains log following' ); like( $help_source, qr/F12.*root maintenance shell/s, 'help explains the maintenance shell' ); foreach my $state (qw/STARTING IDLE WAITING_FOR_LINK CONFIGURING_NETWORK CONTACTING_XCAT ACTION_RECEIVED RUNNING READY DEGRADED FAILED/) { like( $help_source, qr/\b\Q$state\E\b/, "help explains the $state state" ); } unlike( $help_source, qr/xcat\.(?:console|debug-shell)|updates automatically/, 'interface help omits boot overrides and redundant refresh advice' ); my ($diagnostics_source) = $state_source =~ /(void xcat_format_diagnostics.*)\z/s; ok( defined($diagnostics_source), 'console defines the diagnostics view' ); like( $diagnostics_source, qr/Identity\\n\\n.*System\\n\\n.*Management network\\n\\n.*xCAT\\n\\n.*Action\\n\\n.*Runtime\\n\\n/s, 'diagnostics separates each section with an empty line' ); unlike( $diagnostics_source, qr/Last contact|Uptime|network_age|registration_age|action_age/, 'diagnostics omits component timers' ); my ($diagnostics_ui) = $newt_source =~ /(static void show_diagnostics.*?)(?=\nstatic bool journal_value)/s; ok( defined($diagnostics_ui), 'console defines the diagnostics window' ); unlike( $diagnostics_ui, qr/newtFormSetTimer/, 'diagnostics does not refresh while the operator scrolls' ); like( $diagnostics_ui, qr/newtTextboxSetText\(text_box, text\);\s+newtRefresh\(\);\s+while/s, 'diagnostics loads one stable snapshot before handling input' ); my ($header_source) = $newt_source =~ /(static void draw_header.*?)(?=\nstatic void update_form)/s; ok( defined($header_source), 'console defines the status header' ); like( $console_source, qr/state->local_hostname/, 'header uses the local hostname before node assignment' ); like( $console_source, qr/state->serial/, 'header shows the firmware serial when available' ); unlike( $header_source, qr/state->architecture/, 'header leaves architecture in diagnostics' ); like( $header_source, qr/xcat_header_context_columns\(columns\)/, 'header clamps its context to the available terminal width' ); my ($logs_source) = $newt_source =~ /(static void show_logs.*?)(?=\nstatic void show_maintenance_shell)/s; ok( defined($logs_source), 'console defines the log view' ); like( $logs_source, qr/newtListbox.*NEWT_FLAG_SCROLL/s, 'log view provides a scrollable list' ); like( $logs_source, qr/bool follow = true/, 'log view starts in follow mode' ); like( $logs_source, qr/NEWT_KEY_UP.*?NEWT_KEY_PGUP.*?NEWT_KEY_HOME.*?follow = false/s, 'upward navigation pauses log following' ); like( $logs_source, qr/NEWT_KEY_DOWN.*?NEWT_KEY_PGDN.*?follow = selected == item_count/s, 'downward navigation resumes following only at the tail' ); like( $logs_source, qr/NEWT_KEY_END.*?follow = true/s, 'End resumes log following' ); my ($shell_source) = $newt_source =~ /(static void show_maintenance_shell.*?)(?=\nint xcat_run_newt)/s; ok( defined($shell_source), 'console defines the maintenance-shell action' ); like( $shell_source, qr/newtWinChoice.*Open.*Cancel/s, 'maintenance shell requires confirmation' ); like( $shell_source, qr/xcat_run_maintenance_shell\(\)/, 'Newt uses the common maintenance-shell launcher' ); like( $shell_source, qr/newtResume\(\).*newtResizeScreen\(1\)/s, 'Newt restores and repaints its saved screen after the shell' ); unlike( $shell_source, qr/newtCls\(\)/, 'maintenance-shell return preserves the window frame' ); like( $shell_launcher, qr{execl\("/usr/libexec/xcat/genesis-maintenance-shell"}, 'maintenance shell uses the packaged executable' ); like( $shell_launcher, qr/FD_CLOEXEC.*exec_error_size/s, 'maintenance shell distinguishes launch failure from shell exit' ); unlike( $shell_launcher, qr/WEXITSTATUS/, 'maintenance shell accepts any later shell exit status' ); my ($plain_runner) = $plain_source =~ /(int xcat_run_plain.*)\z/s; ok( defined($plain_runner), 'console defines the plain-mode loop' ); like( $plain_runner, qr/Type shell and press Enter for maintenance/, 'plain mode advertises its maintenance command' ); like( $plain_runner, qr/read_plain_command.*?strcmp\(command, "shell"\).*?open_plain_maintenance_shell/s, 'plain mode accepts the shell command' ); my ($plain_shell) = $plain_source =~ /(static void open_plain_maintenance_shell.*?)(?=\nint xcat_run_plain)/s; ok( defined($plain_shell), 'console defines plain shell confirmation' ); like( $plain_shell, qr/Open a root maintenance shell\? \[y\/N\]/, 'plain mode confirms root shell access' ); like( $plain_shell, qr/xcat_run_maintenance_shell\(\)/, 'plain mode uses the common maintenance-shell launcher' ); done_testing();