2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-09-26 01:34:05 +00:00

Merge pull request #7860 from VersatusHPC/test/r26-destiny-chain-behavior

test(destiny): verify chain updates and dispatch
This commit is contained in:
Daniel Hilst
2026-09-24 22:53:18 -03:00
committed by GitHub
+228 -57
View File
@@ -3,75 +3,246 @@ use strict;
use warnings;
use FindBin;
use File::Spec;
use lib "$FindBin::Bin/../lib";
use lib "$FindBin::Bin/../../perl-xCAT";
use File::Temp qw(tempdir);
use Storable qw(dclone);
use Test::More;
use XCAT::Test::File qw(repo_path);
my $repo_root = File::Spec->catdir( $FindBin::Bin, '..', '..' );
my $plugin = File::Spec->catfile( $repo_root, 'xCAT-server/lib/xcat/plugins/destiny.pm' );
my %rows;
my %resolved_nodes;
plan skip_all => "$plugin not found" unless -r $plugin;
BEGIN {
package xCAT::Table;
sub new {
my ( $class, $table ) = @_;
die "Unexpected table $table" unless $table eq 'chain';
return bless {}, $class;
}
sub getNodesAttribs {
my ( $self, $nodes, @attributes ) = @_;
@attributes = @{ $attributes[0] } if ref($attributes[0]) eq 'ARRAY';
my %result;
for my $node (@$nodes) {
$result{$node} = exists($rows{$node})
? [ { map { $_ => $rows{$node}->{$_} } @attributes } ]
: [undef];
}
return \%result;
}
sub setNodeAttribs {
my ( $self, $node, $attributes ) = @_;
my $copy = Storable::dclone($attributes);
@{$rows{$node}}{keys %$copy} = values %$copy;
return 0;
}
$INC{'xCAT/Table.pm'} = __FILE__;
open( my $fh, '<', $plugin ) or die "Unable to read $plugin: $!";
my $source = do { local $/; <$fh> };
close($fh);
package xCAT::NodeRange;
use Exporter qw(import);
our @EXPORT = qw(noderange);
sub noderange { return @{ $resolved_nodes{$_[0]} || [] }; }
$INC{'xCAT/NodeRange.pm'} = __FILE__;
# Extract the chain-advance block out of nextdestiny() and run it directly, so
# that this exercises the shipped logic instead of a copy that can drift away
# from it. The block only touches $ref and $callnodeset, so it can be evaluated
# without a database or a running xcatd.
my ($block) = $source =~ m{
( unless \s* \(\$ref->\{currchain\}\) .*?
\#If \s we've \s gone \s off \s the \s end \s of \s the \s chain .*?
\n \s* \} \n )
}sx;
package xCAT::Utils;
sub isMN { return 0; }
$INC{'xCAT/Utils.pm'} = __FILE__;
ok( $block, 'the chain-advance block was located in nextdestiny()' )
or BAIL_OUT('destiny.pm no longer matches the expected chain-advance shape');
package xCAT::TableUtils;
sub get_site_attribute {
my ( $class, $attribute ) = @_;
die "Unexpected site attribute $attribute" unless $attribute eq 'nodestatus';
return (1);
}
$INC{'xCAT/TableUtils.pm'} = __FILE__;
sub advance {
my (%chain) = @_;
my $ref = { %chain };
my $callnodeset = 1;
my $code = "sub { my (\$ref, \$callnodeset) = \@_;\n$block\n return (\$ref, \$callnodeset); }";
my $sub = eval $code;
die "Unable to evaluate the extracted block: $@" if $@;
my ( $out, $cns ) = $sub->( $ref, $callnodeset );
return $out;
package xCAT::MsgUtils;
sub trace { return; }
$INC{'xCAT/MsgUtils.pm'} = __FILE__;
package xCAT_monitoring::monitorctrl;
$INC{'xCAT_monitoring/monitorctrl.pm'} = __FILE__;
}
# A completed provision leaves chain.currchain set to 'boot' (setdestiny does
# this once the install or netboot destiny is applied). Some installers advance
# the destiny more than once, and that extra advance must leave the node on a
# destiny that still boots it.
my $repeat = advance( currchain => 'boot', currstate => 'boot', chain => 'osimage=rhels9-x86_64-install-compute' );
is( $repeat->{currstate}, 'boot', 'advancing again from boot keeps the node booting' );
is( $repeat->{currchain}, 'boot', 'advancing again from boot leaves boot as the next destiny' );
local $ENV{XCATROOT} = tempdir( CLEANUP => 1 );
my $plugin = repo_path('xCAT-server/lib/xcat/plugins/destiny.pm');
require $plugin;
# Advancing repeatedly has to stay idempotent, not drift one state per call.
my $twice = advance( currchain => $repeat->{currchain}, currstate => $repeat->{currstate}, chain => 'osimage=rhels9-x86_64-install-compute' );
is( $twice->{currstate}, 'boot', 'a third advance still leaves the node booting' );
is( $twice->{currchain}, 'boot', 'repeated advances from boot are idempotent' );
sub capture_chain_handoffs {
my ($request) = @_;
my ( @handoffs, @subrequests, @errors, @logs );
no warnings qw(once redefine);
local %::XCATSITEVALS = ();
local *xCAT_plugin::destiny::setdestiny = sub {
my ( $outgoing, $flag ) = @_;
push @handoffs, {
request => dclone($outgoing),
flag => $flag,
chain_at_handoff => dclone($rows{$outgoing->{node}->[0]}),
};
return;
};
local *xCAT_plugin::destiny::syslog = sub {
push @logs, [@_];
return;
};
xCAT_plugin::destiny::process_request(
$request,
sub { push @errors, dclone($_[0]); },
sub {
push @subrequests, { request => dclone($_[0]), chain_at_dispatch => dclone(\%rows) };
},
);
return { handoffs => \@handoffs, subrequests => \@subrequests, errors => \@errors, logs => \@logs };
}
# An exhausted non-boot chain must still fall to standby, so that a finished
# install does not simply reinstall the node on its next boot.
my $exhausted = advance(
currchain => 'osimage=rhels9-x86_64-install-compute',
currstate => 'osimage=rhels9-x86_64-install-compute',
chain => 'osimage=rhels9-x86_64-install-compute'
my $image = 'osimage=rhels9-x86_64-install-compute';
my @cases = (
{
name => 'repeated advances from boot preserve the state without enacting again',
rows => { node1 => { currstate => 'boot', currchain => 'boot', chain => $image } },
steps => [
{ transitions => [ [ 'node1', 'boot', 'boot' ] ] },
{ transitions => [ [ 'node1', 'boot', 'boot' ] ] },
{ transitions => [ [ 'node1', 'boot', 'boot' ] ] },
],
},
{
name => 'an exhausted installation chain advances to standby',
rows => { node1 => { currstate => $image, currchain => $image, chain => $image } },
steps => [ { transitions => [ [ 'node1', 'standby', 'standby' ] ] } ],
},
{
name => 'remaining steps reach boot before repeated advances stop enacting',
rows => { node1 => { currstate => $image, currchain => "$image,boot", chain => "$image,boot" } },
steps => [
{ transitions => [ [ 'node1', $image, 'boot' ] ], enact => ['node1'] },
{ transitions => [ [ 'node1', 'boot', 'boot' ] ], enact => ['node1'] },
{ transitions => [ [ 'node1', 'boot', 'boot' ] ] },
],
},
{
name => 'an empty current chain starts from the default chain',
rows => { node1 => { currstate => '', currchain => '', chain => "$image,boot" } },
steps => [ { transitions => [ [ 'node1', $image, 'boot' ] ], enact => ['node1'] } ],
},
{
name => 'an unset current chain can start a single boot step',
rows => { node1 => { currstate => undef, currchain => undef, chain => 'boot' } },
steps => [ { transitions => [ [ 'node1', 'boot', 'boot' ] ], enact => ['node1'] } ],
},
{
name => 'a current chain takes precedence over the default chain',
rows => { node1 => { currstate => 'boot', currchain => 'shutdown,boot', chain => $image } },
steps => [ { transitions => [ [ 'node1', 'shutdown', 'boot' ] ], enact => ['node1'] } ],
},
{
name => 'semicolon-separated steps advance and retain the remaining chain',
rows => { node1 => { currstate => 'offline', currchain => 'boot;shutdown;boot', chain => $image } },
steps => [ { transitions => [ [ 'node1', 'boot', 'shutdown,boot' ] ], enact => ['node1'] } ],
},
{
name => 'a new single boot step is enacted when the previous state differs',
rows => { node1 => { currstate => 'offline', currchain => 'boot', chain => $image } },
steps => [ { transitions => [ [ 'node1', 'boot', 'boot' ] ], enact => ['node1'] } ],
},
{
name => 'the initrd option reaches both the destiny handoff and nodeset',
rows => { node1 => { currstate => 'boot', currchain => "$image:--noupdateinitrd,boot", chain => $image } },
steps => [ {
transitions => [ [ 'node1', "$image:--noupdateinitrd", 'boot', [ $image, '--noupdateinitrd' ] ] ],
enact => ['node1'], enact_args => [ 'enact', '--noupdateinitrd' ],
} ],
},
{
name => 'each requested node is updated before one aggregate nodeset request',
request => { command => ['nextdestiny'], node => [qw(node2 node1)] },
rows => {
node1 => { currstate => 'offline', currchain => 'boot', chain => $image },
node2 => { currstate => 'boot', currchain => 'shutdown,boot', chain => $image },
},
steps => [ {
transitions => [ [ 'node2', 'shutdown', 'boot' ], [ 'node1', 'boot', 'boot' ] ],
enact => [qw(node2 node1)],
} ],
},
{
name => 'an initrd option on one node reaches the aggregate nodeset request',
request => { command => ['nextdestiny'], node => [qw(node1 node2)] },
rows => {
node1 => { currstate => 'offline', currchain => 'boot', chain => $image },
node2 => { currstate => 'boot', currchain => "$image:--noupdateinitrd,boot", chain => $image },
},
steps => [ {
transitions => [ [ 'node1', 'boot', 'boot' ], [ 'node2', "$image:--noupdateinitrd", 'boot', [ $image, '--noupdateinitrd' ] ] ],
enact => [qw(node1 node2)], enact_args => [ 'enact', '--noupdateinitrd' ],
} ],
},
{
name => 'a scalar node request reaches the same chain handler',
request => { command => ['nextdestiny'], node => 'node1' },
rows => { node1 => { currstate => 'boot', currchain => 'boot', chain => $image } },
steps => [ { transitions => [ [ 'node1', 'boot', 'boot' ] ] } ],
},
{
name => 'a client request advances the resolved node',
request => { command => ['nextdestiny'], _xcat_clienthost => ['client'] },
resolved => { client => ['node1'] },
rows => { node1 => { currstate => 'boot', currchain => 'boot', chain => $image } },
steps => [ { transitions => [ [ 'node1', 'boot', 'boot' ] ] } ],
},
{
name => 'an unresolved client does not update or enact a chain',
request => { command => ['nextdestiny'], _xcat_clienthost => ['unknown'] },
rows => { node1 => { currstate => 'boot', currchain => 'boot', chain => $image } },
steps => [ { transitions => [] } ],
},
{
name => 'a missing chain row reports an error without dispatching',
rows => {},
steps => [ {
transitions => [],
logs => [ [ 'local4|err', 'ERROR: node requested destiny update, no path in chain.currchain' ] ],
} ],
},
);
is( $exhausted->{currstate}, 'standby', 'an exhausted install chain still falls to standby' );
# A chain with steps left is untouched by the guard and advances normally.
my $remaining = advance(
currchain => 'osimage=rhels9-x86_64-install-compute,boot',
currstate => 'osimage=rhels9-x86_64-install-compute',
chain => 'osimage=rhels9-x86_64-install-compute,boot'
);
is( $remaining->{currstate}, 'osimage=rhels9-x86_64-install-compute', 'a chain with steps left advances to its next step' );
is( $remaining->{currchain}, 'boot', 'a chain with steps left keeps the rest of the chain' );
# With no current chain the default chain is still copied in.
my $fresh = advance( currchain => '', currstate => '', chain => 'osimage=rhels9-x86_64-install-compute,boot' );
is( $fresh->{currstate}, 'osimage=rhels9-x86_64-install-compute', 'an empty currchain still starts from the default chain' );
for my $case (@cases) {
subtest $case->{name} => sub {
%rows = %{ dclone($case->{rows}) };
$rows{unrelated} = { currstate => 'offline', currchain => 'boot', chain => 'boot' };
%resolved_nodes = %{ $case->{resolved} || {} };
my $expected_rows = dclone(\%rows);
for my $step (@{ $case->{steps} }) {
my @expected_handoffs;
for my $transition (@{ $step->{transitions} }) {
my ( $node, $state, $remaining, $args ) = @$transition;
$expected_rows->{$node}->{currstate} = $state;
$expected_rows->{$node}->{currchain} = $remaining;
push @expected_handoffs, {
request => { node => [$node], arg => $args || [$state] },
flag => 1,
chain_at_handoff => dclone($expected_rows->{$node}),
};
}
my $request = dclone($case->{request} || { command => ['nextdestiny'], node => ['node1'] });
my $original = dclone($request);
my $result = capture_chain_handoffs($request);
my $expected_subrequests = $step->{enact}
? [ {
request => { command => ['nodeset'], node => $step->{enact}, arg => $step->{enact_args} || ['enact'] },
chain_at_dispatch => dclone($expected_rows),
} ]
: [];
is_deeply( \%rows, $expected_rows, 'nextdestiny writes the expected chain state without touching other rows' );
is_deeply( $result->{handoffs}, \@expected_handoffs, 'destiny handoffs carry the flag and follow their chain updates' );
is_deeply( $result->{subrequests}, $expected_subrequests, 'nodeset requests follow all chain updates' );
is_deeply( $result->{errors}, [], 'no callback error is emitted' );
is_deeply( $result->{logs}, $step->{logs} || [], 'syslog messages match' );
is_deeply( $request, $original, 'the caller request is preserved' );
}
};
}
done_testing();