2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-09-05 20:47:55 +00:00
Files
xcat-core/xCAT-test/unit/pdu_genpdu.t
T
Kilian Cavalotti daef2d7bae Distinguish SNMP failures from absent objects in genpdu
A missing object is exception text under v2c/v3 and an error under v1,
and genpdu treated both the same. Add pdu2_get, returning a value and an
ok, absent or failed status. A failed signed minimum read now skips the
sensor, and rinv no longer prints "No Such Instance currently exists at
this OID" for a nameplate field the model does not populate.

Read the signed minimum per entity: the MIB indexes it that way, and it
decides which value column is read.

In the session probe, fall back to the PDU2 nameplate rather than
sysDescr, so a credential that cannot read PDU2 is rejected, return
early when the pduCount read fails at the transport level, and warn once
per PDU.

Signed-off-by: Kilian Cavalotti <kilian@stanford.edu>
2026-08-24 16:32:41 -07:00

447 lines
16 KiB
Perl

use strict;
use warnings;
## no critic (Modules::RequireFilenameMatchesPackage, TestingAndDebugging::ProhibitNoWarnings, TestingAndDebugging::ProhibitNoStrict)
no warnings 'once';
use File::Spec;
use FindBin;
use Test::More;
BEGIN {
#pdu.pm pulls in modules the genpdu code paths never touch and that are not
#installed everywhere (SNMP, Expect, XML::Simple), plus xCAT modules that
#would open the database. Stub them out. xCAT::SvrUtils::sendmsg is the one
#that has to behave, since it is how the plugin returns its output.
for my $mod (qw(xCAT::Table xCAT::Utils xCAT::FifoPipe xCAT::MsgUtils
xCAT::State xCAT::Usage xCAT::NodeRange
SNMP Expect XML::Simple)) {
(my $file = $mod) =~ s{::}{/}g;
$INC{"$file.pm"} = __FILE__;
no strict 'refs';
*{"${mod}::import"} = sub { };
}
#pdu.pm uses Expect's exp_continue constant in its crpdu code paths, so the
#stub has to keep exporting that name.
{
no strict 'refs';
no warnings 'redefine';
*{'Expect::import'} = sub {
my $caller = caller;
no strict 'refs';
*{"${caller}::exp_continue"} = sub { return; };
return;
};
}
package xCAT::SvrUtils;
our @messages;
sub import { }
sub sendmsg {
my ($msg, $cb, $node) = @_;
push @messages, defined($node) ? "$node|$msg" : $msg;
return;
}
$INC{'xCAT/SvrUtils.pm'} = __FILE__;
}
my $repo_root = File::Spec->catdir($FindBin::Bin, '..', '..');
$ENV{XCATROOT} = File::Spec->catdir($repo_root, 'xCAT-server');
my $plugin = File::Spec->catfile($repo_root, 'xCAT-server', 'lib', 'xcat',
'plugins', 'pdu.pm');
do $plugin or die $@ || "Unable to load $plugin: $!";
{
#A stand-in for SNMP::Session: answers GETs from a canned table and records
#which OIDs were asked for, so tests can assert on request counts too.
package FakeSession;
sub new {
my ($class, $resp) = @_;
return bless { resp => $resp || {}, gets => [] }, $class;
}
#An OID the fixture does not mention models a device that does not answer:
#undef with ErrorStr set, which is what SNMP.pm does on a timeout. A fixture
#value of "No Such ..." models an answer that carries an exception, and a
#hashref models any other transport or protocol failure.
sub get {
my ($self, $oid) = @_;
push @{ $self->{gets} }, $oid;
my $val = $self->{resp}->{$oid};
if (ref $val eq 'HASH') {
$self->{ErrorStr} = $val->{err};
return undef;
}
unless (exists $self->{resp}->{$oid}) {
$self->{ErrorStr} = 'Timeout';
return undef;
}
$self->{ErrorStr} = '';
return $val;
}
}
my $PDUCOUNT = ".1.3.6.1.4.1.13742.6.3.1.0";
my $OUTLETSTATE = ".1.3.6.1.4.1.13742.6.4.1.2.1.3";
my $NAMEPLATE = ".1.3.6.1.4.1.13742.6.3.2.1.1";
my $UNITCONF = ".1.3.6.1.4.1.13742.6.3.2.2.1";
my $INLET_VAL = ".1.3.6.1.4.1.13742.6.5.2.3.1.4";
my $INLET_SVAL = ".1.3.6.1.4.1.13742.6.5.2.3.1.6";
my $INLET_MIN = ".1.3.6.1.4.1.13742.6.3.3.4.1.27";
my $INLET_UNITS = ".1.3.6.1.4.1.13742.6.3.3.4.1.6";
my $INLET_DEC = ".1.3.6.1.4.1.13742.6.3.3.4.1.7";
my $OUTLET_VAL = ".1.3.6.1.4.1.13742.6.5.4.3.1.4";
my $OUTLET_SVAL = ".1.3.6.1.4.1.13742.6.5.4.3.1.6";
my $OUTLET_MIN = ".1.3.6.1.4.1.13742.6.3.5.4.1.27";
my $OUTLET_UNITS = ".1.3.6.1.4.1.13742.6.3.5.4.1.6";
my $OUTLET_DEC = ".1.3.6.1.4.1.13742.6.3.5.4.1.7";
my $SYSDESCR = ".1.3.6.1.2.1.1.1.0";
#What a PDU2 agent answers for an instance that does not exist.
my $NOSUCH = "No Such Instance currently exists at this OID";
#Readings captured from a Raritan PX4-5851-E7V2 (fw 4.2.10.5-50400), inlet 1
#and outlet 1, as [sensorType, signedMinimum, value, signedValue, units, digits].
#Reactive power is the sensor with a negative signedMinimum: the unsigned column
#answers 0 there, the signed column carries the reading. Active and apparent
#energy are the reverse: the signed column answers 0 because their range
#exceeds Integer32, so neither column can be used unconditionally.
my @PX4_INLET = (
[ 1, 0, 9503, 9503, 2, 3 ],
[ 3, 0, 11, 11, 9, 0 ],
[ 4, 0, 412, 412, 1, 0 ],
[ 5, 0, 6002, 6002, 3, 0 ],
[ 6, 0, 6194, 6194, 4, 0 ],
[ 7, 0, 97, 97, -1, 2 ],
[ 8, 0, 93653612, 0, 5, 0 ],
[ 9, 0, 97629274, 0, 6, 0 ],
[ 23, 0, 600, 600, 8, 1 ],
[ 29, -63000, 0, -1360, 23, 0 ],
);
my @PX4_OUTLET = (
[ 1, 0, 1042, 1042, 2, 3 ],
[ 5, 0, 245, 245, 3, 0 ],
[ 8, 0, 4088376, 0, 5, 0 ],
);
sub inlet_sensor {
my ($resp, $inlet, $s) = @_;
my ($st, $min, $val, $sval, $units, $digits) = @$s;
$resp->{"$INLET_MIN.1.$inlet.$st"} = $min;
$resp->{"$INLET_VAL.1.$inlet.$st"} = $val;
$resp->{"$INLET_SVAL.1.$inlet.$st"} = $sval;
$resp->{"$INLET_UNITS.1.$inlet.$st"} = $units;
$resp->{"$INLET_DEC.1.$inlet.$st"} = $digits;
return;
}
sub px4_responses {
my %resp = (
"$UNITCONF.2.1" => 1, #inletCount
"$UNITCONF.4.1" => 36, #outletCount
);
inlet_sensor(\%resp, 1, $_) foreach (@PX4_INLET);
foreach my $s (@PX4_OUTLET) {
my ($st, $min, $val, $sval, $units, $digits) = @$s;
foreach my $outlet (1 .. 2) {
$resp{"$OUTLET_MIN.1.$outlet.$st"} = $min;
$resp{"$OUTLET_VAL.1.$outlet.$st"} = $val;
$resp{"$OUTLET_SVAL.1.$outlet.$st"} = $sval;
$resp{"$OUTLET_UNITS.1.$outlet.$st"} = $units;
$resp{"$OUTLET_DEC.1.$outlet.$st"} = $digits;
}
}
return \%resp;
}
sub vitals_messages {
my ($resp, $outlets) = @_;
local @xCAT::SvrUtils::messages = ();
my $session = FakeSession->new($resp);
$session->{genpdu} = 1;
xCAT_plugin::pdu::rvitals_for_genpdu('pdu1', $outlets, $session, undef);
return ([ @xCAT::SvrUtils::messages ], $session);
}
#-- rvitals: value column selection ------------------------------------------
my ($msgs, $session) = vitals_messages(px4_responses(), 2);
ok(
(grep { $_ eq 'pdu1|inlet 1 Reactive Power: -1360 var' } @$msgs),
'a sensor with a negative signedMinimum is read from the signed column'
);
ok(
(grep { $_ eq 'pdu1|inlet 1 Active Energy: 93653612 Wh' } @$msgs),
'a sensor with signedMinimum 0 stays on the unsigned column'
);
ok(
(grep { $_ eq 'pdu1|inlet 1 RMS Current: 9.503 A' } @$msgs),
'readings are scaled by decimalDigits and labelled from sensorUnits'
);
ok(
(grep { $_ eq 'pdu1|inlet 1 Power Factor: 0.97' } @$msgs),
'a sensor reporting units none(-1) gets no unit suffix'
);
ok(
(grep { $_ eq 'pdu1|outlet 2 RMS Current: 1.042 A' } @$msgs),
'outlet sensors are read for every outlet in the count'
);
#-- rvitals: metered-only PDUs ------------------------------------------------
my %metered = ("$UNITCONF.2.1" => 1);
inlet_sensor(\%metered, 1, $PX4_INLET[0]);
foreach my $st (map { $_->[0] } @PX4_OUTLET) {
$metered{"$OUTLET_MIN.1.1.$st"} = $NOSUCH;
$metered{"$OUTLET_VAL.1.1.$st"} = $NOSUCH;
$metered{"$OUTLET_SVAL.1.1.$st"} = $NOSUCH;
}
($msgs, $session) = vitals_messages(\%metered, 48);
is(
scalar(grep { /\|outlet / } @$msgs), 0,
'a PDU with no outlet sensors reports inlet readings only'
);
cmp_ok(
scalar(grep { /^\Q$OUTLET_VAL\E\./ } @{ $session->{gets} }), '<=', 2,
'the outlet sensor loop is skipped instead of walking every outlet'
);
#-- rvitals: sensor metadata cache -------------------------------------------
my %two_inlets = ("$UNITCONF.2.1" => 2);
foreach my $col ($INLET_MIN, $INLET_VAL, $INLET_SVAL, $INLET_UNITS, $INLET_DEC) {
$two_inlets{"$col.1.1.1"} = $NOSUCH;
}
inlet_sensor(\%two_inlets, 2, $PX4_INLET[0]);
($msgs, $session) = vitals_messages(\%two_inlets, 0);
ok(
(grep { $_ eq 'pdu1|inlet 2 RMS Current: 9.503 A' } @$msgs),
'an absent sensor on one inlet does not seed the metadata cache for the next'
);
#-- rvitals: the value column is decided per entity ---------------------------
my %two_readings = ("$UNITCONF.2.1" => 2);
inlet_sensor(\%two_readings, 1, [ 29, -63000, 0, -1360, 23, 0 ]);
inlet_sensor(\%two_readings, 2, [ 29, 0, 250, 999, 23, 0 ]);
($msgs, $session) = vitals_messages(\%two_readings, 0);
ok(
(grep { $_ eq 'pdu1|inlet 1 Reactive Power: -1360 var' } @$msgs),
'the entity with a negative signed minimum reads the signed column'
);
ok(
(grep { $_ eq 'pdu1|inlet 2 Reactive Power: 250 var' } @$msgs),
'an entity with signed minimum 0 is not forced onto the signed column by another entity'
);
#-- rvitals: a failed metadata read is not a missing column -------------------
my %min_fails = ("$UNITCONF.2.1" => 1);
inlet_sensor(\%min_fails, 1, $PX4_INLET[0]);
$min_fails{"$INLET_MIN.1.1.1"} = { err => 'Timeout' };
($msgs, $session) = vitals_messages(\%min_fails, 0);
is(
scalar(grep { /RMS Current/ } @$msgs), 0,
'a sensor whose signed minimum read fails is skipped rather than guessed'
);
#-- rvitals: SNMPv1 reports an absent object as an error ----------------------
my %v1_absent = ("$UNITCONF.2.1" => 1);
inlet_sensor(\%v1_absent, 1, $PX4_INLET[0]);
$v1_absent{"$INLET_MIN.1.1.1"} = { err => 'There is no such variable name in this MIB.' };
($msgs, $session) = vitals_messages(\%v1_absent, 0);
ok(
(grep { $_ eq 'pdu1|inlet 1 RMS Current: 9.503 A' } @$msgs),
'an absent signed minimum reported as a v1 error still falls back to the unsigned column'
);
#-- rvitals: a failed unit read is not a unitless sensor ----------------------
my %units_fail = ("$UNITCONF.2.1" => 1);
inlet_sensor(\%units_fail, 1, $PX4_INLET[0]);
$units_fail{"$INLET_DEC.1.1.1"} = { err => 'Timeout' };
($msgs, $session) = vitals_messages(\%units_fail, 0);
is(
scalar(grep { /RMS Current/ } @$msgs), 0,
'a sensor whose precision read fails is skipped rather than reported unscaled'
);
#-- rvitals: firmware without the signedMinimum column -----------------------
my %no_min = ("$UNITCONF.2.1" => 1);
inlet_sensor(\%no_min, 1, $PX4_INLET[0]);
$no_min{"$INLET_MIN.1.1.1"} = $NOSUCH;
($msgs, $session) = vitals_messages(\%no_min, 0);
ok(
(grep { $_ eq 'pdu1|inlet 1 RMS Current: 9.503 A' } @$msgs),
'a sensor with no signedMinimum column falls back to the unsigned column'
);
#-- session probe -------------------------------------------------------------
sub probe {
my ($resp, $name) = @_;
local @xCAT::SvrUtils::messages = ();
my $session = FakeSession->new($resp);
my $ok = xCAT_plugin::pdu::pdu2_session_probe($session, $name || 'pdu1', undef);
return ($ok, $session, [ @xCAT::SvrUtils::messages ]);
}
my ($ok, $probed, $probe_msgs) = probe({});
ok(!$ok, 'a PDU that answers nothing at all is reported as unreachable');
is(scalar(@{ $probed->{gets} }), 1, 'an unanswered PDU is not probed a second time');
($ok, $probed, $probe_msgs) = probe({
$PDUCOUNT => $NOSUCH,
"$NAMEPLATE.3.1" => '"PX4-5851-E7V2"',
"$OUTLETSTATE.1.1" => 7,
});
ok($ok, 'a PDU that answers the nameplate but not pduCount is still usable');
is(scalar(@$probe_msgs), 0, 'a PDU with no pduCount is not warned about');
($ok, $probed, $probe_msgs) = probe({
$PDUCOUNT => $NOSUCH,
"$NAMEPLATE.3.1" => $NOSUCH,
$SYSDESCR => '"Raritan PDU, MD:PX4-5851-E7V2"',
});
ok(!$ok, 'a credential that cannot read the PDU2 tables is not a usable session');
($ok, $probed, $probe_msgs) = probe({
$PDUCOUNT => 0,
"$NAMEPLATE.3.1" => '"PX4-5851-E7V2"',
"$OUTLETSTATE.1.1" => 7,
});
ok($ok, 'a pduCount of 0 falls back to the nameplate rather than being trusted');
is(scalar(@$probe_msgs), 0, 'a pduCount of 0 does not warn about linked units');
($ok, $probed, $probe_msgs) = probe({
$PDUCOUNT => 1,
"$OUTLETSTATE.1.1" => 7,
});
ok($ok, 'a PDU that answers pduCount is usable');
is($probed->{genpdu_switchable}, 1, 'an answered outlet state marks the PDU switchable');
($ok, $probed, $probe_msgs) = probe({
$PDUCOUNT => 1,
"$OUTLETSTATE.1.1" => $NOSUCH,
});
ok($ok, 'a metered-only PDU is still usable for rinv and rvitals');
is($probed->{genpdu_switchable}, 0, 'a missing outlet state marks the PDU not switchable');
my %linked = (
$PDUCOUNT => 3,
"$OUTLETSTATE.1.1" => 7,
);
($ok, $probed, $probe_msgs) = probe(\%linked, 'linked1');
ok($ok, 'a linked PDU is not refused');
ok(
(grep { /pduCount is 3/ } @$probe_msgs),
'a pduCount other than 1 warns that only the primary unit is managed'
);
($ok, $probed, $probe_msgs) = probe(\%linked, 'linked1');
is(
scalar(@$probe_msgs), 0,
'the linked unit warning is not repeated for the same PDU'
);
#-- session arguments from the pdu table -------------------------------------
my %v3 = xCAT_plugin::pdu::pdu2_session_args('pdu1', {
snmpversion => 'v3',
snmpuser => 'admin',
authtype => 'sha',
authkey => 'authsecret',
privtype => 'aes',
});
is($v3{Version}, 3, 'snmpversion v3 selects SNMPv3');
is($v3{SecName}, 'admin', 'snmpuser becomes the v3 security name');
is($v3{SecLevel}, 'authPriv', 'a privacy protocol implies authPriv');
is($v3{AuthProto}, 'SHA', 'the auth protocol is upper cased for net-snmp');
is($v3{PrivProto}, 'AES', 'the privacy protocol is upper cased for net-snmp');
is($v3{PrivPass}, 'authsecret', 'privkey falls back to authkey when unset');
my %noauth = xCAT_plugin::pdu::pdu2_session_args('pdu1', {
snmpversion => '3',
snmpuser => 'admin',
authkey => 'authsecret',
});
is($noauth{SecLevel}, 'authNoPriv', 'no privacy protocol implies authNoPriv');
ok(!exists $noauth{PrivProto}, 'authNoPriv sends no privacy protocol');
my %v2c = xCAT_plugin::pdu::pdu2_session_args('pdu1', {
snmpversion => 'v2c',
community => 'private',
});
is($v2c{Version}, 2, 'snmpversion v2c selects SNMPv2c');
is($v2c{Community}, 'private', 'the community comes from the pdu table');
my %v1 = xCAT_plugin::pdu::pdu2_session_args('pdu1', {});
is($v1{Version}, 1, 'an unset snmpversion falls back to SNMPv1');
is($v1{Community}, 'public', 'an unset community falls back to public');
#-- outlet state translation --------------------------------------------------
sub outlet_state {
my ($val) = @_;
my $session = FakeSession->new({ "$OUTLETSTATE.1.4" => $val });
$session->{genpdu} = 1;
return xCAT_plugin::pdu::outletstat($session, 4);
}
is(outlet_state(7), 'on', 'outletSwitchingState on(7) reads as on');
is(outlet_state(8), 'off', 'outletSwitchingState off(8) reads as off');
is(outlet_state(undef), 'unknown state', 'an unanswered outlet state is unknown');
#-- rinv ----------------------------------------------------------------------
{
local @xCAT::SvrUtils::messages = ();
my $session = FakeSession->new({
"$NAMEPLATE.2.1" => '"Raritan"',
"$NAMEPLATE.3.1" => '"PX4-5851-E7V2"',
"$NAMEPLATE.4.1" => '"R2BP0123456"',
"$UNITCONF.4.1" => 36,
$SYSDESCR => '"Raritan PDU, MD:PX4-5851-E7V2 HW:0x1D FW:4.2.10.5-50400"',
});
$session->{genpdu} = 1;
xCAT_plugin::pdu::rinv_for_genpdu('pdu1', $session, undef);
my @m = @xCAT::SvrUtils::messages;
ok((grep { $_ eq 'pdu1|PDU Manufacturer: Raritan' } @m),
'nameplate strings are reported without their quotes');
ok((grep { $_ eq 'pdu1|PDU Outlet Count: 36' } @m),
'the outlet count is reported from unitConfiguration');
ok((grep { /^pdu1\|PDU Description: Raritan PDU, MD:PX4/ } @m),
'the firmware string is reported from sysDescr');
}
{
local @xCAT::SvrUtils::messages = ();
my $session = FakeSession->new({
"$NAMEPLATE.2.1" => '"Raritan"',
"$NAMEPLATE.7.1" => $NOSUCH,
});
$session->{genpdu} = 1;
xCAT_plugin::pdu::rinv_for_genpdu('pdu1', $session, undef);
is(
scalar(grep { /Rated Frequency/ } @xCAT::SvrUtils::messages), 0,
'a nameplate field the device does not have is left out of rinv'
);
is(
scalar(grep { /No Such/ } @xCAT::SvrUtils::messages), 0,
'rinv never reports an SNMP exception string as inventory'
);
}
done_testing();