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; } #-------------------------------------------------------------------------------- diff --git a/xCAT-test/unit/utils_gettimezone.t b/xCAT-test/unit/utils_gettimezone.t new file mode 100644 index 000000000..bd9448eff --- /dev/null +++ b/xCAT-test/unit/utils_gettimezone.t @@ -0,0 +1,100 @@ +#!/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 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. +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; + 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) +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. +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'); + +# 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();