2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-07-31 18:19:40 +00:00

test(kea): harden live Control Agent smoke

This commit is contained in:
Vinícius Ferrão
2026-07-18 14:24:30 -03:00
parent d155509345
commit 1034752080
@@ -6,7 +6,10 @@ use lib "$FindBin::Bin/../../perl-xCAT";
use File::Path qw/make_path/;
use File::Temp qw/tempdir/;
use IO::Socket::INET;
use POSIX qw/WNOHANG _exit setgid setuid/;
use Test::More;
use Time::HiRes qw/sleep time/;
use xCAT::DHCP::Backend::Kea;
@@ -21,99 +24,172 @@ plan skip_all => 'kea-dhcp4 and kea-ctrl-agent are required'
my $backend = xCAT::DHCP::Backend::Kea->new();
my $hook = $backend->host_cmds_hook_path();
plan skip_all => 'Kea host-commands hook is required' unless $hook;
my $service_account = $backend->service_account();
plan skip_all => 'Kea service account is required'
unless ref($service_account) eq 'HASH'
&& defined( $service_account->{name} )
&& defined( $service_account->{uid} )
&& defined( $service_account->{gid} );
my $tmp = tempdir(CLEANUP => 1);
make_path('/var/run/kea');
make_path('/var/lib/kea');
chmod 0755, $tmp or die "Unable to make $tmp daemon-traversable: $!";
my $socket = "/var/run/kea/kea4-xcat-smoke-$$.sock";
my $lease_file = "/var/lib/kea/kea-leases4-xcat-smoke-$$.csv";
unlink $socket, $lease_file;
my $runtime_dir = "$tmp/run";
my $data_dir = "$tmp/data";
make_path( $runtime_dir, $data_dir );
chown( $service_account->{uid}, $service_account->{gid}, $runtime_dir, $data_dir ) == 2
or die "Unable to set Kea fixture directory ownership: $!";
chmod( 0750, $runtime_dir, $data_dir ) == 2
or die "Unable to set Kea fixture directory permissions: $!";
local $ENV{KEA_CONTROL_SOCKET_DIR} = $runtime_dir;
local $ENV{KEA_DHCP_DATA_DIR} = $data_dir;
local $ENV{KEA_PIDFILE_DIR} = $runtime_dir;
local $ENV{KEA_LOCKFILE_DIR} = $runtime_dir;
$backend = xCAT::DHCP::Backend::Kea->new(kea_socket_dir => $runtime_dir);
my $port_guard = IO::Socket::INET->new(
LocalAddr => '127.0.0.1',
LocalPort => 0,
Listen => 1,
Proto => 'tcp',
) or die "Unable to reserve a Control Agent port: $!";
my $control_agent_port = $port_guard->sockport();
my $socket = $backend->control_socket_path('kea4-ctrl-socket');
my $lease_file = "$data_dir/kea-leases4.csv";
my $dhcp_config = "$tmp/kea-dhcp4.conf";
my $ctrl_config = "$tmp/kea-ctrl-agent.conf";
write_file(
$dhcp_config,
$backend->render_dhcp4_config(
my $dhcp_settings = {
interfaces => [],
'lease-database' => {
type => 'memfile',
name => $lease_file,
},
'control-socket' => {
'socket-type' => 'unix',
'socket-name' => $socket,
},
subnets => [
{
interfaces => ['lo'],
'lease-database' => {
type => 'memfile',
name => $lease_file,
},
'control-socket' => {
'socket-type' => 'unix',
'socket-name' => $socket,
},
'hooks-libraries' => [ { library => $hook } ],
subnets => [
{
id => 1,
subnet => '127.0.0.0/8',
pools => [],
},
],
}
)
);
write_file(
$ctrl_config,
$backend->render_ctrl_agent_config(
{
'http-port' => 18000,
'dhcp4-socket' => $socket,
}
)
);
id => 1,
subnet => '127.0.0.0/8',
pools => [],
},
],
};
$dhcp_settings->{'hooks-libraries'} = [ { library => $hook } ] if $hook;
my $dhcp_validation = $backend->validate_dhcp4_config($dhcp_config);
ok( !$dhcp_validation->{error}, 'live smoke DHCPv4 config validates' )
or diag $dhcp_validation->{error};
my $ctrl_validation = $backend->validate_ctrl_agent_config($ctrl_config);
ok( !$ctrl_validation->{error}, 'live smoke Control Agent config validates' )
or diag $ctrl_validation->{error};
my $dhcp_write = $backend->write_dhcp4_config(
$dhcp_settings,
path => $dhcp_config,
);
ok( !$dhcp_write->{error}, 'live smoke DHCPv4 config validates and writes' )
or diag $dhcp_write->{error};
my @pids;
END {
kill 'TERM', @pids if @pids;
unlink grep { defined($_) && $_ ne '' } ( $socket, $lease_file );
my $ctrl_write = $backend->write_ctrl_agent_config(
{
'http-port' => $control_agent_port,
},
path => $ctrl_config,
);
ok( !$ctrl_write->{error}, 'live smoke Control Agent config validates and writes' )
or diag $ctrl_write->{error};
unless ( !$dhcp_write->{error} && !$ctrl_write->{error} ) {
done_testing();
exit 1;
}
push @pids, start_daemon($kea_dhcp4, '-c', $dhcp_config, '-d', "$tmp/kea-dhcp4.log");
ok( wait_for_process($pids[-1]), 'kea-dhcp4 stays running for smoke test' );
push @pids, start_daemon($kea_ctrl, '-c', $ctrl_config, '-d', "$tmp/kea-ctrl-agent.log");
ok( wait_for_process($pids[-1]), 'kea-ctrl-agent stays running for smoke test' );
my %children;
END { stop_daemons(\%children); }
my $live_backend = xCAT::DHCP::Backend::Kea->new(control_agent_port => 18000);
my $add = $live_backend->live_upsert_reservations(
[
{
'subnet-id' => 1,
'hw-address' => '52:54:00:12:34:56',
'ip-address' => '127.0.0.50',
hostname => 'node-smoke',
},
],
service => ['dhcp4']
);
ok( !$add->{error}, 'reservation-add succeeds through Kea Control Agent' )
or diag $add->{error};
my $dhcp_log = "$tmp/kea-dhcp4.log";
my $dhcp_pid = start_daemon( $service_account, $kea_dhcp4, $dhcp_log, '-c', $dhcp_config, '-d' );
$children{$dhcp_pid} = 1;
my $dhcp_ready = wait_for_socket( $dhcp_pid, $socket, \%children );
ok( $dhcp_ready, 'kea-dhcp4 creates its Control Agent socket' );
diag_file($dhcp_log) unless $dhcp_ready;
unless ($dhcp_ready) {
done_testing();
exit 1;
}
is( ( stat $socket )[4], $service_account->{uid}, 'kea-dhcp4 socket belongs to the service account' );
my $delete = $live_backend->live_delete_reservations(
[
{
'subnet-id' => 1,
'hw-address' => '52:54:00:12:34:56',
'ip-address' => '127.0.0.50',
hostname => 'node-smoke',
},
],
service => ['dhcp4']
);
ok( !$delete->{error}, 'reservation-del succeeds through Kea Control Agent' )
or diag $delete->{error};
my $lease_ready = wait_for_file( $dhcp_pid, $lease_file, \%children );
ok( $lease_ready, 'kea-dhcp4 creates its lease file' );
is( ( stat $lease_file )[4], $service_account->{uid}, 'kea-dhcp4 lease file belongs to the service account' )
if $lease_ready;
close($port_guard) or die "Unable to release the reserved Control Agent port: $!";
my $ctrl_log = "$tmp/kea-ctrl-agent.log";
my $ctrl_pid = start_daemon( $service_account, $kea_ctrl, $ctrl_log, '-c', $ctrl_config, '-d' );
$children{$ctrl_pid} = 1;
my $live_backend = xCAT::DHCP::Backend::Kea->new(control_agent_port => $control_agent_port);
my ( $ctrl_ready, $readiness ) = wait_for_control_agent( $live_backend, $ctrl_pid, \%children );
ok( $ctrl_ready, 'Kea Control Agent forwards commands to kea-dhcp4' );
diag( $readiness->{error} || $readiness->{text} || 'Control Agent returned no response' ) unless $ctrl_ready;
diag_file($ctrl_log) unless $ctrl_ready;
unless ($ctrl_ready) {
done_testing();
exit 1;
}
SKIP: {
skip 'Kea host-commands hook is unavailable', 4 unless $hook;
my $reservation = {
'subnet-id' => 1,
'hw-address' => '52:54:00:12:34:56',
'ip-address' => '127.0.0.50',
hostname => 'node-smoke',
};
my $add = $live_backend->live_upsert_reservations(
[$reservation],
service => ['dhcp4']
);
ok( !$add->{error}, 'reservation-add succeeds through Kea Control Agent' )
or diag $add->{error};
my $lookup = {
'subnet-id' => $reservation->{'subnet-id'},
'identifier-type' => 'hw-address',
identifier => $reservation->{'hw-address'},
};
my $found = $live_backend->control_agent_command( 'reservation-get', $lookup, service => ['dhcp4'] );
my $stored = response_arguments($found);
ok(
ref($stored) eq 'HASH'
&& defined( $stored->{'ip-address'} )
&& $stored->{'ip-address'} eq $reservation->{'ip-address'},
'reservation-get confirms the added reservation is stored'
) or diag( $found->{error} || $found->{text} || 'reservation-get returned no reservation' );
my $delete = $live_backend->live_delete_reservations(
[$reservation],
service => ['dhcp4']
);
ok( !$delete->{error}, 'reservation-del succeeds through Kea Control Agent' )
or diag $delete->{error};
my $missing = $live_backend->control_agent_command( 'reservation-get', $lookup, service => ['dhcp4'] );
my $missing_arguments = response_arguments($missing);
my $expected_not_found = !$missing->{error}
|| ( defined( $missing->{result} ) && $missing->{result} == 3 )
|| ( defined( $missing->{text} ) && $missing->{text} =~ /not\s+found/i );
ok(
defined( $missing->{response} )
&& $expected_not_found
&& ( ref($missing_arguments) ne 'HASH' || !keys %$missing_arguments ),
'reservation-get confirms the deleted reservation is absent'
) or diag( $missing->{error} || $missing->{text} || 'reservation-get returned an unexpected response' );
}
stop_daemons(\%children);
done_testing();
sub command_path {
@@ -131,38 +207,135 @@ sub command_path {
return;
}
sub write_file {
my ( $path, $content ) = @_;
open(my $fh, '>', $path) or die "Unable to write $path: $!";
print $fh $content;
close($fh) or die "Unable to close $path: $!";
return 1;
}
sub start_daemon {
my ( $command, @args ) = @_;
my $log = pop @args;
my ( $account, $command, $log, @args ) = @_;
my $pid = fork();
die "Unable to fork $command: $!" unless defined $pid;
if ($pid == 0) {
open(STDOUT, '>', $log) or die "Unable to write $log: $!";
open(STDERR, '>&', \*STDOUT) or die "Unable to redirect stderr: $!";
exec $command, @args;
die "Unable to exec $command: $!";
open(STDOUT, '>', $log) or child_exit("Unable to write $log: $!");
open(STDERR, '>&', \*STDOUT) or child_exit("Unable to redirect stderr: $!");
$) = "$account->{gid} $account->{gid}";
defined( setgid( $account->{gid} ) )
or child_exit("Unable to set group identity to $account->{gid}: $!");
my @group_ids = split /\s+/, $);
$( == $account->{gid} && @group_ids && !grep { $_ != $account->{gid} } @group_ids
or child_exit("Kea child did not assume group identity $account->{gid}");
defined( setuid( $account->{uid} ) )
or child_exit("Unable to set user identity to $account->{uid}: $!");
$> == $account->{uid} && $< == $account->{uid}
or child_exit("Kea child did not assume user identity $account->{uid}");
{
no warnings 'exec';
exec { $command } $command, @args;
child_exit("Unable to exec $command: $!");
}
}
return $pid;
}
sub wait_for_process {
my ($pid) = @_;
sub child_exit {
my ($message) = @_;
for (1 .. 10) {
sleep 1;
return 0 unless kill 0, $pid;
return 1 if $_ >= 2;
warn "$message\n";
_exit(127);
}
sub wait_for_socket {
my ( $pid, $socket_path, $children ) = @_;
for (1 .. 100) {
return 0 unless process_running( $pid, $children );
return 1 if -S $socket_path;
sleep 0.1;
}
return kill 0, $pid;
return 0;
}
sub wait_for_file {
my ( $pid, $path, $children ) = @_;
for (1 .. 100) {
return 0 unless process_running( $pid, $children );
return 1 if -f $path;
sleep 0.1;
}
return 0;
}
sub wait_for_control_agent {
my ( $backend, $pid, $children ) = @_;
my $last_result = {};
my $deadline = time + 10;
while ( time < $deadline ) {
return ( 0, $last_result ) unless process_running( $pid, $children );
$last_result = $backend->control_agent_command(
'list-commands',
{},
service => ['dhcp4'],
timeout => 1,
);
return ( 1, $last_result ) unless $last_result->{error};
my $remaining = $deadline - time;
sleep( $remaining < 0.1 ? $remaining : 0.1 ) if $remaining > 0;
}
return ( 0, $last_result );
}
sub process_running {
my ( $pid, $children ) = @_;
my $waited = waitpid( $pid, WNOHANG );
return 1 if $waited == 0;
delete $children->{$pid};
return 0;
}
sub stop_daemons {
my ($children) = @_;
my @pids = keys %$children;
kill 'TERM', @pids if @pids;
foreach my $pid (@pids) {
for (1 .. 50) {
last unless process_running( $pid, $children );
sleep 0.1;
}
next unless exists $children->{$pid};
kill 'KILL', $pid;
waitpid( $pid, 0 );
delete $children->{$pid};
}
return;
}
sub response_arguments {
my ($result) = @_;
return unless ref($result) eq 'HASH';
my $response = $result->{response};
my $item = ref($response) eq 'ARRAY' ? $response->[0] : $response;
return unless ref($item) eq 'HASH';
return $item->{arguments} if ref( $item->{arguments} ) eq 'HASH';
return;
}
sub diag_file {
my ($path) = @_;
return unless -e $path;
open( my $fh, '<', $path ) or return;
local $/;
my $content = <$fh>;
close($fh) or diag("Unable to close $path: $!");
diag($content) if defined($content) && $content ne '';
return;
}