From 75474f10fd325cd66075287f5edcd151fa11e5fc Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Sat, 12 Sep 2026 06:09:19 -0300 Subject: [PATCH 1/3] test(xcat-core): gettimezone returns an error sentence as a timezone name The EL 10 riscv64 compute node xcat56-cn never installs a package. Its kickstart carries, at line 21, "timezone Could not determine timezone checksum --utc"; anaconda answers "One or zero arguments are expected for the timezone command" and stops. The node holds status=installing until retry_install.sh reinstalls over it (build 114). gettimezone in Utils.pm compares /etc/localtime against every file under /usr/share/zoneinfo and returns the string "Could not determine timezone checksum" when that pipeline exits non-zero. The Rocky 10.2 riscv64 cloud image has no /etc/localtime and runs on UTC, so the pipeline prints nothing, exits 1, and the sentence is written into the kickstart as a name. This test drives the routine against a scratch root and asserts it names a zone for a root with a symlink, with /etc/timezone, and with neither -- and that the value is one token, which is all the kickstart timezone command accepts. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-test/unit/utils_gettimezone.t | 103 +++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 xCAT-test/unit/utils_gettimezone.t diff --git a/xCAT-test/unit/utils_gettimezone.t b/xCAT-test/unit/utils_gettimezone.t new file mode 100644 index 000000000..02dd528f4 --- /dev/null +++ b/xCAT-test/unit/utils_gettimezone.t @@ -0,0 +1,103 @@ +#!/usr/bin/env perl +# gettimezone names the timezone that goes into a kickstart or an autoyast profile, where the +# value must be one token. It found the name by comparing /etc/localtime against every file under +# /usr/share/zoneinfo, and when that pipeline failed it returned the sentence "Could not determine +# timezone checksum" as if it were a name. +# +# A Rocky 10.2 riscv64 cloud image has no /etc/localtime at all and runs on UTC. The compute node +# xcat56-cn was therefore written /install/autoinst/xcat56-cn line 21 +# "timezone Could not determine timezone checksum --utc", anaconda answered "One or zero arguments +# are expected for the timezone command", and the install stopped before it installed one package. +# The node stayed at status=installing for 59 minutes until retry_install.sh reinstalled over it. +# +# Utils.pm cannot be loaded here, so the routine is extracted and driven against a scratch root +# with the two collaborators it calls replaced. +use strict; +use warnings; + +use File::Path qw(make_path); +use File::Temp qw(tempdir); +use FindBin; +use Test::More; + +my $source = "$FindBin::Bin/../../perl-xCAT/xCAT/Utils.pm"; +open(my $source_fh, '<', $source) or die "open $source: $!"; +my $content = do { local $/; <$source_fh> }; +close($source_fh) or die "close $source: $!"; + +my @routines; +for my $name (qw(gettimezone _zone_from_path)) { + my ($routine) = $content =~ /^(sub \Q$name\E\s*\n?\{.*?^\})/ms; + BAIL_OUT("could not extract $name from Utils.pm") unless $routine; + push(@routines, $routine); +} +# The routines call each other unqualified and the caller reaches them through the class, so +# they go back into the package they came from. +eval "package xCAT::Utils;\n" . join("\n", @routines); ## no critic (BuiltinFunctions::ProhibitStringyEval) +BAIL_OUT("could not load the timezone routines: $@") if $@; + +# The collaborators gettimezone calls. The scan runs `find`, which must never look at the host +# this test runs on, so it answers from the scratch root instead. +our $SCAN_OUT = ''; +our $SCAN_RC = 1; +{ + no warnings 'once'; + *xCAT::Utils::isAIX = sub { return 0 }; + *xCAT::Utils::runcmd = sub { $::RUNCMD_RC = $SCAN_RC; return $SCAN_OUT }; +} + +#----------------------------------------------------------------------------------------------- +=head3 scratch_root + +Descriptions: + A root with a zoneinfo tree, and /etc/localtime as a symlink into it when a zone is named. +Arguments: + $zone - the zone to link /etc/localtime to, or undef for a root with no /etc/localtime +Returns: + The root directory. +=cut +#----------------------------------------------------------------------------------------------- +sub scratch_root { + my ($zone) = @_; + my $root = tempdir(CLEANUP => 1); + make_path("$root/etc", "$root/usr/share/zoneinfo/America"); + for my $name ('UTC', 'America/Sao_Paulo') { + open(my $fh, '>', "$root/usr/share/zoneinfo/$name") or die "create $name: $!"; + print {$fh} "TZif"; + close($fh) or die "close $name: $!"; + } + symlink("../usr/share/zoneinfo/$zone", "$root/etc/localtime") or die "symlink: $!" + if defined $zone; + return $root; +} + +is(xCAT::Utils->gettimezone(root => scratch_root('America/Sao_Paulo')), 'America/Sao_Paulo', + 'the /etc/localtime symlink names the zone'); + +# The failure that stopped the riscv64 install: no /etc/localtime, so the scan reports nothing. +my $none = xCAT::Utils->gettimezone(root => scratch_root(undef)); +is($none, 'UTC', 'a root with no /etc/localtime falls back to UTC'); +unlike($none, qr/\s/, + 'the value is one token, which is all the kickstart timezone command accepts'); + +# /etc/timezone is consulted before the fallback. +my $root = scratch_root(undef); +open(my $tz_fh, '>', "$root/etc/timezone") or die "create /etc/timezone: $!"; +print {$tz_fh} "America/Sao_Paulo\n"; +close($tz_fh) or die "close /etc/timezone: $!"; +is(xCAT::Utils->gettimezone(root => $root), 'America/Sao_Paulo', + '/etc/timezone names the zone when there is no symlink'); + +# The scan still answers for a root whose /etc/localtime is a copy rather than a symlink. +$root = scratch_root(undef); +open(my $copy_fh, '>', "$root/etc/localtime") or die "create /etc/localtime: $!"; +print {$copy_fh} "TZif"; +close($copy_fh) or die "close /etc/localtime: $!"; +{ + local $SCAN_OUT = "$root/usr/share/zoneinfo/America/Sao_Paulo\n"; + local $SCAN_RC = 0; + is(xCAT::Utils->gettimezone(root => $root), 'America/Sao_Paulo', + 'the zoneinfo scan names the zone when /etc/localtime is a copy'); +} + +done_testing(); From 2baa56534c6c5dee337ce9486ba3f413ad74ee2d Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Sat, 12 Sep 2026 06:10:19 -0300 Subject: [PATCH 2/3] fix(xcat-core): gettimezone returns an error sentence as a timezone name The EL 10 riscv64 compute node installs no package. Its kickstart carries "timezone Could not determine timezone checksum --utc" at line 21, anaconda answers "One or zero arguments are expected for the timezone command", and the node holds status=installing until retry_install.sh reinstalls over it (build 114). gettimezone in Utils.pm compares /etc/localtime against every file under /usr/share/zoneinfo and, when that pipeline exits non-zero, returns the string "Could not determine timezone checksum" to its caller as a name. The Rocky 10.2 riscv64 cloud image ships no /etc/localtime and runs on UTC, so the pipeline prints nothing and exits 1. gettimezone now reads the /etc/localtime symlink first, which is what every current distribution uses and what the scan cannot answer for a host with no /etc/localtime, then the scan, then /etc/timezone, and returns UTC when none of them names a zone. It never returns a value with a space in it, which is all the kickstart timezone command accepts. The paths take an optional root prefix so the test drives them against a scratch tree. xCAT-test/unit/utils_gettimezone.t stops without _zone_from_path, and each of the three sources it asserts fails a separate assertion when it is removed. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- perl-xCAT/xCAT/Utils.pm | 88 ++++++++++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 22 deletions(-) diff --git a/perl-xCAT/xCAT/Utils.pm b/perl-xCAT/xCAT/Utils.pm index 7a4405330..62530eb54 100644 --- a/perl-xCAT/xCAT/Utils.pm +++ b/perl-xCAT/xCAT/Utils.pm @@ -3909,36 +3909,80 @@ sub fullpathbin #-------------------------------------------------------------------------------- sub gettimezone { - my ($class) = @_; + my ($class, %opt) = @_; - my $tz; if (xCAT::Utils->isAIX()) { - $tz = $ENV{'TZ'}; - } else { # all linux - my $localtime = "/etc/localtime"; - my $zoneinfo = "/usr/share/zoneinfo"; + return $ENV{'TZ'}; + } + + # all linux. %opt{root} prefixes every path, so a test drives this against a scratch tree. + my $root = defined($opt{root}) ? $opt{root} : ''; + my $localtime = "$root/etc/localtime"; + my $zoneinfo = "$root/usr/share/zoneinfo"; + + # On every current distribution /etc/localtime is a symlink into the zoneinfo tree and its + # target is the name. Read it before the scan: the scan compares /etc/localtime against every + # file in the tree, and a cloud image running on UTC may ship no /etc/localtime at all. + if (-l $localtime) { + my $zone = _zone_from_path(readlink($localtime)); + return $zone if defined $zone; + } + + if (-e $localtime) { my $cmd = "find $zoneinfo -xtype f -exec cmp -s $localtime {} \\; -print | grep -v posix | grep -v SystemV | grep -v right | grep -v localtime "; my $zone_result = xCAT::Utils->runcmd("$cmd", 0); - if ($::RUNCMD_RC != 0) - { - $tz = "Could not determine timezone checksum"; - return $tz; + if ($::RUNCMD_RC == 0) { + my @zones = split /\n/, $zone_result; + my $zone = _zone_from_path($zones[0]); + return $zone if defined $zone; } - my @zones = split /\n/, $zone_result; - - $zones[0] =~ s/$zoneinfo\///; - if (!$zones[0]) { # if we still did not get one, then default - $tz = `cat /etc/timezone`; - chomp $tz; - } else { - $tz = $zones[0]; - } - - } - return $tz; + if (open(my $tz_fh, '<', "$root/etc/timezone")) { + my $zone = <$tz_fh>; + close($tz_fh); + if (defined $zone) { + chomp $zone; + return $zone if length($zone) and $zone !~ /\s/; + } + } + # The caller writes this value into a kickstart or an autoyast profile, where it must be one + # token. Name the zone the host is actually on rather than a sentence that stops the installer. + return 'UTC'; +} + +#-------------------------------------------------------------------------------- + +=head3 _zone_from_path + Returns the timezone name inside a path under a zoneinfo tree, for a symlink target or a + line of the zoneinfo scan. Both absolute and relative targets carry "/zoneinfo/", so the + name is whatever follows it. + Arguments: + A path, or undef + Returns: + The timezone name, or undef when the path names no zone + Globals: + none + Error: + None + Example: + my $zone = _zone_from_path("../usr/share/zoneinfo/America/Sao_Paulo"); + Comments: + none +=cut + +#-------------------------------------------------------------------------------- +sub _zone_from_path +{ + my ($path) = @_; + + return undef unless defined($path) and length($path); + return undef unless $path =~ m{(?:^|/)zoneinfo/(.+)\z}; + my $zone = $1; + $zone =~ s{^posix/}{}; + return undef if $zone eq '' or $zone eq 'localtime' or $zone =~ /\s/; + return $zone; } #-------------------------------------------------------------------------------- From fd97960dbc60438272d06b94e235eff8a31d76b9 Mon Sep 17 00:00:00 2001 From: Daniel Hilst <392820+dhilst@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:35:02 -0300 Subject: [PATCH 3/3] test(xcat-core): a failed extraction in the timezone test stops the whole suite utils_gettimezone.t called BAIL_OUT when it could not lift the two routines out of Utils.pm. prove stops every remaining file on a bail-out, so a rename in Utils.pm that breaks the regex in this file also hides every test that would have run after it. die is just as loud and costs only this file. The header also carried the incident report: a node name, an autoinst file and line, the anaconda message and the minutes the node spent in status=installing. The reader needs the contract, which is that the value must be one token. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com> --- xCAT-test/unit/utils_gettimezone.t | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/xCAT-test/unit/utils_gettimezone.t b/xCAT-test/unit/utils_gettimezone.t index 02dd528f4..bd9448eff 100644 --- a/xCAT-test/unit/utils_gettimezone.t +++ b/xCAT-test/unit/utils_gettimezone.t @@ -4,11 +4,8 @@ # /usr/share/zoneinfo, and when that pipeline failed it returned the sentence "Could not determine # timezone checksum" as if it were a name. # -# A Rocky 10.2 riscv64 cloud image has no /etc/localtime at all and runs on UTC. The compute node -# xcat56-cn was therefore written /install/autoinst/xcat56-cn line 21 -# "timezone Could not determine timezone checksum --utc", anaconda answered "One or zero arguments -# are expected for the timezone command", and the install stopped before it installed one package. -# The node stayed at status=installing for 59 minutes until retry_install.sh reinstalled over it. +# A cloud image that runs on UTC ships no /etc/localtime, so the scan finds nothing there, and +# anaconda refuses a timezone command that carries more than one argument. # # Utils.pm cannot be loaded here, so the routine is extracted and driven against a scratch root # with the two collaborators it calls replaced. @@ -28,13 +25,13 @@ close($source_fh) or die "close $source: $!"; my @routines; for my $name (qw(gettimezone _zone_from_path)) { my ($routine) = $content =~ /^(sub \Q$name\E\s*\n?\{.*?^\})/ms; - BAIL_OUT("could not extract $name from Utils.pm") unless $routine; + die("could not extract $name from Utils.pm") unless $routine; push(@routines, $routine); } # The routines call each other unqualified and the caller reaches them through the class, so # they go back into the package they came from. eval "package xCAT::Utils;\n" . join("\n", @routines); ## no critic (BuiltinFunctions::ProhibitStringyEval) -BAIL_OUT("could not load the timezone routines: $@") if $@; +die("could not load the timezone routines: $@") if $@; # The collaborators gettimezone calls. The scan runs `find`, which must never look at the host # this test runs on, so it answers from the scratch root instead. @@ -74,7 +71,7 @@ sub scratch_root { is(xCAT::Utils->gettimezone(root => scratch_root('America/Sao_Paulo')), 'America/Sao_Paulo', 'the /etc/localtime symlink names the zone'); -# The failure that stopped the riscv64 install: no /etc/localtime, so the scan reports nothing. +# No /etc/localtime, so the scan reports nothing. my $none = xCAT::Utils->gettimezone(root => scratch_root(undef)); is($none, 'UTC', 'a root with no /etc/localtime falls back to UTC'); unlike($none, qr/\s/,