#!/usr/bin/env perl # Copyright (c) 2025, R. W. Rodolico # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, this # list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # sysinfo_update # Updates sysinfo client from svn or tarball source. # # Revision History: # 2026-10-13 Copilot v2.0.0 r? spec-compliant updater # 2026-10-13 Copilot v2.0.1 version-compare flow, rooted config reads, and seed-free update path # 2026-10-14 Claude v2.0.2 the perl -c check of the unpacked client no longer prints "syntax OK" (runs quiet); # the update log honours SYSINFO_ROOT (a root test run no longer writes /var/log) # 2026-10-18 Claude v2.1.0 repository split: --version switches (--ignore-ancestry, the unix/ directory has no common ancestry with an old root checkout) to tags//unix, and the default svn_url ends in /unix # 2026-10-25 Claude v2.2.0 bug 207: the tarball method fetches the small .sha256 file first and compares it with the checksum recorded in /update_installed.sha256 when the last release was installed; equal means nochange without downloading the tarball, different means download and install even when the version string is the same. Without a published checksum it works as before. # 2026-10-19 Claude v2.1.0 (same pending version) no built-in release URLs: the tarball URLs come only from the config or the command line, the svn repository for --version comes from the working copy (svn info) or --svn-url, never the config, and a missing URL gives a message saying which config key to set; a plain svn update needs no URL use strict; use warnings; BEGIN { use FindBin; use File::Spec; use lib File::Spec->catdir($FindBin::Bin, 'installer'); use lib File::Spec->catdir( $FindBin::Bin ); } use Cwd qw(getcwd); use File::Basename qw(basename dirname); use File::Path qw(make_path remove_tree); use File::Temp qw(tempdir); use Getopt::Long; use YAML::Tiny; use Digest::SHA; use version; our $VERSION = version->declare('v2.2.0'); use installer_shared qw( slurpCommand loadInstallerConfig readTextFile appendLog runCommandList getRootPrefix rootPath tryFetch ); my $help = 0; my $showVersion = 0; my $quiet = 0; my $force = 0; my $requireChecksum = 0; my $method = 'auto'; my $os = ''; my $tarballUrl = ''; my $checksumUrl = ''; my $svnUrl = ''; my $wantedVersion = ''; my $workdir = ''; my $logPath = '/var/log/sysinfo_client_update.log'; my $stateDir = ''; # the config directory; holds update_installed.sha256 sub printHelp { print basename($0) . " $VERSION\n"; print <<'END_HELP'; sysinfo_update [options] Options: --method update method (default auto) --url tarball URL override --sha256-url checksum URL override --svn-url svn URL to switch from for --version (default: the working copy's own URL) --version target version --os|-o override OS detection --force bypass the checksum and current-vs-target compares (always download and install) --require-checksum fail if checksum unavailable/mismatch --quiet|-q reduce output --workdir use explicit temporary directory --help|-h show this help --version-info|-v show script version END_HELP } sub detectOs { return $os if $os ne ''; my $det = slurpCommand(['perl', "$FindBin::Bin/installer/determineOS"]); chomp $det if defined($det); return defined($det) ? $det : ''; } sub readVersionToken { my $text = shift; return '' unless defined($text); return $1 if $text =~ /(v[0-9]+\.[0-9]+\.[0-9]+)/; return ''; } sub readVersionFromTree { my $tree = shift; my $out = slurpCommand(['perl', $tree . '/sysinfo_client', '--version']); my $ver = readVersionToken($out); return $ver if $ver ne ''; $out = slurpCommand(['perl', $tree . '/SYSTEM_VERSION']); $ver = readVersionToken($out); return $ver; } sub loadYamlFile { my $path = shift; my $yaml = YAML::Tiny->read($path); die "could not parse yaml $path\n" unless $yaml && ref($yaml->[0]) eq 'HASH'; return $yaml->[0]; } sub parseShaLine { my $line = shift; return '' unless defined($line); if ($line =~ /^([a-fA-F0-9]{64})\b/) { return lc($1); } return ''; } sub calcSha { my $file = shift; open my $fh, '<', $file or return ''; binmode($fh); my $ctx = Digest::SHA->new(256); $ctx->addfile($fh); close($fh); return lc($ctx->hexdigest); } sub resolveReleaseEndpoints { my ($cfg, $updateCfg) = @_; my %urls = ( tarball => $tarballUrl || $updateCfg->{'url'} || '', checksum => $checksumUrl || $updateCfg->{'sha256_url'} || '', ); return \%urls; } sub extractTarball { my ($tarPath, $extractBase) = @_; my $rc = runCommandList(['tar', '-xzf', $tarPath, '-C', $extractBase], 0); return '' if $rc != 0; foreach my $candidate ('sysinfo_client', 'camp_sysinfo_client_3', '.') { my $path = $candidate eq '.' ? $extractBase : "$extractBase/$candidate"; if (-f "$path/installer/setup") { return $path; } } opendir(my $dh, $extractBase) or return ''; my @entries = grep { $_ ne '.' && $_ ne '..' } readdir($dh); closedir($dh); foreach my $entry (@entries) { my $path = "$extractBase/$entry"; return $path if -f "$path/installer/setup"; } return ''; } sub runSetupUpgrade { my ($src, $extraArgs) = @_; my $setup = "$src/installer/setup"; die "setup not found in $src\n" unless -f $setup; my @cmd = ('perl', $setup, '--upgrade'); push @cmd, '--quiet' if $quiet; if (defined($extraArgs) && ref($extraArgs) eq 'ARRAY') { push @cmd, @{$extraArgs}; } my $rc = runCommandList(\@cmd, 0); return $rc; } # the checksum of the tarball that was last installed, '' when none is recorded sub readInstalledChecksum { my $path = $stateDir . '/update_installed.sha256'; return '' unless $stateDir ne '' && -f $path; my ($line) = split /\n/, readTextFile($path); return parseShaLine($line); } # record the checksum of the tarball just installed; a failure is logged and does not fail the update sub writeInstalledChecksum { my $sum = shift; return if $sum eq '' || $stateDir eq ''; make_path($stateDir) unless -d $stateDir; my $path = $stateDir . '/update_installed.sha256'; if (open my $fh, '>', $path) { print {$fh} $sum . "\n"; close($fh); } else { appendLog($logPath, "warning: could not record the installed checksum in $path: $!"); } } sub updateFromTarball { my ($urls, $bindir) = @_; die "no update URL: set update.url (and optionally update.sha256_url) in the sysinfo_client config, or pass --url\n" if $urls->{'tarball'} eq ''; my $tarPath = $workdir . '/sysinfo-client.tgz'; my $sumPath = $workdir . '/sysinfo-client.tgz.sha256'; # the checksum file is tiny: fetch it first, so an unchanged release costs no tarball download my $expected = ''; if ($urls->{'checksum'} ne '') { if (tryFetch($urls->{'checksum'}, $sumPath, 0)) { my $sumText = readTextFile($sumPath); my ($line) = split /\n/, $sumText; $expected = parseShaLine($line); } else { my $msg = 'warning: no checksum at ' . $urls->{'checksum'} . ', continuing'; appendLog($logPath, $msg); print $msg . "\n"; } } if ($expected eq '' && $requireChecksum) { die "checksum required but unavailable\n"; } my $recorded = readInstalledChecksum(); if (!$force && $expected ne '' && -f "$bindir/sysinfo_client" && $recorded eq $expected) { my $msg = 'nochange: installed release matches the published checksum (tarball not downloaded)'; appendLog($logPath, $msg); print $msg . "\n" unless $quiet; return 1; } die "tarball fetch failed\n" unless tryFetch($urls->{'tarball'}, $tarPath); if ($expected ne '') { my $actual = calcSha($tarPath); die "checksum mismatch\n" if $actual eq '' || $actual ne $expected; } my $extractBase = $workdir . '/extract'; make_path($extractBase); my $src = extractTarball($tarPath, $extractBase); die "could not locate installer/setup in tarball\n" if $src eq ''; my $compileRc = runCommandList(['perl', '-c', $src . '/sysinfo_client'], 0, 1); die "tarball sysinfo_client did not compile\n" if $compileRc != 0; my $newVersion = readVersionFromTree($src); my $installedVersion = readVersionFromTree($bindir); # a recorded checksum that differs from the published one means the content changed, whatever the version says my $contentChanged = ($expected ne '' && $recorded ne '' && $recorded ne $expected) ? 1 : 0; if (!$force && !$contentChanged && $newVersion ne '' && $installedVersion ne '' && $newVersion eq $installedVersion) { my $msg = 'nochange: installed version ' . $installedVersion . ' matches tarball'; appendLog($logPath, $msg); print $msg . "\n" unless $quiet; # same version, and no recorded checksum to say otherwise: take the published release as the baseline writeInstalledChecksum($expected); return 1; } my $setupRc = runSetupUpgrade($src); writeInstalledChecksum($expected) if $setupRc == 0; return $setupRc; } sub updateFromSvn { my ($urls, $bindir) = @_; my $rc; if ($wantedVersion ne '') { # switching is a manual act: the repository is read from the working copy itself (or --svn-url), never from the config my $target = $svnUrl; if ($target eq '') { my ($url) = slurpCommand(['svn', 'info', $bindir]) =~ /^URL:\s*(\S+)/m; $target = defined($url) ? $url : ''; } die "cannot tell which repository $bindir came from (svn info gave no URL); pass --svn-url\n" if $target eq ''; my $switchTarget = $target; # the repository root is the URL minus trunk|testing|stable and the unix/ directory below it $switchTarget =~ s{/(?:trunk|testing|stable)(?:/unix)?/?$}{}; $switchTarget = $switchTarget . '/tags/' . $wantedVersion . '/unix'; $rc = runCommandList(['svn', 'switch', '--ignore-ancestry', $switchTarget, $bindir], 0); die "svn switch failed\n" if $rc != 0; } else { $rc = runCommandList(['svn', 'update', $bindir], 0); die "svn update failed\n" if $rc != 0; } return runSetupUpgrade($bindir); } sub loadUpdateConfig { my ($cfg, $osName) = @_; my %out; my $row = $cfg->{'operatingSystems'}{$osName}; return \%out unless defined($row) && defined($row->{'config'}); my $path = rootPath(getRootPrefix(), $row->{'config'}); return \%out unless -f $path; my $yaml = YAML::Tiny->read($path); return \%out unless $yaml && ref($yaml->[0]) eq 'HASH'; my $doc = $yaml->[0]; return \%out unless ref($doc->{'update'}) eq 'HASH'; my $u = $doc->{'update'}; $out{'method'} = $u->{'method'} if defined($u->{'method'}); $out{'url'} = $u->{'url'} if defined($u->{'url'}); $out{'sha256_url'} = $u->{'sha256_url'} if defined($u->{'sha256_url'}); return \%out; } sub isSvnWorkingCopy { my $path = shift; return 0 unless defined($path) && -d "$path/.svn"; return 0 unless -x '/usr/bin/svn' || -x '/usr/local/bin/svn'; my $out = slurpCommand(['svn', 'info', $path]); return $out =~ /Working Copy Root Path:/ ? 1 : 0; } Getopt::Long::Configure('bundling'); GetOptions( 'help|h' => \$help, 'version-info|v' => \$showVersion, 'quiet|q' => \$quiet, 'force' => \$force, 'require-checksum' => \$requireChecksum, 'method=s' => \$method, 'url=s' => \$tarballUrl, 'sha256-url=s' => \$checksumUrl, 'svn-url=s' => \$svnUrl, 'version=s' => \$wantedVersion, 'os|o=s' => \$os, 'workdir=s' => \$workdir, ) or die "Error parsing command line\n"; if ($help) { printHelp(); exit 0; } if ($showVersion) { print basename($0) . " $VERSION\n"; exit 0; } $method = lc($method); die "invalid method\n" unless $method =~ /^(auto|tarball|svn)$/; if ($workdir eq '') { $workdir = tempdir('sysinfo_update_XXXXXX', TMPDIR => 1, CLEANUP => 1); } else { make_path($workdir) unless -d $workdir; } my $cfg = loadInstallerConfig("$FindBin::Bin/installer/installer_config.pl"); my $osName = detectOs(); die "unsupported operating system\n" unless exists $cfg->{'operatingSystems'}{$osName}; my $updateCfg = loadUpdateConfig($cfg, $osName); my $bindir = rootPath(getRootPrefix(), $cfg->{'operatingSystems'}{$osName}{'bindir'}); die "bindir missing\n" unless -d $bindir; $stateDir = rootPath(getRootPrefix(), $cfg->{'operatingSystems'}{$osName}{'confdir'}); if ($method eq 'auto' && defined($updateCfg->{'method'})) { my $cfgMethod = lc($updateCfg->{'method'}); if ($cfgMethod =~ /^(auto|tarball|svn)$/) { $method = $cfgMethod; } } my $current = readVersionFromTree($bindir); my $urls = resolveReleaseEndpoints($cfg, $updateCfg); my $finalMethod = $method; if ($finalMethod eq 'auto') { $finalMethod = isSvnWorkingCopy($bindir) ? 'svn' : 'tarball'; } # the log follows SYSINFO_ROOT like every other path (so tests, even as root, never write the real # /var/log); when its directory is not writable the log goes beside the script instead $logPath = rootPath(getRootPrefix(), $logPath); if (!-w dirname($logPath)) { $logPath = "$FindBin::Bin/sysinfo_update.log"; } appendLog($logPath, "update start method=$finalMethod current=$current target=$wantedVersion\n"); my $rc = 1; eval { if ($finalMethod eq 'tarball') { $rc = updateFromTarball($urls, $bindir); } else { $rc = updateFromSvn($urls, $bindir); } 1; } or do { my $err = $@ || 'update failure'; appendLog($logPath, "update failed: $err\n"); print STDERR $err; exit 2; }; if ($rc != 0) { if ($rc == 1) { appendLog($logPath, "update nochange\n"); exit 1; } appendLog($logPath, "update failed setup rc=$rc\n"); print STDERR "setup upgrade failed rc=$rc\n"; exit $rc; } appendLog($logPath, "update completed\n"); print "update completed\n" unless $quiet; exit 0;