diff --git a/xCAT-test/unit/genesis_openembedded_actions.t b/xCAT-test/unit/genesis_openembedded_actions.t new file mode 100644 index 000000000..0ca4463ce --- /dev/null +++ b/xCAT-test/unit/genesis_openembedded_actions.t @@ -0,0 +1,295 @@ +#!/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 $action_script = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-init files genesis-action) +); +my $status_script = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-init files genesis-status) +); +my $functions_file = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-init files genesis-functions) +); +my $action_service = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-init files xcat-genesis-action.service) +); +my $init_recipe = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-init xcat-genesis-init_1.0.bb) +); + +sub write_file { + my ( $path, $contents, $mode ) = @_; + open( my $fh, '>', $path ) or die "Unable to write $path: $!"; + print {$fh} $contents; + close($fh); + chmod( $mode, $path ) if defined($mode); +} + +sub read_file { + my ($path) = @_; + open( my $fh, '<', $path ) or die "Unable to read $path: $!"; + my $contents = do { local $/; <$fh> }; + close($fh); + return $contents; +} + +my $root = tempdir( CLEANUP => 1 ); +my $bin = File::Spec->catdir( $root, 'bin' ); +my $state_dir = File::Spec->catdir( $root, 'run' ); +my $status_dir = File::Spec->catdir( $state_dir, 'status' ); +my $approved_dir = File::Spec->catdir( $root, 'actions' ); +my $destiny_file = File::Spec->catfile( $state_dir, 'destiny' ); +my $metadata_file = File::Spec->catfile( $state_dir, 'xcat-response.env' ); +my $network_file = File::Spec->catfile( $state_dir, 'genesis.env' ); +my $certificate_file = File::Spec->catfile( $root, 'cert.pem' ); +my $uptime_file = File::Spec->catfile( $root, 'uptime' ); +my $command_log = File::Spec->catfile( $root, 'commands.log' ); +my $getdestiny_queue = File::Spec->catfile( $root, 'getdestiny.queue' ); +my $nextdestiny_queue = File::Spec->catfile( $root, 'nextdestiny.queue' ); + +make_path( $bin, $state_dir, $status_dir, $approved_dir ); +write_file( $network_file, <<'ENV' ); +XCATDEST=192.0.2.10:3001 +XCATMASTER=192.0.2.10 +XCATPORT=3001 +XCAT_INTERFACE=eth0 +XCAT_SOURCE_ADDRESS=192.0.2.98 +ENV +write_file( $uptime_file, "42.00 80.00\n" ); + +write_file( File::Spec->catfile( $bin, 'logger' ), <<'SH', 0755 ); +#!/bin/sh +printf 'logger %s\n' "$*" >>"$XCAT_TEST_LOG" +SH +write_file( File::Spec->catfile( $bin, 'getdestiny' ), <<'SH', 0755 ); +#!/bin/sh +printf 'getdestiny %s\n' "$*" >>"$XCAT_TEST_LOG" +metadata= +while [ "$#" -gt 0 ]; do + case "$1" in + --metadata) metadata=$2; shift 2 ;; + *) shift ;; + esac +done +[ -s "$XCAT_TEST_GETDESTINY_QUEUE" ] || exit 1 +response=$(head -n 1 "$XCAT_TEST_GETDESTINY_QUEUE") +tail -n +2 "$XCAT_TEST_GETDESTINY_QUEUE" >"$XCAT_TEST_GETDESTINY_QUEUE.new" +mv "$XCAT_TEST_GETDESTINY_QUEUE.new" "$XCAT_TEST_GETDESTINY_QUEUE" +if [ -n "$metadata" ]; then + printf '%s\n' 'XCAT_NODE_NAME=node042' "XCAT_DESTINY=$response" >"$metadata" +fi +printf '%s\n' "$response" +SH +write_file( File::Spec->catfile( $bin, 'nextdestiny' ), <<'SH', 0755 ); +#!/bin/sh +printf 'nextdestiny %s\n' "$*" >>"$XCAT_TEST_LOG" +[ -s "$XCAT_TEST_NEXTDESTINY_QUEUE" ] || exit 1 +response=$(head -n 1 "$XCAT_TEST_NEXTDESTINY_QUEUE") +tail -n +2 "$XCAT_TEST_NEXTDESTINY_QUEUE" >"$XCAT_TEST_NEXTDESTINY_QUEUE.new" +mv "$XCAT_TEST_NEXTDESTINY_QUEUE.new" "$XCAT_TEST_NEXTDESTINY_QUEUE" +printf '%s\n' "$response" +SH +write_file( File::Spec->catfile( $bin, 'discover' ), <<'SH', 0755 ); +#!/bin/sh +printf '%s\n' discover >>"$XCAT_TEST_LOG" +SH +write_file( File::Spec->catfile( $bin, 'getcert' ), <<'SH', 0755 ); +#!/bin/sh +printf '%s\n' getcert >>"$XCAT_TEST_LOG" +: >"$XCAT_TEST_CERTIFICATE_FILE" +SH +write_file( File::Spec->catfile( $bin, 'reboot-control' ), <<'SH', 0755 ); +#!/bin/sh +printf 'reboot-control %s\n' "$*" >>"$XCAT_TEST_LOG" +SH +write_file( File::Spec->catfile( $bin, 'poweroff-control' ), <<'SH', 0755 ); +#!/bin/sh +printf 'poweroff-control %s\n' "$*" >>"$XCAT_TEST_LOG" +SH +write_file( File::Spec->catfile( $bin, 'ipmitool' ), <<'SH', 0755 ); +#!/bin/sh +printf 'ipmitool %s\n' "$*" >>"$XCAT_TEST_LOG" +[ "${XCAT_TEST_IPMI-0}" = 1 ] +SH +write_file( File::Spec->catfile( $approved_dir, 'inventory' ), <<'SH', 0755 ); +#!/bin/sh +printf 'inventory' >>"$XCAT_TEST_LOG" +printf ' <%s>' "$@" >>"$XCAT_TEST_LOG" +printf '\n' >>"$XCAT_TEST_LOG" +SH + +my %base_environment = ( + PATH => "$bin:$ENV{PATH}", + XCAT_STATE_DIR => $state_dir, + XCAT_STATUS_COMMAND => $status_script, + XCAT_STATUS_DIR => $status_dir, + XCAT_DESTINY_FILE => $destiny_file, + XCAT_METADATA_FILE => $metadata_file, + XCAT_NETWORK_FILE => $network_file, + XCAT_DISCOVER_COMMAND => File::Spec->catfile( $bin, 'discover' ), + XCAT_CERTIFICATE_COMMAND => File::Spec->catfile( $bin, 'getcert' ), + XCAT_CERTIFICATE_FILE => $certificate_file, + XCAT_GETDESTINY_COMMAND => File::Spec->catfile( $bin, 'getdestiny' ), + XCAT_NEXTDESTINY_COMMAND => File::Spec->catfile( $bin, 'nextdestiny' ), + XCAT_ACTION_COMMAND_DIR => $approved_dir, + XCAT_REBOOT_COMMAND => File::Spec->catfile( $bin, 'reboot-control' ), + XCAT_POWEROFF_COMMAND => File::Spec->catfile( $bin, 'poweroff-control' ), + XCAT_ACTION_POLL_SECONDS => 0, + XCAT_ACTION_REQUEST_TIMEOUT => 2, + XCAT_ACTION_OPERATION_TIMEOUT => 2, + XCAT_ACTION_MAX_STEPS => 1, + XCAT_UPTIME_FILE => $uptime_file, + XCAT_GENESIS_FUNCTIONS => $functions_file, + XCAT_TEST_LOG => $command_log, + XCAT_TEST_GETDESTINY_QUEUE => $getdestiny_queue, + XCAT_TEST_NEXTDESTINY_QUEUE => $nextdestiny_queue, + XCAT_TEST_CERTIFICATE_FILE => $certificate_file, +); + +sub run_action { + my ( $action, %options ) = @_; + + unlink( $command_log, $certificate_file ); + unlink( File::Spec->catfile( $status_dir, 'action.env' ) ); + write_file( $destiny_file, "$action\n" ); + write_file( $getdestiny_queue, + $options{getdestiny} // "$action\n" ); + write_file( $nextdestiny_queue, + $options{nextdestiny} // "standby\n" ); + write_file( $certificate_file, "certificate\n" ) + unless $options{without_certificate}; + local %ENV = ( %ENV, %base_environment, + XCAT_ACTION_MAX_STEPS => ( $options{maximum_steps} // 1 ), + XCAT_TEST_IPMI => ( $options{ipmi} // 0 ) ); + my $status = system( '/bin/bash', $action_script ) >> 8; + my $log = -r $command_log ? read_file($command_log) : ''; + my $action_status = File::Spec->catfile( $status_dir, 'action.env' ); + my $record = -r $action_status ? read_file($action_status) : ''; + return ( $status, $log, $record ); +} + +my ( $status, $log, $record ) = run_action( + 'discover', getdestiny => "standby\n" ); +is( $status, 0, 'discovery action completes' ); +like( $log, qr/^discover$/m, 'discovery action sends inventory' ); +like( $log, qr/^getcert$/m, 'discovery action enrolls a certificate' ); +ok( index( $log, "discover\n" ) < index( $log, 'getdestiny ' ), + 'registered discovery runs before the next xCAT query' ); +like( read_file($destiny_file), qr/^standby$/, + 'discovery loads the assigned node action' ); + +( $status, $log, $record ) = run_action( + 'shell', getdestiny => "install test-image\n" ); +is( $status, 0, 'shell action becomes a managed wait state' ); +unlike( $log, qr{(?:^|/)bash(?:\s|$)}, 'shell action does not open a local shell' ); +like( read_file($destiny_file), qr/^install test-image$/, + 'wait state accepts a remote action change' ); + +( $status, $log, $record ) = run_action( + 'standby', getdestiny => "standby\nstandby\n", + without_certificate => 1, maximum_steps => 2 ); +is( $status, 0, 'standby action polls xCAT' ); +like( $log, qr/^getcert$/m, 'known nodes obtain a missing certificate' ); +like( $record, qr/^STATE=IDLE$/m, 'standby publishes an idle state' ); +like( $record, qr/^VERIFIED_SECONDS=42$/m, + 'standby records its latest successful xCAT contact' ); + +for my $action (qw(osimage ondiscover)) { + ( $status, $log, $record ) = run_action($action); + is( $status, 0, "$action action completes" ); + like( $log, qr/^nextdestiny /m, "$action advances the action chain" ); +} + +( $status, $log, $record ) = run_action( + 'runcmd=inventory storage safe;reboot' ); +is( $status, 0, 'approved command completes' ); +like( $log, qr/^inventory $/m, + 'approved command arguments are not evaluated by a shell' ); +like( $log, qr/^nextdestiny /m, 'approved command advances the chain' ); + +( $status, $log, $record ) = run_action('runcmd=unpackaged'); +isnt( $status, 0, 'unpackaged command is rejected' ); +like( $record, qr/^CODE=ACTION_COMMAND_NOT_APPROVED$/m, + 'unpackaged command has a specific failure code' ); + +for my $case ( + [ runimage => 'UNSAFE_LEGACY_ACTION' ], + [ configraid => 'LEGACY_STORAGE_ACTION' ], + [ sysclone => 'LEGACY_SYSCLONE_ACTION' ], +) { + ( $status, $log, $record ) = run_action( $case->[0] ); + isnt( $status, 0, "$case->[0] fails closed" ); + like( $record, qr/^CODE=\Q$case->[1]\E$/m, + "$case->[0] reports its migration status" ); +} + +for my $action (qw(boot reboot)) { + ( $status, $log, $record ) = run_action( $action, ipmi => 1 ); + is( $status, 0, "$action action completes" ); + like( $log, qr/^nextdestiny /m, "$action advances the chain" ); + like( $log, qr/^ipmitool chassis bootdev pxe$/m, + "$action requests a one-time network boot" ); + like( $log, qr/^reboot-control reboot$/m, "$action requests a reboot" ); +} + +for my $action (qw(install netboot statelite)) { + ( $status, $log, $record ) = run_action($action); + is( $status, 0, "$action action completes" ); + unlike( $log, qr/^nextdestiny /m, + "$action preserves the server-selected chain position" ); + like( $log, qr/^reboot-control reboot$/m, "$action requests a reboot" ); +} + +( $status, $log, $record ) = run_action('shutdown'); +is( $status, 0, 'shutdown action completes' ); +like( $log, qr/^poweroff-control poweroff$/m, + 'shutdown action requests a poweroff' ); + +( $status, $log, $record ) = run_action('error=policy denied'); +isnt( $status, 0, 'xCAT error action fails' ); +like( $record, qr/^CODE=XCAT_ACTION_ERROR$/m, + 'xCAT error remains visible' ); + +( $status, $log, $record ) = run_action('unknown'); +isnt( $status, 0, 'unknown action fails' ); +like( $record, qr/^CODE=XCAT_ACTION_UNSUPPORTED$/m, + 'unknown action has a specific failure code' ); + +( $status, $log, $record ) = run_action( + 'standby', getdestiny => '', without_certificate => 0 ); +is( $status, 0, 'a failed refresh uses the previously received action' ); +like( $record, qr/^STATE=DEGRADED$/m, + 'a failed action poll remains visible' ); + +my $service = read_file($action_service); +like( $service, qr/^Requires=xcat-genesis-register\.service$/m, + 'action execution requires registration' ); +like( $service, qr/^Restart=on-failure$/m, + 'failed actions retry after operator correction' ); +like( $service, qr/^StartLimitIntervalSec=0$/m, + 'action recovery is not disabled by a start limit' ); + +my $recipe = read_file($init_recipe); +like( $recipe, qr/\bxcat-genesis-discovery\b/, + 'action runtime includes discovery support' ); +like( $recipe, qr/file:\/\/genesis-action/, + 'action executor is packaged' ); +like( $recipe, qr/file:\/\/genesis-network-refresh/, + 'discovery network refresh is packaged' ); +like( $recipe, qr/\bxcat-genesis-action\.service\b/, + 'action service is enabled' ); + +done_testing(); diff --git a/xCAT-test/unit/genesis_openembedded_discovery.t b/xCAT-test/unit/genesis_openembedded_discovery.t new file mode 100644 index 000000000..8616e40c3 --- /dev/null +++ b/xCAT-test/unit/genesis_openembedded_discovery.t @@ -0,0 +1,501 @@ +#!/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 IO::Select; +use IPC::Open3; +use Symbol qw(gensym); +use Test::More; + +my $repo_root = File::Spec->catdir( $FindBin::Bin, '..', '..' ); +my $discovery_dir = File::Spec->catdir( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-xcat xcat-genesis-discovery) +); +my $discover_script = File::Spec->catfile( + $discovery_dir, qw(files genesis-discover) +); +my $callback_script = File::Spec->catfile( + $discovery_dir, qw(files genesis-discovery-callback) +); +my $getcert_script = File::Spec->catfile( + $discovery_dir, qw(files genesis-getcert) +); +my $credential_callback_script = File::Spec->catfile( + $discovery_dir, qw(files genesis-credential-callback) +); +my $udp_sender_source = File::Spec->catfile( + $discovery_dir, qw(files genesis-udp-send.c) +); +my $status_script = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-init files genesis-status) +); + +sub write_file { + my ( $path, $contents, $mode ) = @_; + open( my $fh, '>', $path ) or die "Unable to write $path: $!"; + print {$fh} $contents; + close($fh); + chmod( $mode, $path ) if defined($mode); +} + +sub read_file { + my ($path) = @_; + open( my $fh, '<', $path ) or die "Unable to read $path: $!"; + my $contents = do { local $/; <$fh> }; + close($fh); + return $contents; +} + +sub run_script { + my ( $script, $environment, $input ) = @_; + local %ENV = ( %ENV, %{$environment} ); + if ( defined($input) ) { + open( my $pipe, '|-', '/bin/bash', $script ) + or die "Unable to run $script: $!"; + print {$pipe} $input; + close($pipe); + return $? >> 8; + } + return system( '/bin/bash', $script ) >> 8; +} + +sub read_callback_before_eof { + my ( $script, $environment, $input ) = @_; + local %ENV = ( %ENV, %{$environment} ); + my ( $child_in, $child_out ); + my $child_err = gensym; + my $pid = open3( $child_in, $child_out, $child_err, + '/bin/bash', $script ); + print {$child_in} $input; + my $ready = IO::Select->new($child_out)->can_read(2); + my $response = $ready ? <$child_out> : undef; + close($child_in); + waitpid( $pid, 0 ); + return $response; +} + +my $root = tempdir( CLEANUP => 1 ); +my $bin = File::Spec->catdir( $root, 'bin' ); +my $state_dir = File::Spec->catdir( $root, 'run' ); +my $key_dir = File::Spec->catdir( $root, 'keys' ); +my $proc_root = File::Spec->catdir( $root, 'proc' ); +my $dmi_dir = File::Spec->catdir( + $root, qw(sys devices virtual dmi id) +); +my $eth0 = File::Spec->catdir( $root, qw(sys class net eth0) ); +my $driver = File::Spec->catdir( $root, qw(drivers virtio_net) ); +my $network_file = File::Spec->catfile( $state_dir, 'genesis.env' ); +my $response_file = File::Spec->catfile( $state_dir, 'discovery-response' ); +my $packet_file = File::Spec->catfile( $root, 'packet.xml' ); +my $credential_request = File::Spec->catfile( $root, 'credential-request.xml' ); +my $credential_response = File::Spec->catfile( $root, 'credential-response.xml' ); +my $metadata_file = File::Spec->catfile( $state_dir, 'xcat-response.env' ); +my $command_log = File::Spec->catfile( $root, 'commands.log' ); +my $uptime = File::Spec->catfile( $root, 'uptime' ); + +make_path( $bin, $state_dir, $key_dir, $proc_root, $dmi_dir, + File::Spec->catdir( $eth0, 'device' ), $driver, + File::Spec->catdir( $root, 'dev' ) ); +symlink( $driver, File::Spec->catfile( $eth0, 'device', 'driver' ) ) + or die "Unable to create driver link: $!"; +write_file( File::Spec->catfile( $eth0, 'address' ), + "52:54:00:00:00:02\n" ); +write_file( File::Spec->catfile( $eth0, 'device', 'uevent' ), + "PCI_SLOT_NAME=0000:00:03.0\n" ); +write_file( File::Spec->catfile( $eth0, 'device', 'physical_slot' ), + "Slot 4\n" ); +write_file( File::Spec->catfile( $root, 'dev', 'ipmi0' ), '' ); +write_file( File::Spec->catfile( $dmi_dir, 'sys_vendor' ), + "Acme & Co\n" ); +write_file( File::Spec->catfile( $dmi_dir, 'product_name' ), + "Rack \n" ); +write_file( File::Spec->catfile( $dmi_dir, 'product_serial' ), + "SN-0042\n" ); +write_file( File::Spec->catfile( $dmi_dir, 'product_uuid' ), + "00112233-4455-6677-8899-aabbccddeeff\n" ); +write_file( File::Spec->catfile( $proc_root, 'cpuinfo' ), <<'CPU' ); +processor : 0 +model name : Test CPU & Controller +processor : 1 +CPU +write_file( File::Spec->catfile( $proc_root, 'meminfo' ), + "MemTotal: 2097152 kB\n" ); +write_file( $uptime, "42.00 80.00\n" ); +write_file( $network_file, <<'ENV' ); +XCATDEST=192.0.2.10:3001 +XCATMASTER=192.0.2.10 +XCATPORT=3001 +XCAT_INTERFACE=eth0 +XCAT_SOURCE_ADDRESS=192.0.2.98 +ENV + +write_file( File::Spec->catfile( $bin, 'logger' ), + "#!/bin/sh\nexit 0\n", 0755 ); +write_file( File::Spec->catfile( $bin, 'uname' ), <<'SH', 0755 ); +#!/bin/sh +[ "$1" = "-m" ] && printf '%s\n' "${XCAT_TEST_ARCH-x86_64}" +SH +write_file( File::Spec->catfile( $bin, 'ipmitool' ), <<'SH', 0755 ); +#!/bin/sh +case "$*" in + 'mc info') exit 0 ;; + 'sol info') printf '%s\n' 'Payload Channel : 1' ;; + 'lan print 1'|'lan print') + printf '%s\n' 'IP Address Source : Static Address' \ + 'IP Address : 192.0.2.101' \ + 'MAC Address : 52:54:00:aa:bb:cc' + ;; +esac +SH +write_file( File::Spec->catfile( $bin, 'lldpcli' ), <<'SH', 0755 ); +#!/bin/sh +printf '%s\n' 'lldp.eth0.chassis.name=switch01' \ + 'lldp.eth0.chassis.mgmt-ip=192.0.2.2' \ + 'lldp.eth0.chassis.descr=Test switch' \ + 'lldp.eth0.port.descr=Ethernet1/4' +SH +write_file( File::Spec->catfile( $bin, 'lsblk' ), <<'SH', 0755 ); +#!/bin/sh +printf '%s\n' 'vda 21474836480 disk' 'vda1 1073741824 part' +SH +write_file( File::Spec->catfile( $bin, 'ip' ), <<'SH', 0755 ); +#!/bin/sh +case "$*" in + '-4 -o address show dev eth0 scope global') + [ -z "$XCAT_TEST_IPV4" ] \ + || printf '2: eth0 inet %s scope global eth0\n' "$XCAT_TEST_IPV4" + ;; + '-6 -o address show dev eth0 scope global') + [ -z "$XCAT_TEST_IPV6" ] \ + || printf '2: eth0 inet6 %s scope global eth0\n' "$XCAT_TEST_IPV6" + ;; +esac +SH +write_file( File::Spec->catfile( $bin, 'openssl' ), <<'SH', 0755 ); +#!/bin/sh +printf 'openssl %s\n' "$*" >>"$XCAT_TEST_LOG" +case "$1" in + genpkey) + while [ "$#" -gt 0 ]; do + if [ "$1" = '-out' ]; then + printf '%s\n' key >"$2" + break + fi + shift + done + ;; + pkey) + case " $* " in + *' -pubout '*) + printf '%s\n' '-----BEGIN PUBLIC KEY-----' 'UFVCS0VZ' \ + '-----END PUBLIC KEY-----' + ;; + esac + ;; + dgst) + while [ "$#" -gt 0 ]; do + if [ "$1" = '-out' ]; then + printf '%s\n' signature >"$2" + break + fi + shift + done + ;; + base64) printf '%s' U0lH ;; + req) + while [ "$#" -gt 0 ]; do + if [ "$1" = '-out' ]; then + printf '%s\n' '-----BEGIN CERTIFICATE REQUEST-----' 'Q1NS' \ + '-----END CERTIFICATE REQUEST-----' >"$2" + break + fi + shift + done + ;; + s_client) + cat >"$XCAT_TEST_CREDENTIAL_REQUEST" + cat "$XCAT_TEST_CREDENTIAL_RESPONSE" + ;; + x509) exit 0 ;; +esac +SH +write_file( File::Spec->catfile( $bin, 'timeout' ), <<'SH', 0755 ); +#!/bin/sh +shift +exec "$@" +SH +write_file( File::Spec->catfile( $bin, 'send-discovery' ), <<'SH', 0755 ); +#!/bin/sh +gzip -dc "$1" >"$XCAT_TEST_PACKET" +printf '%s %s\n' "$2" "$3" >>"$XCAT_TEST_LOG" +printf '%s\n' "$XCAT_TEST_RESPONSE" >"$XCAT_DISCOVERY_RESPONSE_FILE" +SH +write_file( File::Spec->catfile( $bin, 'network-refresh' ), <<'SH', 0755 ); +#!/bin/sh +printf 'network-refresh %s\n' "$1" >>"$XCAT_TEST_LOG" +SH + +my %environment = ( + PATH => "$bin:$ENV{PATH}", + XCAT_DISCOVERY_ATTEMPTS => 1, + XCAT_DISCOVERY_RESPONSE_FILE => $response_file, + XCAT_DISCOVERY_RETRY_SECONDS => 1, + XCAT_DISCOVERY_SEND_COMMAND => File::Spec->catfile( $bin, + 'send-discovery' ), + XCAT_KEY_DIR => $key_dir, + XCAT_METADATA_FILE => $metadata_file, + XCAT_NETWORK_FILE => $network_file, + XCAT_NETWORK_REFRESH_COMMAND => File::Spec->catfile( $bin, + 'network-refresh' ), + XCAT_PROC_ROOT => $proc_root, + XCAT_STATE_DIR => $state_dir, + XCAT_STATUS_COMMAND => $status_script, + XCAT_STATUS_DIR => File::Spec->catdir( $state_dir, 'status' ), + XCAT_SYS_ROOT => $root, + XCAT_TEST_LOG => $command_log, + XCAT_TEST_PACKET => $packet_file, + XCAT_TEST_IPV4 => '192.0.2.98/24', + XCAT_TEST_IPV6 => '', + XCAT_TEST_CREDENTIAL_REQUEST => $credential_request, + XCAT_TEST_CREDENTIAL_RESPONSE => $credential_response, + XCAT_TEST_RESPONSE => 'restart (eth0)', + XCAT_TEST_ARCH => 'x86_64', + XCAT_UPTIME_FILE => $uptime, +); + +is( run_script( $discover_script, \%environment ), 0, + 'discovery accepts an xCAT node match' ); +my $packet = read_file($packet_file); +like( $packet, qr{findme}, + 'discovery sends a findme request' ); +like( $packet, qr{x86_64}, + 'discovery sends the canonical architecture' ); +unlike( $packet, qr{virtual}, + 'physical DMI does not claim a virtual node' ); +like( $packet, qr{2}, + 'discovery reports the processor count' ); +like( $packet, qr{Test CPU & Controller}, + 'discovery escapes inventory text' ); +like( $packet, qr{2048MB}, + 'discovery reports memory' ); +like( $packet, qr{vda:20GB}, + 'discovery reports disk capacity' ); +like( $packet, + qr{Acme & Co:Rack <Node>}, + 'discovery escapes system identity' ); +like( $packet, + qr{virtio_net\|eth0\|52:54:00:00:00:02\|192\.0\.2\.98/24}, + 'discovery reports the management NIC' ); +like( $packet, qr{Slot 4}, + 'discovery reports the NIC location' ); +like( $packet, qr{switch01}, + 'discovery reports the LLDP neighbor' ); +like( $packet, qr{Ethernet1/4}, + 'discovery reports the LLDP port' ); +like( $packet, qr{1}, + 'discovery reports in-band BMC access' ); +like( $packet, qr{192\.0\.2\.101}, + 'discovery reports a static BMC address' ); +like( $packet, qr{52:54:00:aa:bb:cc}, + 'discovery reports the BMC MAC address' ); +like( $packet, qr{\nU0lH\n}, + 'discovery signs the inventory' ); +is( read_file($command_log) =~ /192\.0\.2\.10 3001/ ? 1 : 0, 1, + 'discovery sends to the selected xCAT endpoint' ); +like( read_file($command_log), qr/^network-refresh restart \(eth0\)$/m, + 'discovery applies the assigned network identity' ); +like( + read_file( + File::Spec->catfile( $state_dir, 'status', 'discovery.env' ) + ), + qr/^STATE=READY$/m, + 'discovery publishes completion' +); + +write_file( $network_file, <<'ENV' ); +XCATDEST=[2001:db8::10]:3001 +XCATMASTER=2001:db8::10 +XCATPORT=3001 +XCAT_INTERFACE=eth0 +XCAT_SOURCE_ADDRESS=2001:db8::98 +ENV +$environment{XCAT_TEST_IPV4} = ''; +$environment{XCAT_TEST_IPV6} = '2001:db8::98/64'; +is( run_script( $discover_script, \%environment ), 0, + 'discovery accepts an IPv6-only network' ); +$packet = read_file($packet_file); +like( $packet, + qr{virtio_net\|eth0\|52:54:00:00:00:02\|2001:db8::98/64}, + 'IPv6 supplies the legacy management NIC address' ); +like( $packet, qr{2001:db8::98/64}, + 'discovery reports the IPv6 interface address' ); +unlike( $packet, qr{}, + 'an IPv6-only interface does not claim an IPv4 address' ); +like( read_file($command_log), qr/^2001:db8::10 3001$/m, + 'discovery sends to the IPv6 xCAT endpoint' ); +$environment{XCAT_TEST_IPV4} = '192.0.2.98/24'; +$environment{XCAT_TEST_IPV6} = ''; + +write_file( File::Spec->catfile( $dmi_dir, 'sys_vendor' ), "QEMU\n" ); +$environment{XCAT_TEST_RESPONSE} = 'processed'; +isnt( run_script( $discover_script, \%environment ), 0, + 'an unmatched discovery request fails' ); +like( read_file($packet_file), qr{virtual}, + 'virtual DMI is reported to xCAT' ); +like( + read_file( + File::Spec->catfile( $state_dir, 'status', 'discovery.env' ) + ), + qr/^CODE=DISCOVERY_NOT_MATCHED$/m, + 'an unmatched node has a specific failure code' +); + +unlink( + File::Spec->catfile( $dmi_dir, 'sys_vendor' ), + File::Spec->catfile( $dmi_dir, 'product_name' ), + File::Spec->catfile( $dmi_dir, 'product_serial' ), + File::Spec->catfile( $dmi_dir, 'product_uuid' ), +); +my $device_tree = File::Spec->catdir( $proc_root, 'device-tree' ); +make_path($device_tree); +write_file( File::Spec->catfile( $device_tree, 'model' ), + "IBM,9009-42A\0" ); +write_file( File::Spec->catfile( $device_tree, 'system-id' ), + "IBM,02AB123\0" ); +write_file( File::Spec->catfile( $proc_root, 'cpuinfo' ), + "cpu : POWER9\ncpu : POWER9\nplatform : PowerNV\n" ); +$environment{XCAT_TEST_ARCH} = 'ppc64le'; +$environment{XCAT_TEST_RESPONSE} = 'restart'; +is( run_script( $discover_script, \%environment ), 0, + 'Power discovery accepts device-tree identity' ); +$packet = read_file($packet_file); +like( $packet, qr{ppc64le}, + 'Power discovery keeps the canonical architecture' ); +like( $packet, qr{9009-42A}, + 'Power discovery reports the machine type' ); +like( $packet, qr{02AB123}, + 'Power discovery reports the system serial' ); +like( $packet, qr{PowerNV}, + 'Power discovery reports the firmware platform' ); +like( $packet, qr{2}, + 'Power discovery counts processor records' ); +like( $packet, + qr{9009-42a-02ab123-525400000002}, + 'Power discovery creates a stable fallback UUID' ); + +unlink($response_file); +is( run_script( $callback_script, \%environment, "restart (eth0)" ), 0, + 'callback accepts a restart response' ); +is( read_file($response_file), "restart (eth0)\n", + 'callback records the requested interface' ); +isnt( run_script( $callback_script, \%environment, "malformed" ), 0, + 'callback rejects an unknown response' ); + +write_file( $metadata_file, <<'ENV' ); +XCAT_NODE_NAME=node042 +XCAT_DESTINY=standby +ENV +write_file( $credential_response, <<'XML' ); + + +-----BEGIN CERTIFICATE----- +Q0VSVA== +-----END CERTIFICATE----- + + +XML +is( run_script( $getcert_script, \%environment ), 0, + 'certificate client installs an xCAT certificate' ); +is( read_file( File::Spec->catfile( $key_dir, 'cert.pem' ) ), <<'PEM', +-----BEGIN CERTIFICATE----- +Q0VSVA== +-----END CERTIFICATE----- +PEM + 'certificate client stores only the PEM certificate' +); +my $certificate_request = read_file($credential_request); +like( $certificate_request, qr{getcredentials}, + 'certificate client requests x509 credentials' ); +like( $certificate_request, qr{300}, + 'certificate client declares the privileged callback port' ); +like( $certificate_request, qr{\nU0lH\n}, + 'certificate request is signed by the discovery identity' ); +like( read_file($command_log), + qr{openssl s_client -connect \[2001:db8::10\]:3001 -quiet}, + 'certificate client keeps the bracketed IPv6 endpoint' ); +like( + read_file( + File::Spec->catfile( $state_dir, 'status', 'credentials.env' ) + ), + qr/^STATE=READY$/m, + 'certificate client publishes completion' +); + +write_file( $metadata_file, "XCAT_NODE_NAME=invalid/name\n" ); +isnt( run_script( $getcert_script, \%environment ), 0, + 'certificate client rejects an unsafe node name' ); +like( + read_file( + File::Spec->catfile( $state_dir, 'status', 'credentials.env' ) + ), + qr/^CODE=CERTIFICATE_NODE_IDENTITY_MISSING$/m, + 'missing confirmed identity has a specific failure code' +); + +is( run_script( $credential_callback_script, \%environment, + "CREDOKBYYOU?" ), + 0, 'credential callback accepts the xCAT challenge' ); +is( + read_callback_before_eof( + $credential_callback_script, \%environment, "CREDOKBYYOU?\n" + ), + "CREDOKBYME\n", + 'credential callback answers before xcatd closes the connection' +); +isnt( run_script( $credential_callback_script, \%environment, "unknown" ), 0, + 'credential callback rejects an unknown challenge' ); + +my $recipe = read_file( + File::Spec->catfile( $discovery_dir, 'xcat-genesis-discovery_1.0.bb' ) +); +like( $recipe, qr/^RDEPENDS:\$\{PN\} = "bash coreutils gzip iproute2 openssl-bin util-linux-lsblk"$/m, + 'discovery dependencies are explicit' ); +like( $recipe, qr/xcat-genesis-discovery\.socket/, + 'discovery callback socket is packaged' ); +like( $recipe, qr/xcat-genesis-credential\.socket/, + 'credential callback socket is packaged' ); +like( $recipe, qr/\$\{CC\}.*genesis-udp-send\.c/s, + 'discovery UDP sender is compiled for the target' ); +like( $recipe, qr/-std=c17.*-Werror/s, + 'discovery UDP sender uses the strict C build contract' ); +like( $recipe, qr{\$\{libexecdir\}/xcat/genesis-udp-send}, + 'discovery UDP sender is packaged' ); + +my $udp_sender = read_file($udp_sender_source); +like( $udp_sender, qr/^\s*SOURCE_PORT = 301,$/m, + 'discovery sender uses the legacy privileged source port' ); +like( $udp_sender, qr/^\s*hints\.ai_family = AF_UNSPEC;$/m, + 'discovery sender resolves both address families' ); +like( $udp_sender, qr/source4->sin_port = htons\(SOURCE_PORT\)/, + 'discovery sender binds the IPv4 source port' ); +like( $udp_sender, qr/source6->sin6_port = htons\(SOURCE_PORT\)/, + 'discovery sender binds the IPv6 source port' ); +like( read_file($discover_script), qr{/usr/libexec/xcat/genesis-udp-send}, + 'discovery uses the source-port-aware UDP sender' ); + +my $image = read_file( + File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core images xcat-genesis-image.bb) + ) +); +like( $image, qr/\bxcat-genesis-discovery\b/, + 'base image includes the discovery client' ); + +done_testing(); diff --git a/xCAT-test/unit/genesis_openembedded_metrics.t b/xCAT-test/unit/genesis_openembedded_metrics.t new file mode 100644 index 000000000..65f0e06ae --- /dev/null +++ b/xCAT-test/unit/genesis_openembedded_metrics.t @@ -0,0 +1,183 @@ +#!/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 IO::Compress::Gzip qw(gzip $GzipError); +use Test::More; + +my $repo_root = File::Spec->catdir( $FindBin::Bin, '..', '..' ); +my $report = File::Spec->catfile( + $repo_root, qw(xCAT-genesis-builder oe report) +); +my $metrics = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-init files genesis-metrics) +); +my $recipe = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-init xcat-genesis-init_1.0.bb) +); +my $service = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-init files xcat-genesis-metrics.service) +); + +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; +} + +sub run_command { + my ( $environment, @command ) = @_; + local %ENV = ( %ENV, %{$environment} ); + open( my $stream, '-|', @command ) + or die "Unable to run @command: $!"; + my $output = do { local $/; <$stream> }; + close($stream); + return ( $? >> 8, $output ); +} + +sub parse_report { + my ($contents) = @_; + my %values; + foreach my $line ( split( /\n/, $contents ) ) { + my ( $name, $value ) = split( /=/, $line, 2 ); + $values{$name} = $value; + } + return \%values; +} + +my $recipe_contents = read_file($recipe); +like( $recipe_contents, qr/file:\/\/genesis-metrics/, + 'metrics collector is included in the init package' ); +like( $recipe_contents, qr/SYSTEMD_SERVICE.*?xcat-genesis-metrics\.service/s, + 'metrics service is enabled with the init package' ); + +my $service_contents = read_file($service); +like( $service_contents, + qr/Requires=xcat-genesis-register\.service\nAfter=xcat-genesis-register\.service/, + 'metrics are captured after successful registration' ); +like( $service_contents, + qr{ExecStart=/usr/libexec/xcat/genesis-metrics --output /run/xcat/metrics\.env}, + 'metrics are stored in the runtime state directory' ); + +my $root = tempdir( CLEANUP => 1 ); +my $registration = File::Spec->catfile( $root, 'registration.env' ); +my $meminfo = File::Spec->catfile( $root, 'meminfo' ); +my $uptime = File::Spec->catfile( $root, 'uptime' ); +my $runtime = File::Spec->catfile( $root, 'metrics.env' ); + +write_file( + $registration, + "SCHEMA=1\nSTATE=ACTION_RECEIVED\nDETAIL=Destiny shell\nUPDATED_SECONDS=42\n" +); +write_file( + $meminfo, + "MemTotal: 1048576 kB\nMemFree: 524288 kB\n" + . "MemAvailable: 786432 kB\n" +); +write_file( $uptime, "45.92 12.34\n" ); + +my %metrics_environment = ( + XCAT_REGISTRATION_STATUS_FILE => $registration, + XCAT_MEMINFO_FILE => $meminfo, + XCAT_UPTIME_FILE => $uptime, +); +my ( $status, $output ) = run_command( \%metrics_environment, $metrics ); +is( $status, 0, 'runtime metrics are collected' ); +is_deeply( + parse_report($output), + { + SCHEMA => '1', + BOOT_READY_SECONDS => '42', + CAPTURE_UPTIME_SECONDS => '45', + MEMORY_TOTAL_KIB => '1048576', + MEMORY_AVAILABLE_KIB => '786432', + MEMORY_USED_KIB => '262144', + }, + 'runtime report records boot time and memory use' +); + +( $status, $output ) = run_command( + \%metrics_environment, $metrics, '--output', $runtime +); +is( $status, 0, 'runtime metrics can be stored atomically' ); +is( read_file($runtime), + "SCHEMA=1\nBOOT_READY_SECONDS=42\nCAPTURE_UPTIME_SECONDS=45\n" + . "MEMORY_TOTAL_KIB=1048576\nMEMORY_AVAILABLE_KIB=786432\n" + . "MEMORY_USED_KIB=262144\n", + 'stored runtime report is complete' ); +is( ( stat($runtime) )[2] & 07777, 0644, + 'stored runtime report is readable' ); + +my $image_dir = File::Spec->catdir( $root, 'image' ); +make_path($image_dir); +my $kernel = File::Spec->catfile( $image_dir, 'kernel' ); +my $initramfs = File::Spec->catfile( $image_dir, 'initramfs.cpio.gz' ); +my $payload = "cpio payload\n"; +write_file( $kernel, "kernel\n" ); +gzip( \$payload => $initramfs ) + or die "Unable to create test initramfs: $GzipError"; + +( $status, $output ) = run_command( + {}, $report, '--runtime', $runtime, 'x86_64', $image_dir +); +is( $status, 0, 'image and runtime metrics are reported together' ); +my $current = parse_report($output); +is( $current->{ARCHITECTURE}, 'x86_64', + 'report records the Genesis architecture' ); +is( $current->{KERNEL_BYTES}, -s $kernel, + 'report measures the kernel' ); +is( $current->{COMPRESSED_INITRAMFS_BYTES}, -s $initramfs, + 'report measures the compressed initramfs' ); +is( $current->{UNPACKED_INITRAMFS_BYTES}, length($payload), + 'report measures the unpacked initramfs' ); +is( $current->{BOOT_READY_SECONDS}, 42, + 'report includes the registration-ready time' ); +is( $current->{MEMORY_USED_KIB}, 262144, + 'report includes captured memory use' ); + +my $baseline = File::Spec->catfile( $root, 'baseline.env' ); +write_file( + $baseline, + "SCHEMA=1\nARCHITECTURE=x86_64\nKERNEL_BYTES=" + . ( ( -s $kernel ) - 2 ) + . "\nCOMPRESSED_INITRAMFS_BYTES=" + . ( ( -s $initramfs ) - 3 ) + . "\nUNPACKED_INITRAMFS_BYTES=" + . ( length($payload) - 4 ) + . "\nBOOT_READY_SECONDS=40\nMEMORY_USED_KIB=250000\n" +); + +( $status, $output ) = run_command( + {}, $report, '--runtime', $runtime, '--baseline', $baseline, + 'x86_64', $image_dir +); +is( $status, 0, 'report compares against a matching baseline' ); +my $comparison = parse_report($output); +is( $comparison->{KERNEL_BYTES_DELTA}, 2, + 'kernel growth is reported' ); +is( $comparison->{COMPRESSED_INITRAMFS_BYTES_DELTA}, 3, + 'compressed image growth is reported' ); +is( $comparison->{UNPACKED_INITRAMFS_BYTES_DELTA}, 4, + 'unpacked image growth is reported' ); +is( $comparison->{BOOT_READY_SECONDS_DELTA}, 2, + 'slower registration is reported' ); +is( $comparison->{MEMORY_USED_KIB_DELTA}, 12144, + 'additional memory use is reported' ); + +done_testing(); diff --git a/xCAT-test/unit/genesis_openembedded_runtime.t b/xCAT-test/unit/genesis_openembedded_runtime.t new file mode 100644 index 000000000..81eab2e04 --- /dev/null +++ b/xCAT-test/unit/genesis_openembedded_runtime.t @@ -0,0 +1,552 @@ +#!/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 IO::Socket::INET; +use Test::More; + +my $repo_root = File::Spec->catdir( $FindBin::Bin, '..', '..' ); +my $network_script = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-init files genesis-network-state) +); +my $network_refresh_script = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-init files genesis-network-refresh) +); +my $register_script = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-init files genesis-register) +); +my $status_script = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-init files genesis-status) +); +my $maintenance_shell_script = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-init files genesis-maintenance-shell) +); +my $functions_file = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-builder oe meta-xcat-genesis recipes-core xcat-genesis-init files genesis-functions) +); +my $getdestiny_script = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-scripts usr bin getdestiny) +); +my $nextdestiny_script = File::Spec->catfile( + $repo_root, + qw(xCAT-genesis-scripts usr bin nextdestiny) +); + +sub write_file { + my ( $path, $contents, $mode ) = @_; + open( my $fh, '>', $path ) or die "Unable to write $path: $!"; + print {$fh} $contents; + close($fh); + chmod( $mode, $path ) if defined($mode); +} + +sub read_file { + my ($path) = @_; + open( my $fh, '<', $path ) or die "Unable to read $path: $!"; + my $contents = do { local $/; <$fh> }; + close($fh); + return $contents; +} + +sub run_script { + my ( $script, $environment, @arguments ) = @_; + local %ENV = ( %ENV, %{$environment} ); + return system( '/bin/bash', $script, @arguments ) >> 8; +} + +sub capture_command { + my ( $environment, @command ) = @_; + local %ENV = ( %ENV, %{$environment} ); + open( my $fh, '-|', @command ) or die "Unable to run @command: $!"; + my $output = do { local $/; <$fh> }; + close($fh); + return ( $? >> 8, $output ); +} + +my $root = tempdir( CLEANUP => 1 ); +my $bin = File::Spec->catdir( $root, 'bin' ); +my $state_dir = File::Spec->catdir( $root, 'run' ); +my $sys_class_net = File::Spec->catdir( $root, 'sys', 'class', 'net' ); +my $eth0 = File::Spec->catdir( $sys_class_net, 'eth0' ); +my $eth1 = File::Spec->catdir( $sys_class_net, 'eth1' ); +my $cmdline = File::Spec->catfile( $root, 'cmdline' ); +my $uptime = File::Spec->catfile( $root, 'uptime' ); +my $command_log = File::Spec->catfile( $root, 'commands.log' ); +my $destiny_response = File::Spec->catfile( $root, 'destiny-response.xml' ); +my $destiny_metadata = File::Spec->catfile( $state_dir, 'xcat-response.env' ); +my $listener = IO::Socket::INET->new( + LocalAddr => '127.0.0.1', + LocalPort => 0, + Listen => 2, + ReuseAddr => 1, +) or die "Unable to open test listener: $!"; +my $listener_port = $listener->sockport(); +my $listener_pid = fork(); +die "Unable to fork test listener: $!" unless defined($listener_pid); +if ( $listener_pid == 0 ) { + for ( 1 .. 3 ) { + my $client = $listener->accept() or exit 1; + close($client); + } + exit 0; +} + +make_path( $bin, $state_dir, $eth0, $eth1 ); +write_file( File::Spec->catfile( $eth0, 'address' ), + "52:54:00:00:00:35\n" ); +write_file( File::Spec->catfile( $eth0, 'operstate' ), "up\n" ); +write_file( $uptime, "123.45 456.78\n" ); +write_file( + File::Spec->catfile( $bin, 'logger' ), + "#!/bin/sh\nexit 0\n", 0755 +); +write_file( + File::Spec->catfile( $bin, 'ip' ), + <<'SH', 0755 +#!/bin/sh +case "$*" in + '-4 -o route get '*) printf '%s\n' "$XCAT_TEST_ROUTE" ;; + '-4 -o address show dev eth0 scope global') + printf '%s\n' '2: eth0 inet 192.0.2.98/24 scope global eth0' + ;; +esac +SH +); +write_file( + File::Spec->catfile( $bin, 'nmcli' ), + <<'SH', 0755 +#!/bin/sh +printf 'nmcli %s\n' "$*" >>"$XCAT_TEST_LOG" +case "$*" in + '-t -f DEVICE,TYPE device status') + printf '%s\n' 'eth0:ethernet' 'eth1:ethernet' 'lo:loopback' + ;; + '-g GENERAL.STATE device show '*) printf '%s\n' '100 (connected)' ;; + '--terse --escape no -g IP4.DNS,IP6.DNS device show eth0') + printf '%s\n' '192.0.2.53 | 2001:db8::53' + ;; +esac +SH +); +write_file( + File::Spec->catfile( $bin, 'network-state-refresh' ), + <<'SH', 0755 +#!/bin/sh +printf '%s\n' 'network-state-refresh' >>"$XCAT_TEST_LOG" +SH +); +write_file( + File::Spec->catfile( $bin, 'getdestiny' ), + <<'SH', 0755 +#!/bin/sh +printf 'getdestiny %s\n' "$*" >>"$XCAT_TEST_LOG" +metadata= +while [ "$#" -gt 0 ]; do + case "$1" in + --metadata) metadata=$2; shift 2 ;; + *) shift ;; + esac +done +if [ -n "$metadata" ]; then + cat >"$metadata" <<'ENV' +XCAT_NODE_NAME=node042 +XCAT_DESTINY=osimage=test-image +XCAT_IMAGE_SERVER=192.0.2.10 +XCAT_KERNEL=/tftp/vmlinuz +XCAT_INITRD=/tftp/initrd +XCAT_KERNEL_COMMAND_LINE=console=ttyS0 quiet +ENV +fi +printf '%s\n' "${XCAT_TEST_DESTINY-standby}" +SH +); +write_file( + File::Spec->catfile( $bin, 'openssl' ), + <<'SH', 0755 +#!/bin/sh +printf 'openssl %s\n' "$*" >>"$XCAT_TEST_LOG" +cat "$XCAT_TEST_RESPONSE_FILE" +SH +); + +my %environment = ( + PATH => "$bin:$ENV{PATH}", + XCAT_CMDLINE_FILE => $cmdline, + XCAT_STATE_DIR => $state_dir, + XCAT_STATUS_COMMAND => $status_script, + XCAT_STATUS_DIR => File::Spec->catdir( $state_dir, 'status' ), + XCAT_SYS_CLASS_NET => $sys_class_net, + XCAT_TEST_LOG => $command_log, + XCAT_TEST_RESPONSE_FILE => $destiny_response, + XCAT_TEST_ROUTE => '127.0.0.1 via 192.0.2.1 dev eth0 src 192.0.2.98', + XCAT_UPTIME_FILE => $uptime, + XCAT_GENESIS_FUNCTIONS => $functions_file, + XCAT_REGISTRATION_ATTEMPTS => 1, + XCAT_REGISTRATION_RETRY_SECONDS => 0, + XCAT_REGISTRATION_REQUEST_TIMEOUT => 1, +); + +write_file( + $destiny_response, + <<'XML' + + +node042 +osimage=test-image +/tftp/vmlinuz +/tftp/initrd +console=ttyS0 quiet +192.0.2.10 + + +XML +); +my ( $destiny_status, $destiny_output ) = capture_command( + \%environment, + '/bin/bash', $getdestiny_script, + '192.0.2.10:3001', '--once', '--metadata', $destiny_metadata +); +is( $destiny_status, 0, 'getdestiny accepts a complete response' ); +is( $destiny_output, "osimage=test-image\n", + 'getdestiny preserves the complete action' ); +is( + read_file($destiny_metadata), + <<'ENV', +XCAT_NODE_NAME=node042 +XCAT_DESTINY=osimage=test-image +XCAT_IMAGE_SERVER=192.0.2.10 +XCAT_KERNEL=/tftp/vmlinuz +XCAT_INITRD=/tftp/initrd +XCAT_KERNEL_COMMAND_LINE=console=ttyS0 quiet +ENV + 'getdestiny records the response metadata' +); + +write_file( $destiny_response, "denied\n" ); +( $destiny_status, $destiny_output ) = capture_command( + \%environment, + '/bin/bash', $getdestiny_script, + '192.0.2.10:3001', '--once', '--metadata', $destiny_metadata +); +isnt( $destiny_status, 0, 'getdestiny rejects an error response' ); +is( read_file($destiny_metadata), <<'ENV', +XCAT_NODE_NAME=node042 +XCAT_DESTINY=osimage=test-image +XCAT_IMAGE_SERVER=192.0.2.10 +XCAT_KERNEL=/tftp/vmlinuz +XCAT_INITRD=/tftp/initrd +XCAT_KERNEL_COMMAND_LINE=console=ttyS0 quiet +ENV + 'a failed request preserves prior metadata' +); + +write_file( + $destiny_response, + <<'XML' + + +node042 +install rocky9.7-x86_64-compute +/tftp/vmlinuz +/tftp/initrd +console=ttyS0 quiet +192.0.2.10 + + +XML +); +( $destiny_status, $destiny_output ) = capture_command( + \%environment, + '/bin/bash', $nextdestiny_script, + '192.0.2.10:3001', '--once', '--metadata', $destiny_metadata +); +is( $destiny_status, 0, 'nextdestiny accepts a complete response' ); +is( $destiny_output, "install rocky9.7-x86_64-compute\n", + 'nextdestiny preserves a legacy action with arguments' ); +like( read_file($destiny_metadata), + qr/^XCAT_DESTINY=install rocky9\.7-x86_64-compute$/m, + 'nextdestiny records the complete action' ); + +write_file( $destiny_response, + "chain unavailable\n" ); +( $destiny_status, $destiny_output ) = capture_command( + \%environment, + '/bin/bash', $nextdestiny_script, + '192.0.2.10:3001', '--once' +); +is( $destiny_status, 0, 'nextdestiny returns a server error as an action' ); +is( $destiny_output, "error=chain unavailable\n", + 'nextdestiny preserves legacy error handling' ); + +write_file( $destiny_response, "\n" ); +( $destiny_status, $destiny_output ) = capture_command( + \%environment, + '/bin/bash', $nextdestiny_script, + '192.0.2.10:3001', '--once' +); +isnt( $destiny_status, 0, 'nextdestiny rejects an incomplete response' ); + +write_file( + $cmdline, + "xcatd=127.0.0.1:$listener_port BOOTIF=01-52-54-00-00-00-35\n" +); +is( run_script( $network_script, \%environment ), 0, + 'network state accepts DHCP boot state' ); +is( + read_file( File::Spec->catfile( $state_dir, 'genesis.env' ) ), + "XCATDEST=127.0.0.1:$listener_port\n" + . "XCATMASTER=127.0.0.1\n" + . "XCATPORT=$listener_port\n" + . <<'ENV', +XCAT_INTERFACE=eth0 +XCAT_SOURCE_ADDRESS=192.0.2.98 +XCAT_SOURCE_PREFIXED_ADDRESS=192.0.2.98/24 +XCAT_GATEWAY=192.0.2.1 +XCAT_DNS_SERVERS=192.0.2.53,2001:db8::53 +XCAT_NETWORK_METHOD=auto +XCAT_LINK_STATE=up +XCAT_MAC_ADDRESS=52:54:00:00:00:35 +XCAT_VERIFIED_SECONDS=123 +ENV + 'network state is written atomically' +); +is( + read_file( + File::Spec->catfile( $state_dir, 'status', 'network.env' ) + ), + <<'ENV', +SCHEMA=1 +STATE=READY +DETAIL=Management network ready on eth0 +STARTED_SECONDS=123 +UPDATED_SECONDS=123 +VERIFIED_SECONDS=123 +ENV + 'network readiness publishes status' +); + +write_file( $command_log, '' ); +$environment{XCAT_NETWORK_STATE_COMMAND} = File::Spec->catfile( + $bin, 'network-state-refresh' ); +is( run_script( $network_refresh_script, \%environment, 'restart (eth0)' ), + 0, 'discovery refreshes its DHCP identity' ); +my $refresh_log = read_file($command_log); +like( $refresh_log, qr/^nmcli --wait 10 device disconnect eth1$/m, + 'forced interface selection disconnects other Ethernet devices' ); +like( $refresh_log, qr/^nmcli --wait 10 device disconnect eth0$/m, + 'the discovery interface releases its temporary lease' ); +like( $refresh_log, qr/^nmcli --wait 30 device connect eth0$/m, + 'the discovery interface requests its assigned lease' ); +like( $refresh_log, qr/^network-state-refresh$/m, + 'network state is rebuilt after DHCP renewal' ); +delete $environment{XCAT_NETWORK_STATE_COMMAND}; + +write_file( + $cmdline, + "xcatd=127.0.0.1:$listener_port BOOTIF=01-52-54-00-00-00-35 " + . 'hostip=192.0.2.98 netmask=255.255.255.192 ' + . "gateway=192.0.2.1\n" +); +write_file( $command_log, '' ); +is( run_script( $network_script, \%environment ), 0, + 'network state accepts static boot settings' ); +like( read_file($command_log), qr/ipv4\.addresses 192\.0\.2\.98\/26/, + 'dotted netmask becomes a CIDR prefix' ); + +write_file( + $cmdline, + "xcatd=127.0.0.1:$listener_port BOOTIF=01-52-54-00-00-00-35 " + . 'hostip=2001:db8::98/64 netmask=64 ' + . "gateway=2001:db8::1\n" +); +write_file( $command_log, '' ); +is( run_script( $network_script, \%environment ), 0, + 'network state accepts static IPv6 boot settings' ); +like( read_file($command_log), + qr/ipv4\.method disabled ipv6\.method manual ipv6\.addresses 2001:db8::98\/64 ipv6\.gateway 2001:db8::1/, + 'static IPv6 disables IPv4 and configures the IPv6 route' ); + +write_file( + $cmdline, + "xcatd=127.0.0.1:$listener_port BOOTIF=01-52-54-00-00-00-35 " + . "hostip=192.0.2.98 netmask=255.255.255.0\n" +); +isnt( run_script( $network_script, \%environment ), 0, + 'partial static settings fail closed' ); +like( + read_file( + File::Spec->catfile( $state_dir, 'status', 'network.env' ) + ), + qr/^STATE=FAILED$/m, + 'network failure replaces the prior status' +); +like( + read_file( + File::Spec->catfile( $state_dir, 'status', 'network.env' ) + ), + qr/^CODE=INVALID_STATIC_NETWORK$/m, + 'network failure identifies the rejected configuration' +); + +$environment{XCATDEST} = '192.0.2.213:3001'; +write_file( $command_log, '' ); +write_file( $cmdline, "xcatd=192.0.2.213:3001 destiny=shell\n" ); +is( run_script( $register_script, \%environment ), 0, + 'registration accepts a kernel destiny' ); +is( read_file( File::Spec->catfile( $state_dir, 'destiny' ) ), "shell\n", + 'kernel destiny remains authoritative' ); +like( read_file($command_log), qr/getdestiny 192\.0\.2\.213:3001/, + 'kernel destiny still records xCAT contact' ); +like( + read_file( + File::Spec->catfile( $state_dir, 'status', 'registration.env' ) + ), + qr/^STATE=ACTION_RECEIVED$/m, + 'registration records the received action' +); +like( + read_file( + File::Spec->catfile( $state_dir, 'status', 'registration.env' ) + ), + qr/^ACTION=shell\nTARGET=\nNODE_NAME=node042$/m, + 'registration publishes confirmed identity and the selected action' +); + +write_file( $command_log, '' ); +write_file( $cmdline, "xcatd=192.0.2.213:3001\n" ); +is( run_script( $register_script, \%environment ), 0, + 'registration requests a missing destiny' ); +is( read_file( File::Spec->catfile( $state_dir, 'destiny' ) ), "standby\n", + 'xCAT supplies a missing destiny' ); + +$environment{XCAT_TEST_DESTINY} = ''; +isnt( run_script( $register_script, \%environment ), 0, + 'registration rejects an empty destiny' ); +is( read_file( File::Spec->catfile( $state_dir, 'destiny' ) ), "standby\n", + 'an empty destiny does not replace prior state' ); +like( + read_file( + File::Spec->catfile( $state_dir, 'status', 'registration.env' ) + ), + qr/^STATE=FAILED$/m, + 'registration failure replaces the prior status' +); +like( + read_file( + File::Spec->catfile( $state_dir, 'status', 'registration.env' ) + ), + qr/^CODE=XCAT_RESPONSE_UNAVAILABLE\nRECOVERY=/m, + 'registration failure includes a recovery hint' +); + +$environment{XCAT_TEST_DESTINY} = 'install rocky9.7-x86_64-compute'; +is( run_script( $register_script, \%environment ), 0, + 'registration accepts a legacy action with arguments' ); +like( + read_file( + File::Spec->catfile( $state_dir, 'status', 'registration.env' ) + ), + qr/^ACTION=install\nTARGET=rocky9\.7-x86_64-compute$/m, + 'registration separates the action from its argument' +); + +{ + local %ENV = ( %ENV, %environment ); + is( system( '/bin/sh', $status_script, 'console', 'DEGRADED', + "bad\n\tvalue\x01" ) >> 8, + 0, 'status helper accepts a valid record' ); + like( + read_file( + File::Spec->catfile( $state_dir, 'status', 'console.env' ) + ), + qr/^DETAIL=bad value$/m, + 'status detail is reduced to printable text' + ); + write_file( $uptime, "130.00 500.00\n" ); + is( system( '/bin/sh', $status_script, 'console', 'DEGRADED', + 'waiting for operator', 'CODE=OPERATOR_WAIT', + 'RECOVERY=Review diagnostics' ) >> 8, + 0, 'status helper accepts structured fields' ); + is( + read_file( + File::Spec->catfile( $state_dir, 'status', 'console.env' ) + ), + <<'ENV', +SCHEMA=1 +STATE=DEGRADED +DETAIL=waiting for operator +STARTED_SECONDS=123 +UPDATED_SECONDS=130 +CODE=OPERATOR_WAIT +RECOVERY=Review diagnostics +ENV + 'status helper preserves the stage start time' + ); + open( my $saved_stderr, '>&', \*STDERR ) + or die "Unable to duplicate stderr: $!"; + open( STDERR, '>', File::Spec->devnull() ) + or die "Unable to redirect stderr: $!"; + isnt( system( '/bin/sh', $status_script, '../console', 'READY' ) >> 8, + 0, 'status helper rejects an unsafe component' ); + isnt( system( '/bin/sh', $status_script, 'console', 'UNKNOWN' ) >> 8, + 0, 'status helper rejects an unknown state' ); + isnt( system( '/bin/sh', $status_script, 'console', 'READY', '', + 'ATTEMPT=invalid' ) >> 8, + 0, 'status helper rejects invalid numeric fields' ); + open( STDERR, '>&', $saved_stderr ) + or die "Unable to restore stderr: $!"; +} + +my $component_status_dir = File::Spec->catdir( $state_dir, 'status' ); +for my $component (qw(network extensions registration action)) { + my $state = $component eq 'extensions' ? 'FAILED' : + $component eq 'action' ? 'IDLE' : 'READY'; + write_file( + File::Spec->catfile( $component_status_dir, "$component.env" ), + "STATE=$state\n" + ); +} +my ( $shell_status, $shell_output ) = capture_command( + \%environment, + '/bin/bash', '-c', + 'printf "exit\\n" | /bin/bash "$1" 2>&1', + 'genesis-maintenance-shell', $maintenance_shell_script +); +is( $shell_status, 0, 'maintenance shell exits cleanly' ); +like( + $shell_output, + qr/^Overall state: FAILED$/m, + 'maintenance shell reports an extension failure as the overall state' +); + +write_file( + File::Spec->catfile( $component_status_dir, 'extensions.env' ), + "STATE=READY\n" +); +( $shell_status, $shell_output ) = capture_command( + \%environment, + '/bin/bash', '-c', + 'printf "exit\\n" | /bin/bash "$1" 2>&1', + 'genesis-maintenance-shell', $maintenance_shell_script +); +like( + $shell_output, + qr/^Overall state: IDLE$/m, + 'maintenance shell prefers the current action state' +); + +waitpid( $listener_pid, 0 ); +is( $? >> 8, 0, 'network readiness probes the xCAT port' ); + +done_testing();