2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-10-21 07:08:12 +00:00

test(packaging): exercise Debian service lifecycle

This commit is contained in:
Vinícius Ferrão
2026-10-09 03:22:46 -03:00
parent 2840a965a6
commit e83ddfe0d9
5 changed files with 545 additions and 441 deletions
+6
View File
@@ -12,7 +12,13 @@ jobs:
run: sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-install-suggests bats bubblewrap rpm rsyslog build-essential fakeroot reprepro devscripts debhelper libcapture-tiny-perl libfile-slurper-perl libjson-perl libyaml-pp-perl libparallel-forkmanager-perl libsoap-lite-perl libdbi-perl libcgi-pm-perl quilt openssh-server dpkg looptools genometools software-properties-common grub-common grub-efi-amd64-bin
- name: Enable postscript test namespaces
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Install lifecycle test dependencies
run: sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-install-suggests ucf debconf-utils systemd
- name: Test Debian package lifecycle
run: prove -v xCAT-test/native/xcatd_debian_lifecycle.t
- name: Prepare xCAT syslog directory
if: ${{ !cancelled() }}
run: sudo install -d -o syslog -g adm -m 0750 /var/log/xcat
- name: Run tests
if: ${{ !cancelled() }}
run: perl github_action_xcat_test.pl
+200
View File
@@ -0,0 +1,200 @@
package XCAT::Test::Lifecycle;
use strict;
use warnings;
use Capture::Tiny qw(capture);
use Cwd qw(getcwd);
use File::Basename qw(dirname basename);
use File::Copy qw(copy);
use File::Path qw(make_path);
use File::Slurper qw(read_binary write_binary);
use File::Temp qw(tempdir);
use Text::ParseWords qw(shellwords);
use XCAT::Test::File qw(repo_path);
sub command {
my (@argv) = @_;
my ($out, $err, $status) = capture { system(@argv) };
$status = $status == -1 ? 255 : ($status & 127) ? 128 + ($status & 127) : $status >> 8;
return ($status, $out, $err);
}
sub checked {
my (@argv) = @_;
my ($status, $out, $err) = command(@argv);
die "@argv failed ($status):\n$out$err" if $status;
return $out;
}
sub build_deb {
my ($package) = @_;
my $work = tempdir(CLEANUP => 1);
checked('tar', '-cf', "$work/source.tar", '-C', repo_path('.'), $package);
checked('tar', '-xf', "$work/source.tar", '-C', $work);
my $previous = getcwd();
chdir "$work/$package" or die "chdir: $!";
local $ENV{PWD} = getcwd();
my ($status, $out, $err) = command('dpkg-buildpackage', '-b', '-uc', '-us', '-d');
chdir $previous or die "chdir: $!";
die "Building $package failed ($status):\n$out$err" if $status;
my @debs = glob "$work/*.deb";
die "Expected one $package DEB, found @debs" unless @debs == 1;
return $debs[0];
}
sub new {
my ($class) = @_;
my $root = tempdir(CLEANUP => 1);
my $self = bless {root => $root}, $class;
make_path(map { "$root/$_" } qw(etc opt var tmp run install root test-bin
usr/sbin usr/lib/systemd/system usr/share/doc proc dev
var/lib/dpkg/updates var/lib/dpkg/info var/lib/ucf var/cache/debconf
etc/init.d etc/profile.d etc/systemd/system etc/alternatives));
$self->write('/var/lib/dpkg/status', '');
$self->write('/etc/passwd', "root:x:0:0:root:/root:/bin/sh\nwww-data:x:33:33:www-data:/var/www:/usr/sbin/nologin\n");
$self->write('/etc/group', "root:x:0:\nwww-data:x:33:\n");
$self->write('/etc/profile.d/xcat.sh', "export XCATROOT=/opt/xcat\nexport PATH=/test-bin:/opt/xcat/sbin:/usr/sbin:/usr/bin:/sbin:/bin\n");
copy('/etc/ld.so.cache', "$root/etc/ld.so.cache") or die "copy ld.so.cache: $!" if -f '/etc/ld.so.cache';
copy('/etc/debconf.conf', "$root/etc/debconf.conf") or die "copy debconf.conf: $!" if -f '/etc/debconf.conf';
if (-f '/etc/debian_version') {
my @packages = qw(base-files ucf debconf systemd init-system-helpers);
$self->write('/var/lib/dpkg/status', checked('dpkg-query', '-s', @packages));
for my $record ((map { "/var/lib/dpkg/info/$_.list" } @packages), '/var/lib/dpkg/info/ucf.templates') {
copy($record, "$root/var/lib/dpkg/info/" . basename($record)) or die "copy $record: $!";
}
}
for my $dir ('', '/sbin', '/lib', '/lib/systemd', '/share') {
for my $entry (glob "/usr$dir/*") {
my $name = basename($entry);
next if $dir eq '/sbin' && $name eq 'init';
next if -d "$root/usr$dir/$name";
symlink("/host-usr$dir/$name", "$root/usr$dir/$name") or die "symlink: $!";
}
}
for my $entry (glob '/etc/alternatives/*') {
next unless -l $entry;
symlink(readlink($entry), "$root/etc/alternatives/" . basename($entry)) or die "symlink: $!";
}
for my $dir (qw(bin sbin lib lib64)) {
next unless -l "/$dir";
symlink(readlink("/$dir"), "$root/$dir") or die "symlink: $!";
}
$self->private_bin('/sbin') if -d '/sbin' && !-l '/sbin';
if (-f "$root/var/lib/dpkg/info/ucf.templates") {
my ($status, $out, $err) = $self->run('debconf-loadtemplate', 'ucf', '/var/lib/dpkg/info/ucf.templates');
die "Loading ucf templates failed: $out$err" if $status;
}
return $self;
}
sub path { return $_[0]->{root} . $_[1]; }
sub write {
my ($self, $path, $text, $mode) = @_;
my $destination = $self->path($path);
make_path(dirname($destination));
write_binary($destination, $text);
chmod($mode, $destination) == 1 or die "chmod $destination: $!" if defined $mode;
}
sub read { return read_binary($_[0]->path($_[1])); }
sub private_bin {
my ($self, $dir) = @_;
my $target = $self->path($dir);
unlink $target or die "unlink $target: $!" if -l $target;
make_path($target);
my $host = $dir =~ m{^/usr/} ? "/host-usr" . substr($dir, 4) : "/host" . $dir;
for my $entry (glob "$dir/*") {
my $name = basename($entry);
next if $name eq 'init';
next if -e "$target/$name" || -l "$target/$name";
symlink("$host/$name", "$target/$name") or die "symlink: $!";
}
}
sub hide_command {
my ($self, $name) = @_;
for my $dir (qw(/usr/bin /usr/sbin /bin /sbin)) {
next if -l $dir;
$self->private_bin($dir);
my $entry = $self->path("$dir/$name");
unlink $entry or die "unlink $entry: $!" if -e $entry || -l $entry;
}
}
sub start_unit {
my ($self, $path) = @_;
my ($section, @commands);
for my $line (split /\n/, $self->read($path)) {
if ($line =~ /^\s*\[([^]]+)\]\s*$/) { $section = $1; next; }
next unless ($section // '') eq 'Service' && $line =~ /^\s*ExecStart\s*=\s*(.*)$/;
my $value = $1;
if (length $value) { push @commands, $value; } else { @commands = (); }
}
die 'Expected one service start command' unless @commands == 1;
return $self->run(shellwords($commands[0]));
}
sub run {
my ($self, @argv) = @_;
my $root = $self->{root};
my $prefix = $self->{chroot} ? '/image' : '';
my @mounts;
if ($self->{chroot}) {
push @mounts, '--tmpfs', '/', '--ro-bind', '/usr', '/usr';
for my $dir (qw(bin sbin lib lib64)) {
next unless -e "/$dir";
push @mounts, -l "/$dir" ? ('--symlink', readlink("/$dir"), "/$dir")
: ('--ro-bind', "/$dir", "/$dir");
}
}
push @mounts, ('--bind', $root, "$prefix/", '--ro-bind', '/usr', "$prefix/host-usr",
'--ro-bind', repo_path('.'), "$prefix/source");
for my $dir (qw(bin sbin lib lib64)) {
next unless -e "/$dir";
next if -l "$root/$dir";
push @mounts, -l "/$dir" ? ('--symlink', readlink("/$dir"), "$prefix/$dir")
: ('--ro-bind', "/$dir", "$prefix/" . (-d "$root/$dir" ? "host/$dir" : $dir));
}
push @mounts, ('--ro-bind', $self->{deb}, "$prefix/package.deb") if $self->{deb};
push @mounts, ('--proc', "$prefix/proc") if $self->{live};
push @mounts, ('--cap-add', 'CAP_SYS_CHROOT') if $self->{chroot};
unshift @argv, 'chroot', $prefix if $self->{chroot};
return command('/usr/bin/env', '-i', 'PATH=/usr/bin:/bin',
'bwrap', '--die-with-parent', '--unshare-all', '--uid', $self->{uid} // 0,
'--gid', $self->{gid} // 0,
@mounts, '--dev', "$prefix/dev", '--setenv', 'PATH',
'/test-bin:/opt/xcat/sbin:/usr/sbin:/usr/bin:/sbin:/bin',
'--setenv', 'HOME', '/root', '--setenv', 'LC_ALL', 'C',
'--setenv', 'DEBIAN_FRONTEND', 'noninteractive', '--chdir', '/', @argv);
}
sub record_command {
my ($self, $path, $delegate) = @_;
my $name = basename($path);
my $forward = defined $delegate ? "exec \"$delegate\" \"\$@\"\n" : '';
$self->write($path, "#!/bin/sh\nprintf '$name' >> /calls\n" .
'for arg do printf " <%s>" "$arg" >> /calls; done' .
"\nprintf '\\n' >> /calls\n$forward", 0755);
}
sub install_deb {
my ($self, $deb) = @_;
local $self->{deb} = $deb;
return $self->run('dpkg', '--force-depends', '--force-confold', '-i', '/package.deb');
}
sub deb_script {
my ($self, $deb, $script, @args) = @_;
local $self->{deb} = $deb;
my @unpack = $self->run('dpkg-deb', '-e', '/package.deb', '/tmp/control');
return @unpack if $unpack[0];
my $package = checked('dpkg-deb', '-f', $deb, 'Package');
chomp $package;
return $self->run('env', "DPKG_MAINTSCRIPT_PACKAGE=$package",
"DPKG_MAINTSCRIPT_NAME=$script", 'DPKG_MAINTSCRIPT_ARCH=all',
'/bin/sh', "/tmp/control/$script", @args);
}
1;
+339
View File
@@ -0,0 +1,339 @@
#!/usr/bin/env perl
use strict;
use warnings;
use FindBin;
use File::Path qw(make_path);
use File::Temp qw(tempdir);
use File::Slurper qw(write_binary);
use Digest::MD5 qw(md5_hex);
use lib "$FindBin::Bin/../lib";
use Test::More;
use XCAT::Test::Lifecycle;
use XCAT::Test::File qw(slurp_repo_file);
plan skip_all => 'Run on Linux with dpkg-buildpackage; other missing prerequisites fail'
unless $^O eq 'linux' && -x '/usr/bin/dpkg-buildpackage';
my $deb = XCAT::Test::Lifecycle::build_deb('xCAT-server');
my $version = XCAT::Test::Lifecycle::checked('dpkg-deb', '-f', $deb, 'Version');
chomp $version;
my $template = slurp_repo_file('xCAT-server/etc/init.d/xcatd');
my $depends = XCAT::Test::Lifecycle::checked('dpkg-deb', '-f', $deb, 'Depends');
my @dependencies = split /\s*,\s*/, $depends;
ok(grep(/^ucf(?:\s*\([^)]+\))?\s*$/, @dependencies), 'built package requires ucf');
sub succeeds {
my ($label, @result) = @_;
is($result[0], 0, $label) or diag($result[1] . $result[2]);
}
for my $mode (qw(systemd legacy explicit-legacy unknown)) {
subtest "$mode package lifecycle" => sub {
my $root = XCAT::Test::Lifecycle->new;
$root->write('/etc/os-release', $mode eq 'systemd' ? "ID=ubuntu\nVERSION_ID=\"24.04\"\n"
: $mode eq 'legacy' ? "ID=ubuntu\nVERSION_ID=\"14.04\"\n" : "ID=fixture\n");
$root->write('/sbin/init', '', 0755) if $mode eq 'explicit-legacy';
my $modern = $mode eq 'systemd' || $mode eq 'unknown';
succeeds('fresh package transaction', $root->install_deb($deb));
my %checksums = map { reverse split /\s+/, $_ } split /\n/,
$root->read('/opt/xcat/share/xcat/scripts/xcatd.md5sum');
is($checksums{'2.17.0'}, '0b1eea60994ff79faa9a8d0bcd53c558', 'package recognizes the pre-transition template');
is($checksums{'2.18.0'}, '8797eef6731719e0eacff59612b3e916', 'package recognizes the 2.18.0 template');
ok(grep($_ eq md5_hex($template), values %checksums), 'package recognizes its current legacy template');
my $init = $root->path('/etc/init.d/xcatd');
if ($modern) {
ok(!-e $init && !-l $init, 'systemd target has no legacy script');
ok(-l $root->path('/etc/systemd/system/multi-user.target.wants/xcatd.service'), 'fresh service is enabled');
$root->record_command('/opt/xcat/sbin/xcatd');
succeeds('execute installed unit start command', $root->start_unit('/usr/lib/systemd/system/xcatd.service'));
is($root->read('/calls'), "xcatd <-p> </run/xcatd.pid>\n", 'unit starts the daemon without a legacy script');
succeeds('disable service', $root->run('systemctl', 'disable', 'xcatd.service'));
} else {
is($root->read('/etc/init.d/xcatd'), $template, 'legacy template installed');
ok(-x $init, 'legacy script is executable');
succeeds('disable legacy service', $root->run('update-rc.d', 'xcatd', 'disable'));
$root->write('/etc/init.d/xcatd', "$template\n# local configuration\n", 0755);
}
succeeds('upgrade transaction', $root->install_deb($deb));
if ($modern) {
ok(!-l $root->path('/etc/systemd/system/multi-user.target.wants/xcatd.service'), 'upgrade keeps disabled state');
ok(!-e $root->path('/var/lib/xcat/xcatd-init-state/context'), 'reconfiguration has no preinst context');
succeeds('reconfigure the installed package', $root->deb_script($deb, 'postinst', 'configure', $version));
ok(!-l $root->path('/etc/systemd/system/multi-user.target.wants/xcatd.service'), 'reconfiguration preserves administrator disablement');
succeeds('mask service', $root->run('systemctl', 'mask', 'xcatd.service'));
succeeds('upgrade masked service', $root->install_deb($deb));
is(readlink($root->path('/etc/systemd/system/xcatd.service')), '/dev/null', 'upgrade keeps mask');
} else {
is($root->read('/etc/init.d/xcatd'), "$template\n# local configuration\n", 'upgrade keeps administrator edits');
my @enabled = glob $root->path('/etc/rc[2345].d/S*xcatd');
is(scalar @enabled, 0, 'upgrade keeps disabled runlevels');
my @disabled = glob $root->path('/etc/rc[2345].d/K*xcatd');
ok(@disabled, 'upgrade retains disabled registration');
}
ok(!-e $root->path('/var/lib/xcat/xcatd-init-state/context'), 'successful configuration clears transaction context');
if ($modern) {
succeeds('unmask before removal', $root->run('systemctl', 'unmask', 'xcatd.service'));
succeeds('enable before removal', $root->run('systemctl', 'enable', 'xcatd.service'));
ok(-l $root->path('/etc/systemd/system/multi-user.target.wants/xcatd.service'), 'removal starts with an enabled service');
}
succeeds('remove package', $root->run('dpkg', '--force-depends', '--remove', 'xcat-server'));
ok(!-e $root->path('/usr/sbin/xcatd') && !-l $root->path('/usr/sbin/xcatd'), 'remove clears daemon link');
ok(!-l $root->path('/etc/systemd/system/multi-user.target.wants/xcatd.service'), 'remove clears systemd registration');
my @registration = glob $root->path('/etc/rc?.d/[SK]??xcatd');
is_deeply(\@registration, [], 'remove clears legacy registration');
for my $suffix (qw(ucf-old ucf-new ucf-dist dpkg-bak dpkg-backup dpkg-remove)) {
$root->write("/etc/init.d/xcatd.$suffix", 'backup fixture');
}
$root->write('/tmp/ucf-template', 'registered fixture');
succeeds('register a ucf checksum', $root->run('ucf', '/tmp/ucf-template', '/etc/init.d/xcatd'));
succeeds('register ucf ownership', $root->run('ucfr', 'xcat-server', '/etc/init.d/xcatd'));
succeeds('purge package', $root->run('dpkg', '--purge', 'xcat-server'));
ok(!-e $init && !-l $init, 'purge clears legacy configuration');
ok(!-e $root->path('/var/lib/xcat/xcatd-init-state/state'), 'purge clears durable transition state');
my @backups = glob $root->path('/etc/init.d/xcatd.*');
is_deeply(\@backups, [], 'purge removes conffile backup artifacts');
for my $file (qw(hashfile registry)) {
my $contents = -f $root->path("/var/lib/ucf/$file") ? $root->read("/var/lib/ucf/$file") : '';
unlike($contents, qr{(?:^|\s)/etc/init\.d/xcatd(?:\s|$)}, "purge removes ucf $file entry");
}
};
}
subtest 'legacy conffile migration through dpkg' => sub {
my $package = tempdir(CLEANUP => 1);
make_path("$package/old/DEBIAN", "$package/old/etc/init.d");
write_binary("$package/old/DEBIAN/control", "Package: xcat-server\nVersion: 2.0\nArchitecture: all\nMaintainer: xCAT <xcat-user\@lists.sourceforge.net>\nDescription: legacy init conffile fixture\n");
write_binary("$package/old/DEBIAN/conffiles", "/etc/init.d/xcatd\n");
write_binary("$package/old/etc/init.d/xcatd", $template);
chmod 0755, "$package/old/etc/init.d/xcatd" or die "chmod: $!";
XCAT::Test::Lifecycle::checked('dpkg-deb', '--root-owner-group', '--build', "$package/old", "$package/old.deb");
for my $state (qw(enabled disabled unregistered deleted)) {
subtest $state => sub {
my $root = XCAT::Test::Lifecycle->new;
$root->write('/etc/os-release', "ID=ubuntu\nVERSION_ID=24.04\n");
succeeds('install old conffile package', $root->install_deb("$package/old.deb"));
if ($state eq 'deleted') {
unlink $root->path('/etc/init.d/xcatd') or die "unlink: $!";
} else {
$root->write('/etc/init.d/xcatd', "$template\n# administrator edit\n", 0750);
}
if ($state eq 'enabled' || $state eq 'disabled') {
make_path($root->path('/etc/rc3.d'));
symlink '../init.d/xcatd', $root->path('/etc/rc3.d/' . ($state eq 'enabled' ? 'S' : 'K') . '85xcatd') or die "symlink: $!";
}
if ($state eq 'disabled' || $state eq 'unregistered') {
$root->record_command('/usr/sbin/systemctl', '/host-usr/bin/systemctl');
}
succeeds('upgrade to native service package', $root->install_deb($deb));
ok(!-e $root->path('/etc/init.d/xcatd'), 'modern target removes active legacy script');
my $enabled = -l $root->path('/etc/systemd/system/multi-user.target.wants/xcatd.service') ? 1 : 0;
is($enabled, $state eq 'enabled' ? 1 : 0, 'migration preserves prior registration');
like($root->read('/calls'), qr/^systemctl <disable> <xcatd\.service>$/m,
'legacy migration requests disable through the real service tool') if $state eq 'disabled' || $state eq 'unregistered';
if ($state ne 'deleted') {
is($root->read('/var/lib/xcat/xcatd-init-state/xcatd'), "$template\n# administrator edit\n", 'migration stashes edited bytes');
}
$root->write('/etc/os-release', "ID=ubuntu\nVERSION_ID=14.04\n");
succeeds('return to legacy target', $root->install_deb($deb));
if ($state eq 'deleted') {
ok(!-e $root->path('/etc/init.d/xcatd'), 'administrator deletion survives return');
} else {
is($root->read('/etc/init.d/xcatd'), "$template\n# administrator edit\n", 'return restores administrator bytes');
is((stat($root->path('/etc/init.d/xcatd')))[2] & 07777, 0750, 'return restores mode');
}
};
}
};
for my $action (qw(abort-install abort-upgrade remove purge upgrade failed-upgrade disappear)) {
subtest "postrm $action recovery" => sub {
my $root = XCAT::Test::Lifecycle->new;
$root->write('/etc/os-release', "ID=ubuntu\nVERSION_ID=14.04\n");
my $state = '/var/lib/xcat/xcatd-init-state';
for my $file (qw(state xcatd context context.tmp.123 pending-xcatd pending-deleted pending-enabled pending-xcatd.tmp.123)) {
$root->write("$state/$file", "retained $file\n");
}
$root->write('/var/lib/xcat/xcatd-systemd-mode', 'retained marker');
succeeds('complete maintainer script', $root->deb_script($deb, 'postrm', $action));
my $retained = $action eq 'upgrade' || $action eq 'failed-upgrade' ? 1 : 0;
for my $file (qw(context context.tmp.123)) {
is(-e $root->path("$state/$file") ? 1 : 0, $retained, "$action $file retention");
}
is(-e $root->path('/var/lib/xcat/xcatd-systemd-mode') ? 1 : 0,
$action eq 'purge' ? 0 : 1, 'systemd marker retention');
for my $file (qw(state xcatd)) {
is(-e $root->path("$state/$file") ? 1 : 0, $action eq 'purge' ? 0 : 1, "$file retention");
}
for my $file (qw(pending-xcatd pending-deleted pending-enabled pending-xcatd.tmp.123)) {
is(-e $root->path("$state/$file") ? 1 : 0, $action eq 'purge' || $action eq 'abort-install' ? 0 : 1, "$file retention");
}
};
}
subtest 'abort-install removes an empty state directory' => sub {
my $root = XCAT::Test::Lifecycle->new;
$root->write('/etc/os-release', "ID=ubuntu\nVERSION_ID=14.04\n");
my $state = '/var/lib/xcat/xcatd-init-state';
for my $file (qw(context context.tmp.123 pending-xcatd pending-enabled pending-deleted pending-xcatd.tmp.123)) {
$root->write("$state/$file", 'aborted attempt');
}
succeeds('abort before durable state exists', $root->deb_script($deb, 'postrm', 'abort-install'));
ok(!-e $root->path($state), 'abort-install removes the empty state directory');
};
subtest 'preinst failure preserves durable state' => sub {
my $root = XCAT::Test::Lifecycle->new;
$root->write('/etc/os-release', "ID=ubuntu\nVERSION_ID=14.04\n");
$root->write('/var/lib/xcat/xcatd-init-state/state', 'durable state');
$root->write('/test-bin/mv', "#!/bin/sh\nexit 17\n", 0755);
my ($status, $out, $err) = $root->deb_script($deb, 'preinst', 'install');
isnt($status, 0, 'context rename failure fails the transaction');
is($root->read('/var/lib/xcat/xcatd-init-state/state'), 'durable state', 'failure retains prior durable state');
my @temporary = glob $root->path('/var/lib/xcat/xcatd-init-state/context.tmp.*');
is(scalar @temporary, 0, 'failure removes context temporary');
};
for my $command (qw(cp mv)) {
subtest "preinst cleans up after $command failure" => sub {
my $root = XCAT::Test::Lifecycle->new;
$root->write('/etc/os-release', "ID=ubuntu\nVERSION_ID=24.04\n");
$root->write('/etc/init.d/xcatd', $template, 0750);
my $partial = $command eq 'cp' ? 'printf partial > "$destination"' : ':';
$root->write("/test-bin/$command", "#!/bin/sh\n" .
'for destination; do :; done' . "\n" .
'case "$destination" in /var/lib/xcat/xcatd-init-state/pending-xcatd*)' . "\n" .
"$partial\nexit 17;;\nesac\nexec /host-usr/bin/$command \"\$\@\"\n", 0755);
my ($status) = $root->deb_script($deb, 'preinst', 'upgrade', '2.18.0');
isnt($status, 0, 'failed pending capture stops preinst');
my @pending = glob $root->path('/var/lib/xcat/xcatd-init-state/pending-*');
is_deeply(\@pending, [], 'failed pending capture removes partial evidence');
is($root->read('/etc/init.d/xcatd'), $template, 'failed capture preserves administrator bytes');
is((stat($root->path('/etc/init.d/xcatd')))[2] & 07777, 0750, 'failed capture preserves administrator mode');
};
}
for my $helper (
['unsupported', "exit 2", 0],
['command-aware', undef, 0],
['malformed', "printf '%s\\n' malformed", 1],
['failure', "exit 7", 7],
) {
subtest "preinst with $helper->[0] installed helper" => sub {
my $root = XCAT::Test::Lifecycle->new;
$root->write('/etc/os-release', "ID=ubuntu\nVERSION_ID=14.04\n");
$root->write('/etc/init.d/xcatd', $template, 0755);
make_path($root->path('/etc/rc.d/rc3.d'));
symlink('/etc/init.d/xcatd', $root->path('/etc/rc.d/rc3.d/S85xcatd')) or die "symlink: $!";
$root->write('/opt/xcat/share/xcat/scripts/xcatd-init-compat',
defined $helper->[1] ? "#!/bin/sh\n$helper->[1]\n" : slurp_repo_file('xCAT-server/share/xcat/scripts/xcatd-init-compat'), 0755);
my ($status, $out, $err) = $root->deb_script($deb, 'preinst', 'upgrade', '2.0');
is($status, $helper->[2], 'complete preinst propagates the detector result') or diag "$out$err";
if ($status == 0) {
is($root->read('/var/lib/xcat/xcatd-init-state/pending-enabled'), "unregistered\n", 'RPM-only links do not enable a Debian service');
is($root->read('/var/lib/xcat/xcatd-init-state/pending-xcatd'), $template, 'pre-unpack capture keeps the conffile');
is((stat($root->path('/var/lib/xcat/xcatd-init-state/context')))[2] & 07777, 0600, 'context is private');
}
};
}
for my $suffix (qw(dpkg-bak dpkg-backup dpkg-remove)) {
subtest "preinst recovers $suffix" => sub {
my $root = XCAT::Test::Lifecycle->new;
$root->write('/etc/os-release', "ID=ubuntu\nVERSION_ID=\"24.04\"\n");
$root->write("/etc/init.d/xcatd.$suffix", "administrator recovery\n", 0750);
succeeds('recover interrupted conffile transition', $root->deb_script($deb, 'preinst', 'upgrade', '2.18.0'));
my $pending = '/var/lib/xcat/xcatd-init-state/pending-xcatd';
ok(-f $root->path($pending), 'recovery stages the conffile backup');
if (-f $root->path($pending)) {
is($root->read($pending), "administrator recovery\n", 'recovery preserves backup bytes');
is((stat($root->path($pending)))[2] & 07777, 0750, 'recovery preserves backup mode');
}
};
}
subtest 'obsolete conffile is package omission' => sub {
my $root = XCAT::Test::Lifecycle->new;
$root->write('/etc/os-release', "ID=ubuntu\nVERSION_ID=\"24.04\"\n");
$root->write('/var/lib/dpkg/status', $root->read('/var/lib/dpkg/status') .
"\nPackage: xcat-server\nStatus: install ok installed\nArchitecture: all\nVersion: 2.18.0\n" .
"Maintainer: xCAT <xcat-user\@lists.sourceforge.net>\nDescription: obsolete conffile fixture\n" .
"Conffiles:\n /etc/init.d/xcatd 8797eef6731719e0eacff59612b3e916 obsolete\n\n");
succeeds('capture obsolete conffile metadata', $root->deb_script($deb, 'preinst', 'upgrade', '2.18.0'));
ok(-f $root->path('/var/lib/xcat/xcatd-systemd-mode'), 'obsolete conffile records package omission');
ok(!-e $root->path('/var/lib/xcat/xcatd-init-state/pending-deleted'), 'package omission is not an administrator deletion');
};
subtest 'invalid postinst context fails closed' => sub {
my $root = XCAT::Test::Lifecycle->new;
$root->write('/etc/os-release', "ID=ubuntu\nVERSION_ID=\"24.04\"\n");
succeeds('install before invalid-context retry', $root->install_deb($deb));
my $state = '/var/lib/xcat/xcatd-init-state';
my $before = $root->read("$state/state");
$root->write("$state/context", "invalid\n");
my ($status, $out, $err) = $root->deb_script($deb, 'postinst', 'configure', $version);
isnt($status, 0, 'invalid transaction context fails configuration');
like($err, qr/Invalid xcatd package transition context/, 'failure identifies the invalid context');
is($root->read("$state/state"), $before, 'invalid context leaves durable state unchanged');
};
subtest 'failed configuration can be retried' => sub {
my $root = XCAT::Test::Lifecycle->new;
$root->write('/etc/os-release', "ID=ubuntu\nVERSION_ID=24.04\n");
$root->write('/test-bin/ln', <<'SHELL', 0755);
#!/bin/sh
for destination; do :; done
case "$destination" in /usr/sbin/xcatd) exit 17;; esac
exec /host-usr/bin/ln "$@"
SHELL
my ($status) = $root->install_deb($deb);
isnt($status, 0, 'late configuration failure stops the transaction');
my $context = '/var/lib/xcat/xcatd-init-state/context';
ok(-f $root->path($context), 'late failure retains transaction context');
is($root->read($context), "fresh\n", 'retry retains the original transition') if -f $root->path($context);
unlink $root->path('/test-bin/ln') or die "unlink: $!";
succeeds('retry package configuration', $root->run('dpkg', '--force-depends', '--configure', 'xcat-server'));
is(readlink($root->path('/usr/sbin/xcatd')), '/opt/xcat/sbin/xcatd', 'retry creates the daemon link');
ok(!-e $root->path($context), 'successful retry clears transaction context');
};
subtest 'preinst discards stale pending evidence' => sub {
my $root = XCAT::Test::Lifecycle->new;
$root->write('/etc/os-release', "ID=ubuntu\nVERSION_ID=\"24.04\"\n");
my $state = '/var/lib/xcat/xcatd-init-state';
for my $file (qw(pending-xcatd pending-enabled pending-deleted pending-xcatd.tmp.123)) {
$root->write("$state/$file", "stale evidence\n");
}
succeeds('begin installation without installed conffile metadata', $root->deb_script($deb, 'preinst', 'install'));
my @pending = glob $root->path("$state/pending-*");
is_deeply(\@pending, [], 'preinst discards pending evidence without installed ownership');
};
for my $mode (qw(systemd legacy)) {
for my $proc (0, 1) {
for my $available ($mode eq 'systemd' ? (0, 1) : (0)) {
subtest "$mode service dispatch proc=$proc manager=$available" => sub {
my $root = XCAT::Test::Lifecycle->new;
$root->{live} = $proc;
$root->write('/etc/os-release', "ID=ubuntu\nVERSION_ID=\"" . ($mode eq 'systemd' ? '24.04' : '14.04') . "\"\n");
make_path($root->path('/run/systemd/system')) if $available;
$root->record_command('/usr/sbin/systemctl');
$root->record_command('/test-bin/update-rc.d');
for my $event (qw(install upgrade)) {
$root->write('/calls', '');
succeeds("$event dispatch transaction", $root->install_deb($deb));
my @reloads = $root->read('/calls') =~ /^(systemctl <daemon-reload>)$/mg;
is_deeply(\@reloads, $available ? ['systemctl <daemon-reload>'] : [],
"$event reloads service definitions only with systemd available");
}
$root->record_command('/etc/init.d/xcatd') if $mode eq 'legacy';
$root->write('/calls', '');
succeeds('remove dispatch transaction', $root->run('dpkg', '--force-depends', '--remove', 'xcat-server'));
my @stops = $root->read('/calls') =~ /^((?:systemctl <stop> <xcatd.service>|xcatd <stop>))$/mg;
my $stop = $mode eq 'systemd' ? 'systemctl <stop> <xcatd.service>' : 'xcatd <stop>';
is_deeply(\@stops, $proc && ($available || $mode eq 'legacy') ? [$stop] : [],
'removal stops the available service only with proc');
};
}
}
}
done_testing();
@@ -1,102 +0,0 @@
#!/usr/bin/env perl
use strict;
use warnings;
use Digest::MD5 qw(md5_hex);
use File::Spec;
use FindBin;
use Test::More;
my $repo_root = File::Spec->catdir( $FindBin::Bin, '..', '..' );
sub read_file {
my ($path) = @_;
open( my $fh, '<', $path ) or die "Unable to read $path: $!";
binmode($fh);
my $contents = do { local $/; <$fh> };
close($fh);
return $contents;
}
my $deb_install = read_file(
File::Spec->catfile( $repo_root, 'xCAT-server', 'debian', 'install' )
);
my $dpkg_managed =
$deb_install =~ qr{^etc/init\.d/xcatd etc/init\.d$}m;
my $ucf_managed =
$deb_install =~ qr{^etc/init\.d/xcatd opt/xcat/share/xcat/scripts$}m;
ok( $dpkg_managed || $ucf_managed,
'Debian selects a configuration-aware legacy init backend' );
if ($dpkg_managed) {
pass('dpkg owns the directly packaged legacy init conffile');
done_testing();
exit;
}
my $deb_control = read_file(
File::Spec->catfile( $repo_root, 'xCAT-server', 'debian', 'control' )
);
like( $deb_control, qr{^Depends:.*(?:,\s+|^)ucf(?:,|$)}m,
'the generated-conffile backend depends on ucf' );
like( $deb_install,
qr{^debian/xcatd\.md5sum opt/xcat/share/xcat/scripts$}m,
'historical template checksums are installed beside the template' );
my $sum_path = File::Spec->catfile(
$repo_root, 'xCAT-server', 'debian', 'xcatd.md5sum'
);
ok( -f $sum_path, 'the Debian package carries historical template checksums' );
my $sums = -f $sum_path ? read_file($sum_path) : '';
like( $sums, qr{^0b1eea60994ff79faa9a8d0bcd53c558\s+2\.17\.0$}m,
'the last pre-transition release is recognized as unmodified' );
my $template = read_file(
File::Spec->catfile(
$repo_root, 'xCAT-server', 'etc', 'init.d', 'xcatd'
)
);
like( $sums, qr{^\Q@{[ md5_hex($template) ]}\E\s+2\.18\.0$}m,
'the current released template is recognized as unmodified' );
my $postinst = read_file(
File::Spec->catfile( $repo_root, 'xCAT-server', 'debian', 'postinst' )
);
like( $postinst,
qr{"\$xcatd_init_state" configure-legacy "\$xcatd_transition_context"},
'postinst delegates legacy conffile transitions to the state helper' );
my $state_helper = read_file(
File::Spec->catfile( $repo_root, 'xCAT-server', 'debian', 'xcatd-init-state' )
);
like( $state_helper, qr{ucf "\$legacy_template" "\$legacy_init"},
'legacy configuration delegates updates to ucf' );
like( $state_helper, qr{ucfr xcat-server "\$legacy_init"},
'legacy configuration registers ucf ownership' );
like( $state_helper, qr{state_content=stashed.*?stash_candidate}s,
'a present legacy script is represented by an exact stash' );
like( $state_helper, qr{state_content=deleted},
'administrator deletion has a distinct persistent state' );
like( $state_helper,
qr{package-default\).*?UCF_FORCE_CONFFMISS=1 ucf}s,
'only known package omission forces recreation of a missing script' );
like( $state_helper,
qr{state_content=unknown.*?preserving its absence}s,
'ambiguous markerless upgrades preserve absence instead of guessing' );
my $postrm = read_file(
File::Spec->catfile( $repo_root, 'xCAT-server', 'debian', 'postrm' )
);
like( $postrm,
qr{xcatd\.ucf-old.*?xcatd\.ucf-new.*?xcatd\.ucf-dist}s,
'purge removes ucf backup artifacts' );
like( $postrm, qr{ucf --purge /etc/init\.d/xcatd},
'purge removes ucf checksum state' );
like( $postrm, qr{ucfr --purge xcat-server /etc/init\.d/xcatd},
'purge removes the ucf ownership registration' );
like( $postrm,
qr{xcatd_state_dir=/var/lib/xcat/xcatd-init-state.*?(?:/var/lib/xcat/xcatd-init-state/state|"\$xcatd_state_dir/state")}s,
'purge removes the persistent init transition state' );
done_testing();
@@ -1,339 +0,0 @@
#!/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, '..', '..' );
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 init_classifier_block {
my ($script) = @_;
return $1
if $script =~
/(xcat_systemd_os_release_status\(\).*?\n}\n\nxcat_target_uses_systemd\(\).*?\n})\n\n/s;
return '';
}
sub pending_cleanup_block {
my ($script) = @_;
return $1
if $script =~ /(clear_xcatd_pending_state\(\)\n\{.*?\n\})\n/s;
return '';
}
sub legacy_state_block {
my ($script) = @_;
return $1
if $script =~
/(xcatd_legacy_enable_state\(\)\n\{.*?\n\})\n\nif xcat_target_uses_systemd/s;
return '';
}
sub run_legacy_state_block {
my ( $block, $helper_source ) = @_;
my $root = tempdir( CLEANUP => 1 );
my $helper = File::Spec->catfile( $root, 'xcatd-init-compat' );
open( my $helper_fh, '>', $helper )
or die "Unable to stage compatibility helper: $!";
print {$helper_fh} $helper_source;
close($helper_fh);
chmod 0755, $helper;
my $rpm_runlevel =
File::Spec->catdir( $root, 'etc', 'rc.d', 'rc3.d' );
make_path($rpm_runlevel);
symlink( '/etc/init.d/xcatd',
File::Spec->catfile( $rpm_runlevel, 'S85xcatd' ) )
or die "Unable to stage RPM-only runlevel link: $!";
my $isolated_block = $block;
$isolated_block =~ s{/etc/rc}{\$fixture_root/etc/rc}g;
my $runner = File::Spec->catfile( $root, 'run-detector' );
open( my $runner_fh, '>', $runner )
or die "Unable to stage detector runner: $!";
print {$runner_fh} "#!/bin/sh\nset -e\n";
print {$runner_fh} "fixture_root=\${XCAT_TEST_ROOT:?}\n";
print {$runner_fh} "xcatd_init_compat=\${XCAT_TEST_HELPER:?}\n";
print {$runner_fh} "$isolated_block\n\nxcatd_legacy_enable_state\n";
close($runner_fh);
chmod 0755, $runner;
local $ENV{XCAT_TEST_ROOT} = $root;
local $ENV{XCAT_TEST_HELPER} = $helper;
open( my $pipe, '-|', '/bin/sh', $runner )
or die "Unable to run preinst detector: $!";
my $output = do { local $/; <$pipe> };
close($pipe);
my $status = $? >> 8;
$output =~ s/\s+\z//;
return ( $status, $output );
}
my $deb_install = read_file(
File::Spec->catfile( $repo_root, 'xCAT-server', 'debian', 'install' )
);
like( $deb_install, qr{^etc/init\.d/xcatd opt/xcat/share/xcat/scripts$}m,
'Debian stages the legacy script as a compatibility template' );
unlike( $deb_install, qr{^etc/init\.d/xcatd etc/init\.d$}m,
'Debian does not install the legacy script directly' );
my $deb_dirs = read_file(
File::Spec->catfile( $repo_root, 'xCAT-server', 'debian', 'dirs' )
);
unlike( $deb_dirs, qr{^etc/init\.d/?$}m,
'Debian does not package an empty legacy init directory' );
foreach my $maintainer_script (qw(preinst postinst postrm)) {
my $script = read_file(
File::Spec->catfile(
$repo_root, 'xCAT-server', 'debian', $maintainer_script
)
);
like( $script,
qr{dpkg-maintscript-helper rm_conffile /etc/init\.d/xcatd -- "\$\@"},
"Debian $maintainer_script participates in graceful conffile removal" );
}
my $deb_postinst = read_file(
File::Spec->catfile( $repo_root, 'xCAT-server', 'debian', 'postinst' )
);
like( $deb_postinst,
qr{if "\$xcatd_init_compat" uses-systemd --explicit-target; then},
'Debian postinst delegates target init detection to the packaged helper' );
unlike( $deb_postinst,
qr{"\$xcatd_init_compat" (?:uses-systemd|configure)(?! --explicit-target)},
'every Debian postinst helper call uses explicit target detection' );
unlike( $deb_postinst, qr{sub xcat_target_uses_systemd|xcat_target_uses_systemd\(\)},
'Debian postinst does not carry weaker duplicate init detection' );
my $deb_init_state = read_file(
File::Spec->catfile(
$repo_root, 'xCAT-server', 'debian', 'xcatd-init-state'
)
);
like( $deb_init_state,
qr{\[ -x "\$legacy_init" \].*?update-rc\.d xcatd defaults}s,
'Debian registers SysV only after materializing an executable script' );
like( $deb_init_state,
qr{apply_legacy_registration\(\).*?no\).*?update-rc\.d xcatd defaults.*?update-rc\.d xcatd disable.*?unregistered\|unknown\).*?update-rc\.d -f xcatd remove}s,
'Debian distinguishes registered-disabled from unregistered SysV state' );
like( $deb_init_state,
qr{stash_candidate\(\).*?if ! cp -a.*?\|\|\s*! mv -f.*?rm -f "\$stash_tmp".*?return 1}s,
'Debian removes a failed atomic stash temporary' );
like( $deb_init_state, qr{printf 'format=2},
'Debian writes registration-aware state format 2' );
like( $deb_init_state,
qr{state_format" = 1.*?state_enabled" = no.*?state_enabled=unregistered}s,
'Debian preserves format-1 remove-all-links behavior' );
my @unregistered_refresh_guards =
$deb_init_state =~ /state_enabled" != unregistered/g;
is( scalar @unregistered_refresh_guards, 2,
'both systemd refresh paths retain unregistered provenance' );
like( $deb_init_state,
qr{init_compat=.*?xcatd-init-compat.*?shared_init_state\(\).*?"\$init_compat" "\$\@".*?debian-legacy-state.*?systemd-state --allow-unknown}s,
'Debian delegates legacy and precise systemd state detection to the shared helper' );
like( $deb_init_state,
qr{if ! detected_legacy_state=.*?return 1.*?if ! detected_systemd_state=.*?return 1}s,
'Debian propagates failures from both shared state probes' );
unlike( $deb_init_state,
qr{systemctl (?:--root=.*? )?is-enabled|systemd/system/\*\.(?:wants|requires)/xcatd\.service|rc\?\.d/S\?\?xcatd},
'Debian init state does not duplicate shared service-state probes' );
unlike( $deb_init_state, qr{systemd_is_masked},
'Debian does not hide detector failures in a boolean mask probe' );
like( $deb_postinst,
qr{transition_origin=.*?get origin.*?fresh:yes\|legacy:yes.*?systemctl enable}s,
'Debian changes systemd enablement only for fresh installs and init transitions' );
like( $deb_postinst,
qr{legacy:no\|legacy:unregistered\) systemctl disable xcatd\.service},
'Debian maps both disabled SysV registration states to systemd disablement' );
unlike( $deb_postinst, qr{systemd:yes.*?systemctl enable}s,
'Debian same-systemd upgrades do not normalize existing enablement' );
my %classifier_blocks;
foreach my $fallback_script (qw(preinst postrm)) {
my $script = read_file(
File::Spec->catfile( $repo_root, 'xCAT-server', 'debian', $fallback_script )
);
$classifier_blocks{$fallback_script} = init_classifier_block($script);
ok( $classifier_blocks{$fallback_script} ne '',
"Debian $fallback_script classifier can be extracted" );
like( $script, qr{VERSION_ID.*?ubuntu.*?-ge 15.*?debian.*?-ge 8}s,
"Debian $fallback_script recognizes stripped modern targets" );
like( $script, qr{\[ "\$os_id" = "debian" \].*?\[ -z "\$os_version" \]}s,
"Debian $fallback_script recognizes testing and sid without VERSION_ID" );
like( $script, qr{value=\$\{value#\\'\}\s+value=\$\{value%\\'\}}s,
"Debian $fallback_script strips single-quoted os-release values" );
like( $script,
qr{xcat_target_uses_systemd\(\).*?\[ -d /run/systemd/system \].*?\[ -L /sbin/init \].*?\*\) return 1 ;;.*?\[ -e /sbin/init \].*?return 1.*?if xcat_systemd_os_release_status; then.*?\[ -x /usr/lib/systemd/systemd \]}s,
"Debian $fallback_script prefers active and explicit target evidence" );
like( $script,
qr{xcat_systemd_os_release_status\(\).*?return 2.*?return 1.*?return 2}s,
"Debian $fallback_script distinguishes legacy from unknown releases" );
}
my $deb_preinst = read_file(
File::Spec->catfile( $repo_root, 'xCAT-server', 'debian', 'preinst' )
);
my $preinst_legacy_state_block = legacy_state_block($deb_preinst);
ok( $preinst_legacy_state_block ne '',
'preinst legacy registration detector can be extracted' );
is_deeply(
[ run_legacy_state_block( $preinst_legacy_state_block, <<'SH' ) ],
#!/bin/sh
root=${XCAT_TEST_ROOT:?}
case "${1:-}" in
legacy-state)
for link in "$root"/etc/rc.d/rc?.d/S??xcatd; do
if [ -e "$link" ] || [ -L "$link" ]; then
printf '%s\n' enabled
exit 0
fi
done
printf '%s\n' unregistered
;;
*) exit 2 ;;
esac
SH
[ 0, 'unregistered' ],
'preinst falls back to Debian links when the installed helper lacks the command'
);
is_deeply(
[ run_legacy_state_block( $preinst_legacy_state_block, <<'SH' ) ],
#!/bin/sh
root=${XCAT_TEST_ROOT:?}
case "${1:-}:$#" in
debian-legacy-state:1)
for link in "$root"/etc/rc?.d/S??xcatd; do
if [ -e "$link" ] || [ -L "$link" ]; then
printf '%s\n' enabled
exit 0
fi
done
printf '%s\n' unregistered
;;
*) exit 2 ;;
esac
SH
[ 0, 'unregistered' ],
'preinst uses Debian-only state from a command-aware installed helper'
);
my ( $malformed_helper_status, $malformed_helper_output ) =
run_legacy_state_block( $preinst_legacy_state_block, <<'SH' );
#!/bin/sh
[ "${1:-}" = debian-legacy-state ] || exit 2
printf '%s\n' malformed
SH
is( $malformed_helper_status, 1,
'preinst rejects malformed successful helper output' );
is( $malformed_helper_output, '',
'preinst does not promote malformed helper output' );
my ( $failed_helper_status, $failed_helper_output ) =
run_legacy_state_block( $preinst_legacy_state_block, <<'SH' );
#!/bin/sh
exit 7
SH
is( $failed_helper_status, 7,
'preinst fails closed on non-capability helper errors' );
is( $failed_helper_output, '',
'preinst does not fall back after a real helper failure' );
like( $deb_preinst,
qr{write_xcatd_context\(\)\s*\(.*?umask 077.*?\n\)}s,
'preinst confines the context-file umask to a subshell' );
like( $deb_preinst,
qr{context_tmp=.*?if ! printf.*?\|\|\s*! mv -f.*?rm -f "\$context_tmp".*?return 1}s,
'preinst removes a failed context-file temporary' );
my $preinst_pending_cleanup = pending_cleanup_block($deb_preinst);
like( $preinst_pending_cleanup,
qr{pending-xcatd.*?pending-deleted.*?pending-enabled.*?pending-xcatd\.tmp\.}s,
'preinst clears every transaction-local pending state artifact' );
like( $deb_preinst,
qr{conffile_record=.*?dpkg-query.*?case "\$conffile_record" in.*?obsolete.*?touch "\$xcatd_old_systemd_marker".*?\*\).*?touch "\$xcatd_state_dir/pending-deleted"}s,
'preinst distinguishes package-obsolete conffiles from administrator deletion' );
like( $deb_preinst,
qr{xcatd_transition_context=\s*case "\$1" in\s*upgrade\) xcatd_transition_context=upgrade ;;\s*install\) xcatd_transition_context=fresh ;;\s*esac\s*if \[ -n "\$xcatd_transition_context" \]}s,
'preinst prepares transition state only for install and upgrade calls' );
like( $deb_preinst,
qr{\$xcatd_legacy_init\.dpkg-bak.*?\$xcatd_legacy_init\.dpkg-backup.*?\$xcatd_legacy_init\.dpkg-remove}s,
'preinst recovers every conffile backup name used during interrupted removal' );
like( $deb_preinst,
qr{pending_tmp=.*?if ! cp -a.*?\|\|\s*! mv -f.*?rm -f "\$pending_tmp".*?exit 1}s,
'preinst removes a failed pending-stash temporary' );
like( $deb_preinst,
qr{if \[ -n "\$conffile_record" \]; then.*?case "\$conffile_record" in.*?esac\s*else\s*# A retry without an installed conffile record.*?clear_xcatd_pending_state\s*fi}s,
'preinst discards stale pending evidence when dpkg has no conffile record' );
like( $deb_preinst,
qr{xcatd_legacy_enable_state\(\).*?"\$xcatd_init_compat" debian-legacy-state 2>/dev/null.*?detector_status=\$\?.*?"\$detector_status" -ne 2.*?/etc/rc\?\.d/S\?\?xcatd.*?/etc/rc\?\.d/K\?\?xcatd.*?unregistered}s,
'preinst reuses shared registration detection with a pre-unpack S/K fallback' );
like( $deb_preinst,
qr{upgrade\)\s*write_xcatd_context upgrade.*?install\)\s*write_xcatd_context fresh.*?abort-upgrade\)\s*rm -f "\$xcatd_context_file"}s,
'preinst records explicit durable package transition context' );
like( $deb_postinst,
qr{ln -sf /opt/xcat/sbin/xcatd /usr/sbin/xcatd.*?#DEBHELPER#.*?rm -f "\$xcatd_context_file"}s,
'postinst retains upgrade context until every fallible installation step succeeds' );
like( $deb_postinst,
qr{if \[ -r "\$xcatd_context_file" \].*?fresh\|upgrade.*?elif \[ "\$1" = configure \] && \[ -n "\$\{2:-\}" \].*?xcatd_transition_context=upgrade}s,
'postinst validates explicit context and preserves state on configured-package fallback' );
unlike( "$deb_preinst\n$deb_postinst", qr{/tmp/xCAT-server_upgrade\.tmp},
'maintainer scripts keep transaction state out of shared temporary storage' );
is( $classifier_blocks{preinst}, $classifier_blocks{postrm},
'Debian preinst and postrm carry the same lifecycle-safe classifier' );
my $deb_postrm = read_file(
File::Spec->catfile( $repo_root, 'xCAT-server', 'debian', 'postrm' )
);
my $postrm_pending_cleanup = pending_cleanup_block($deb_postrm);
is( $postrm_pending_cleanup, $preinst_pending_cleanup,
'preinst and postrm share the same pending-state cleanup contract' );
unlike( $deb_postrm, qr{/tmp/xCAT-server_upgrade\.tmp},
'postrm keeps transaction state out of shared temporary storage' );
my ($abort_install) = $deb_postrm =~ /\n\s*abort-install\)\n(.*?)\n\s*;;/s;
my ($abort_upgrade) =
$deb_postrm =~ /\n\s*abort-upgrade\|disappear\)\n(.*?)\n\s*;;/s;
ok( defined($abort_install) && defined($abort_upgrade),
'postrm abort recovery arms can be inspected independently' );
like( $abort_install,
qr{clear_xcatd_pending_state.*?rm -f "\$xcatd_context_file".*?rmdir "\$xcatd_state_dir"}s,
'abort-install clears pending context and removes only an empty state directory' );
unlike( $abort_install,
qr{\$xcatd_state_dir/(?:state|xcatd)|xcatd_old_systemd_marker},
'abort-install preserves durable init state and compatibility evidence' );
like( $abort_upgrade, qr{rm -f "\$xcatd_context_file"},
'abort-upgrade clears only the completed-attempt context' );
unlike( $abort_upgrade,
qr{clear_xcatd_pending_state|pending-(?:xcatd|deleted|enabled)},
'abort-upgrade preserves pending recovery evidence for retry' );
like( $deb_postrm,
qr{purge\).*?rm -f "\$xcatd_context_file".*?upgrade\|failed-upgrade\)\s*;;.*?remove\).*?rm -f "\$xcatd_context_file"}s,
'postrm clears upgrade context only when installation cannot continue' );
my $deb_prerm = read_file(
File::Spec->catfile( $repo_root, 'xCAT-server', 'debian', 'prerm' )
);
like( $deb_prerm,
qr{if "\$xcatd_init_compat" uses-systemd --explicit-target; then.*?update-rc\.d -f xcatd remove}s,
'Debian prerm removes registration through the detected init implementation' );
unlike( $deb_prerm,
qr{"\$xcatd_init_compat" uses-systemd(?! --explicit-target)},
'every Debian prerm helper call uses explicit target detection' );
unlike( $deb_postinst, qr{xcat_can_use_systemctl},
'Debian postinst does not duplicate the shared systemctl readiness guard' );
like( $deb_postinst,
qr{if "\$xcatd_init_compat" can-use-systemctl; then\s+systemctl daemon-reload}s,
'Debian postinst delegates its active-systemd guard to the shared helper' );
unlike( $deb_prerm, qr{xcat_can_use_systemctl},
'Debian prerm does not duplicate the shared systemctl readiness guard' );
like( $deb_prerm,
qr{if "\$xcatd_init_compat" can-use-systemctl; then\s+systemctl stop xcatd\.service}s,
'Debian prerm delegates its active-systemd guard to the shared helper' );
done_testing();