#! /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. # Revision History: # 2026-10-13 Copilot v3.0.0 stage-3 setup orchestration rewrite # 2026-10-13 Copilot v3.0.1 migration rollback, seed URL fetch, and result version fixes # 2026-10-16 Claude v3.0.3 a checkout made directly into bindir is installed in place (configure, permissions, # schedule) instead of reporting nochange; legacy cleanup removes the old schedule before the # old bindir and no longer fails when setup is run from inside the old bindir (bug 205) # 2026-10-14 Claude v3.0.2 YAML::Tiny loaded after the dependency step instead of at compile time; # version probe made quiet when sysinfo_client cannot compile without it # 2026-10-27 Claude v3.0.5 output (including validateDependencies and the package/cpan installs) is also written to # /tmp/sysinfo_client_install.log, so a failed dependency install can be diagnosed afterwards # 2026-10-20 Claude v3.0.4 restarts itself once when dependency installs replaced perl (FreeBSD pkg upgrades perl5), instead of failing with "YAML::Tiny is not available" use strict; use warnings; # find our location and use it for searching for libraries BEGIN { use FindBin; use File::Spec; # use libraries from the directory this script is in use lib File::Spec->catdir($FindBin::Bin); # and its parent use lib File::Spec->catdir( $FindBin::Bin . '/../' ); } use version; our $VERSION = version->declare('v3.0.5'); use Cwd qw(abs_path getcwd); use File::Basename qw(basename dirname); use File::Copy qw(copy); use File::Path qw(make_path); use Getopt::Long; # YAML::Tiny is not loaded here: it may be missing on a fresh host, and installing it is # validateDependencies' job. It is loaded below, right after that step (see "loadYamlTiny"). use installer_shared qw( getRootPrefix rootPath ensureDir writeTextFile readTextFile appendLog runCommandList removeTreeIfExists movePath copyTree loadInstallerConfig nowStamp slurpCommand tryFetch teeOutputToLog ); my $os = ''; my $dryRun = 0; my $quiet = 0; my $debug = 0; my $help = 0; my $showVersion = 0; my $seed = ''; my $upgrade = 0; my $force = 0; my $root = ''; my %legacyRollbackState; my $resultVersion = 'v0.0.0'; my @logs; ####################################################### # # logLine( $text ) # # Appends one message to in-memory installer log. # # Parameters: # $text - message text # # Returns: # none # # Side Effects: # mutates @logs and writes STDOUT when not quiet # ####################################################### sub logLine { my $text = shift; push @logs, $text; print "$text\n" unless $quiet; } ####################################################### # # printHelp() # # Displays command usage text. # # Parameters: # none # # Returns: # none # # Side Effects: # writes STDOUT # ####################################################### sub printHelp { print basename($0) . " $VERSION\n"; print <<'END_HELP'; setup [options] Options: --os|-o operating system token --quiet|-q unattended run (no STDIN reads) --dryrun|-n no filesystem/package changes --upgrade run upgrade install flow (.new/.previous swap) --seed|-s seed source for fresh config --force force migration conflict resolution --root test root prefix --version|-v show version and exit --help|-h show help END_HELP } ####################################################### # # detectOs() # # Resolves current OS token using determineOS when missing. # # Parameters: # none # # Returns: # scalar OS token # # Side Effects: # runs determineOS helper script # ####################################################### sub detectOs { return $os if $os ne ''; my $rootPrefix = getRootPrefix($root); local $ENV{'SYSINFO_ROOT'} = $rootPrefix if defined($rootPrefix) && $rootPrefix ne ''; my $det = slurpCommand(['perl', "$FindBin::Bin/determineOS"]); chomp $det if defined($det); return defined($det) ? $det : ''; } ####################################################### # # decodePathSet( $cfg, $osName, $rootPrefix ) # # Builds rooted OS-specific path structure. # # Parameters: # $cfg - installer config hash ref # $osName - OS token # $rootPrefix - SYSINFO_ROOT override value # # Returns: # hash reference of rooted/unrooted path fields # # Side Effects: # none # ####################################################### sub decodePathSet { my ($cfg, $osName, $rootPrefix) = @_; my $row = $cfg->{'operatingSystems'}{$osName}; my %paths = ( 'bindirRaw' => $row->{'bindir'}, 'confdirRaw' => $row->{'confdir'}, 'configRaw' => $row->{'config'}, 'scheduleRaw' => $row->{'schedule'}, 'legacyBindirRaw' => $row->{'legacyBindir'}, 'legacyConfdirRaw' => $row->{'legacyConfdir'}, 'legacyConfigRaw' => $row->{'legacyConfig'}, 'legacyScheduleRaw' => $row->{'legacySchedule'}, 'opnsenseActionsRaw' => $row->{'opnsenseActions'}, 'owner' => $row->{'owner'}, 'group' => $row->{'group'}, ); foreach my $k (qw(bindir confdir config schedule legacyBindir legacyConfdir legacyConfig legacySchedule opnsenseActions)) { my $raw = $paths{$k . 'Raw'}; $paths{$k} = $raw eq '' ? '' : rootPath($rootPrefix, $raw); } return \%paths; } ####################################################### # # runDependencies( $osName, $rootPrefix ) # # Executes validateDependencies helper with current flags. # # Parameters: # $osName - OS token # $rootPrefix - root prefix path # # Returns: # integer exit status # # Side Effects: # runs validateDependencies script # ####################################################### sub runDependencies { my ($osName, $rootPrefix) = @_; my @cmd = ( 'perl', "$FindBin::Bin/validateDependencies", '--os', $osName, ); push @cmd, '--quiet' if $quiet; push @cmd, '--dryrun' if $dryRun; push @cmd, '--root', $rootPrefix if defined($rootPrefix) && $rootPrefix ne ''; return runCommandList(\@cmd, 0); } ####################################################### # # loadYamlConfig( $path ) # # Loads YAML config hash from disk. # # Parameters: # $path - config file path # # Returns: # hash reference # # Side Effects: # file IO # ####################################################### sub loadYamlConfig { my $path = shift; return {} unless -f $path; my $yaml = YAML::Tiny->read($path); return {} unless $yaml && ref($yaml->[0]) eq 'HASH'; return $yaml->[0]; } ####################################################### # # writeYamlConfig( $path, $hash ) # # Writes YAML config hash to disk. # # Parameters: # $path - destination path # $hash - config hash ref # # Returns: # boolean success # # Side Effects: # writes config file # ####################################################### sub writeYamlConfig { my ($path, $hash) = @_; return 1 if $dryRun; my $parent = dirname($path); make_path($parent) unless -d $parent; my $yaml = YAML::Tiny->new($hash); return $yaml->write($path) ? 1 : 0; } ####################################################### # # migrateConfigPaths( $cfgHash, $paths ) # # Rewrites only path keys allowed by spec 4.6 step 4. # # Parameters: # $cfgHash - config hash reference # $paths - decoded path structure # # Returns: # boolean changed flag # # Side Effects: # mutates config hash # ####################################################### sub migrateConfigPaths { my ($cfgHash, $paths) = @_; my $changed = 0; foreach my $listKey (qw(moduleDirs scriptDirs)) { next unless ref($cfgHash->{$listKey}) eq 'ARRAY'; my @newVals; foreach my $entry (@{$cfgHash->{$listKey}}) { my $newValue = $entry; if ($newValue =~ /sysinfo-client/) { $newValue =~ s/sysinfo-client/sysinfo_client/g; } my $legacyBind = $paths->{'legacyBindirRaw'}; my $legacyConf = $paths->{'legacyConfdirRaw'}; my $newBind = $paths->{'bindirRaw'}; my $newConf = $paths->{'confdirRaw'}; if ( index($newValue, $legacyBind) == 0 || index($newValue, $legacyConf) == 0 || index($newValue, $newBind) == 0 || index($newValue, $newConf) == 0 ) { $newValue =~ s{/modules$}{/sensors}; $newValue =~ s{/scripts$}{/transports}; } push @newVals, $newValue; $changed = 1 if $newValue ne $entry; } $cfgHash->{$listKey} = \@newVals; } return $changed; } ####################################################### # # migrateLegacy( $paths, $cfg ) # # Performs spec 4.6 legacy migration steps. # # Parameters: # $paths - decoded path structure # $cfg - installer config tables # # Returns: # integer status (0 success, 2 fail) # # Side Effects: # moves legacy directories/files and rewrites config # ####################################################### sub migrateLegacy { my ($paths, $cfg) = @_; my $legacyExists = (-d $paths->{'legacyBindir'}) || (-d $paths->{'legacyConfdir'}) || (-f $paths->{'legacyConfig'}); $legacyRollbackState{'legacyExists'} = $legacyExists ? 1 : 0; return 0 unless $legacyExists; logLine('legacy migration detected'); if (-d $paths->{'legacyConfdir'} && -d $paths->{'confdir'} && -f $paths->{'legacyConfig'} && -f $paths->{'config'} && !$force) { logLine('legacy migration conflict: both legacy and new config exist'); return 2; } if (-d $paths->{'legacyConfdir'} && -d $paths->{'confdir'} && -f $paths->{'legacyConfig'} && -f $paths->{'config'} && $force) { logLine('legacy migration conflict: --force keeping new config and leaving legacy confdir untouched'); return 0; } if (-d $paths->{'legacyConfdir'} && !-d $paths->{'confdir'}) { logLine('migrate: moving legacy confdir to new confdir'); return 2 unless movePath($paths->{'legacyConfdir'}, $paths->{'confdir'}, $dryRun); $legacyRollbackState{'movedConfdir'} = 1; } if (-d $paths->{'confdir'} && -f $paths->{'legacyConfig'} && !-f $paths->{'config'}) { my $targetOld = $paths->{'confdir'} . '/sysinfo-client.yaml'; if (!-f $targetOld) { logLine('migrate: moving legacy config file into new confdir'); return 2 unless movePath($paths->{'legacyConfig'}, $targetOld, $dryRun); $legacyRollbackState{'movedLegacyConfigAcross'} = 1; } } my $insideModules = $paths->{'confdir'} . '/modules'; my $insideScripts = $paths->{'confdir'} . '/scripts'; my $insideSensors = $paths->{'confdir'} . '/sensors'; my $insideTransports = $paths->{'confdir'} . '/transports'; if (-d $insideModules && !-d $insideSensors) { logLine('migrate: renaming conf modules to sensors'); return 2 unless movePath($insideModules, $insideSensors, $dryRun); } if (-d $insideScripts && !-d $insideTransports) { logLine('migrate: renaming conf scripts to transports'); return 2 unless movePath($insideScripts, $insideTransports, $dryRun); } my $targetOld = $paths->{'confdir'} . '/sysinfo-client.yaml'; if (-f $targetOld && !-f $paths->{'config'}) { $legacyRollbackState{'legacyConfigOriginal'} = readTextFile($targetOld); my $bak = $targetOld . '.v3.7.1.bak'; logLine('migrate: creating v3.7.1 backup'); return 2 unless writeTextFile($bak, $legacyRollbackState{'legacyConfigOriginal'}, $dryRun); $legacyRollbackState{'createdBackup'} = 1; logLine('migrate: renaming dash config to underscore config'); return 2 unless movePath($targetOld, $paths->{'config'}, $dryRun); $legacyRollbackState{'renamedDashConfig'} = 1; } if (-f $paths->{'config'}) { my $cfgHash = loadYamlConfig($paths->{'config'}); my $changed = migrateConfigPaths($cfgHash, $paths); if (!exists($cfgHash->{'update'})) { # no release URLs: they are site-specific and come from the seed or the config, never from the code $cfgHash->{'update'} = { 'method' => 'auto' }; $changed = 1; } if ($changed) { logLine('migrate: writing updated config path values and update block'); return 2 unless writeYamlConfig($paths->{'config'}, $cfgHash); } } if (-e rootPath(getRootPrefix($root), '/opt/camp/sysinfo')) { logLine('migrate: old trunk-era /opt/camp/sysinfo path found and left in place'); } return 0; } ####################################################### # # makeSchedule( $paths, $osName ) # # Creates schedule entries for current platform. # # Parameters: # $paths - decoded path structure # $osName - OS token # # Returns: # integer status (0 success) # # Side Effects: # writes symlinks/actions and may restart configd # ####################################################### sub makeSchedule { my ($paths, $osName) = @_; if ($osName eq 'opnsense') { my $actionsPath = $paths->{'opnsenseActions'}; my $content = "[reload]\n" . 'command:' . $paths->{'bindirRaw'} . "/sysinfo_client\n" . "parameter:\n" . "type:script\n" . "message:Collecting Systems Information\n" . "description:Collects Systems Info and sends to CAMP\n"; logLine('schedule: writing opnsense actions file'); return 2 unless writeTextFile($actionsPath, $content, $dryRun); my $rc = runCommandList(['service', 'configd', 'restart'], $dryRun); if ($rc != 0 && !$dryRun) { logLine('schedule: warning configd restart failed'); } return 0; } return 0 if $paths->{'schedule'} eq ''; my $parent = dirname($paths->{'schedule'}); return 2 unless ensureDir($parent, $dryRun); if (-l $paths->{'schedule'} || -f $paths->{'schedule'}) { return 2 unless removeTreeIfExists($paths->{'schedule'}, $dryRun); } if (!$dryRun) { symlink($paths->{'bindir'} . '/sysinfo_client', $paths->{'schedule'}) or return 2; } logLine('schedule: created schedule entry'); return 0; } ####################################################### # # runPermissions( $osName, $rootPrefix ) # # Executes permissions helper script. # # Parameters: # $osName - OS token # $rootPrefix - root prefix path # # Returns: # integer status # # Side Effects: # runs installer/permissions # ####################################################### sub runPermissions { my ($osName, $rootPrefix) = @_; my @cmd = ('perl', "$FindBin::Bin/permissions", '--os', $osName); push @cmd, '--quiet' if $quiet; push @cmd, '--dryrun' if $dryRun; push @cmd, '--root', $rootPrefix if defined($rootPrefix) && $rootPrefix ne ''; return runCommandList(\@cmd, 0); } ####################################################### # # runConfigure( $paths, $seedArg ) # # Runs installer/mergeconfig -q and optionally bootstraps config from seed. # # Parameters: # $paths - decoded path structure # $seedArg - optional seed path/url # # Returns: # integer status # # Side Effects: # writes configuration files # ####################################################### sub runConfigure { my ($paths, $seedArg) = @_; my $dashConfig = $paths->{'confdir'} . '/sysinfo-client.yaml'; my $readClientNameFromPath = sub { my $path = shift; return '' unless -f $path; my $yaml = YAML::Tiny->read($path); return '' unless $yaml && ref($yaml->[0]) eq 'HASH'; my $name = defined($yaml->[0]{'clientName'}) ? $yaml->[0]{'clientName'} : ''; $name =~ s/^\s+|\s+$//g; return $name; }; my $resolveSeedContent = sub { my $seedValue = shift; my $templatePath = "$FindBin::Bin/sysinfo_client.conf.template.yaml"; if (defined($seedValue) && $seedValue ne '') { if ($seedValue =~ m{^[a-zA-Z][a-zA-Z0-9+.-]*://}) { my $seedFetch = $paths->{'confdir'} . '/.setup-seed.yaml'; if (tryFetch($seedValue, $seedFetch, $dryRun)) { my $seedText = readTextFile($seedFetch); removeTreeIfExists($seedFetch, $dryRun); return ($seedText, $seedValue) if $seedText ne ''; } logLine('config: seed URL fetch failed, falling back to local template'); return (readTextFile($templatePath), $templatePath); } my $seedText = readTextFile($seedValue); return ($seedText, $seedValue); } my $seedUrl = ''; my $templateYaml = YAML::Tiny->read($templatePath); if ($templateYaml && ref($templateYaml->[0]) eq 'HASH' && ref($templateYaml->[0]{'update'}) eq 'HASH') { $seedUrl = $templateYaml->[0]{'update'}{'seed_url'} // ''; } if ($seedUrl ne '') { my $seedFetch = $paths->{'confdir'} . '/.setup-seed.yaml'; if (tryFetch($seedUrl, $seedFetch, $dryRun)) { my $seedText = readTextFile($seedFetch); removeTreeIfExists($seedFetch, $dryRun); return ($seedText, $seedUrl) if $seedText ne ''; } logLine('config: default seed URL fetch failed, using local template'); } return (readTextFile($templatePath), $templatePath); }; if ($dryRun) { my $clientName = ''; $clientName = $readClientNameFromPath->($paths->{'config'}) if -f $paths->{'config'}; if ($clientName eq '' && -f $dashConfig) { $clientName = $readClientNameFromPath->($dashConfig); } if ($clientName eq '' && -f $paths->{'legacyConfig'}) { $clientName = $readClientNameFromPath->($paths->{'legacyConfig'}); } if ($clientName eq '' && $quiet && !-f $paths->{'config'} && !-f $dashConfig) { my ($seedContent, $seedSource) = $resolveSeedContent->($seedArg); my $yaml = $seedContent eq '' ? undef : YAML::Tiny->read_string($seedContent); if ($yaml && ref($yaml->[0]) eq 'HASH' && defined($yaml->[0]{'clientName'})) { $clientName = $yaml->[0]{'clientName'}; $clientName =~ s/^\s+|\s+$//g; } logLine('config: dryrun clientName check source ' . $seedSource); } return 2 if $quiet && $clientName eq ''; return 0; } if (!-f $paths->{'config'}) { my ($seedContent, $seedSource) = $resolveSeedContent->($seedArg); return 2 if $seedContent eq ''; return 2 unless writeTextFile($paths->{'config'}, $seedContent, $dryRun); logLine('config: created initial config from seed/template source ' . $seedSource); } my @cmd = ('perl', $paths->{'bindir'} . '/installer/mergeconfig', '--quiet', '--output', $paths->{'config'}, '--file', $paths->{'config'}); push @cmd, '--dryrun' if $dryRun; my $configurePath = $paths->{'bindir'} . '/installer/mergeconfig'; my $configureText = readTextFile($configurePath); my $serverList = rootPath(getRootPrefix($root), '/etc/server.info') . ',' . rootPath(getRootPrefix($root), '/usr/local/etc/server.info'); my $rc; if ($configureText =~ /SYSINFO_SERVER_INFO_SEARCH/) { local $ENV{'SYSINFO_SERVER_INFO_SEARCH'} = $serverList; $rc = runCommandList(\@cmd, 0); } elsif ($configureText =~ /SYSINFO_SERVERINFO_SEARCH/) { local $ENV{'SYSINFO_SERVERINFO_SEARCH'} = $serverList; $rc = runCommandList(\@cmd, 0); } elsif ($configureText =~ /SYSINFO_SERVER_INFO_PATHS/) { local $ENV{'SYSINFO_SERVER_INFO_PATHS'} = $serverList; $rc = runCommandList(\@cmd, 0); } else { $rc = runCommandList(\@cmd, 0); } if ($rc == 0 && $quiet) { my $clientName = $readClientNameFromPath->($paths->{'config'}); if ($clientName eq '') { return 3; } } return $rc; } ####################################################### # # smokeCheck( $paths ) # # Runs sysinfo_client --version smoke validation. # # Parameters: # $paths - decoded path structure # # Returns: # integer status # # Side Effects: # runs sysinfo_client executable # ####################################################### sub smokeCheck { my $paths = shift; return runCommandList(['perl', $paths->{'bindir'} . '/sysinfo_client', '--version'], $dryRun); } ####################################################### # # installByCopy( $paths, $sourceTree ) # # Installs by copying source tree to target bindir (fresh/upgrade). # # Parameters: # $paths - decoded path structure # $sourceTree - source root to copy from # # Returns: # integer status # # Side Effects: # writes/replaces bindir trees # ####################################################### sub installByCopy { my ($paths, $sourceTree) = @_; # a checkout made directly into bindir (svn co ... sysinfo_client) is the source tree # itself, so there is nothing to copy; carry on to configure, permissions and schedule my $inPlace = (-d $paths->{'bindir'} && abs_path($sourceTree) && abs_path($paths->{'bindir'}) && abs_path($sourceTree) eq abs_path($paths->{'bindir'})) ? 1 : 0; if (!$upgrade) { if ($inPlace) { logLine('install: source tree is bindir, skipping copy'); return 0; } if (-d $paths->{'bindir'}) { logLine('install: bindir already present; nochange'); return 1; } return 2 unless copyTree($sourceTree, $paths->{'bindir'}, $dryRun); logLine('install: copied tree to bindir'); return 0; } my $newTree = $paths->{'bindir'} . '.new'; my $prevTree = $paths->{'bindir'} . '.previous'; if ($inPlace) { logLine('install: upgrade source tree is bindir, skipping copy/swap'); return 0; } return 2 unless removeTreeIfExists($newTree, $dryRun); return 2 unless copyTree($sourceTree, $newTree, $dryRun); my $reportsPath = $paths->{'bindir'} . '/reports'; if (-d $reportsPath && !$dryRun) { ensureDir($newTree . '/reports', 0) or return 2; my $rc = runCommandList(['cp', '-a', $reportsPath . '/.', $newTree . '/reports/'], 0); return 2 if $rc != 0; } if (-d $prevTree || -f $prevTree) { return 2 unless removeTreeIfExists($prevTree, $dryRun); } if (-d $paths->{'bindir'}) { return 2 unless movePath($paths->{'bindir'}, $prevTree, $dryRun); } return 2 unless movePath($newTree, $paths->{'bindir'}, $dryRun); logLine('install: upgrade tree swap completed'); my $smokeRc = smokeCheck($paths); if ($smokeRc != 0) { logLine('install: smoke failed, rolling back to previous tree'); removeTreeIfExists($paths->{'bindir'}, $dryRun); movePath($prevTree, $paths->{'bindir'}, $dryRun); return 3; } return 0; } ####################################################### # # resolveSourceTree( $paths ) # # Resolves source tree used for install copy. # # Parameters: # $paths - decoded path structure # # Returns: # scalar source directory path # # Side Effects: # none # ####################################################### sub resolveSourceTree { my $paths = shift; my $candidate = abs_path($FindBin::Bin . '/..'); if (-d $paths->{'bindir'} && abs_path($paths->{'bindir'}) && $candidate eq abs_path($paths->{'bindir'})) { return $paths->{'bindir'}; } return $candidate; } ####################################################### # # readVersionToken( $text ) # # Extracts first version token from command output. # # Parameters: # $text - source text # # Returns: # scalar version token or empty string # # Side Effects: # none # ####################################################### sub readVersionToken { my $text = shift; return '' unless defined($text); return $1 if $text =~ /(v[0-9]+\.[0-9]+\.[0-9]+)/; return ''; } ####################################################### # # detectInstallVersion( $sourceTree ) # # Determines version of tree being installed. # # Parameters: # $sourceTree - source tree root # # Returns: # scalar version token # # Side Effects: # runs version helper scripts; their STDERR is discarded, because this runs before the # dependency step and sysinfo_client cannot even compile while YAML::Tiny is missing # (the SYSTEM_VERSION fallback below then supplies the version) # ####################################################### sub detectInstallVersion { my $sourceTree = shift; my $savedStderr; open($savedStderr, '>&', \*STDERR) or $savedStderr = undef; open(STDERR, '>', '/dev/null') if defined($savedStderr); my $out = slurpCommand(['perl', $sourceTree . '/sysinfo_client', '--version']); if (defined($savedStderr)) { open(STDERR, '>&', $savedStderr); close($savedStderr); } my $token = readVersionToken($out); return $token if $token ne ''; $out = slurpCommand(['perl', $sourceTree . '/SYSTEM_VERSION']); $token = readVersionToken($out); return $token if $token ne ''; return 'v0.0.0'; } ####################################################### # # moveLegacyReports( $paths ) # # Moves legacy reports into new reports directory without # overwriting existing names. # # Parameters: # $paths - decoded path set # # Returns: # boolean success # # Side Effects: # moves report files across trees # ####################################################### sub moveLegacyReports { my $paths = shift; my $srcReports = $paths->{'legacyBindir'} . '/reports'; return 1 unless -d $srcReports; my $dstReports = $paths->{'bindir'} . '/reports'; return 0 unless ensureDir($dstReports, $dryRun); opendir(my $dh, $srcReports) or return 0; my @entries = grep { $_ ne '.' && $_ ne '..' } readdir($dh); closedir($dh); foreach my $name (@entries) { my $src = $srcReports . '/' . $name; my $dst = $dstReports . '/' . $name; if (-f $src) { if (-e $dst || -l $dst) { logLine('migrate: skipping legacy report already present ' . $name); next; } if (!$dryRun) { copy($src, $dst) or return 0; unlink($src) or return 0; } logLine('migrate: moved legacy report ' . $name); } } return 1; } ####################################################### # # finalizeLegacyAfterSmoke( $paths, $osName ) # # Completes legacy cleanup after verified install. # # Parameters: # $paths - decoded path set # $osName - OS token # # Returns: # integer status # # Side Effects: # removes legacy schedule, then legacy bindir, after smoke pass; changes cwd when it # is inside the legacy bindir # ####################################################### sub finalizeLegacyAfterSmoke { my ($paths, $osName) = @_; return 0 unless $legacyRollbackState{'legacyExists'}; # the schedule goes first, so a failure removing the bindir cannot leave the old cron job running if ($paths->{'legacySchedule'} ne '' && (-l $paths->{'legacySchedule'} || -f $paths->{'legacySchedule'})) { logLine('migrate: removing legacy schedule after smoke success'); return 2 unless removeTreeIfExists($paths->{'legacySchedule'}, $dryRun); } if (-d $paths->{'legacyBindir'}) { logLine('migrate: moving legacy reports into new bindir'); return 2 unless moveLegacyReports($paths); # setup run from inside the legacy bindir (svn switch in place) has it as cwd, and # remove_tree refuses to delete the cwd; the copy in bindir carries the working copy my $cwd = getcwd(); my $legacyAbs = abs_path($paths->{'legacyBindir'}); if (!$dryRun && defined($cwd) && defined($legacyAbs) && ($cwd eq $legacyAbs || index($cwd, $legacyAbs . '/') == 0)) { chdir($paths->{'bindir'}) or chdir('/') or return 2; logLine('migrate: setup was run from the legacy bindir; the shell must cd to ' . $paths->{'bindir'}); } logLine('migrate: removing legacy bindir after smoke success'); return 2 unless removeTreeIfExists($paths->{'legacyBindir'}, $dryRun); } return 0; } ####################################################### # # rollbackLegacyMigration( $paths ) # # Restores legacy layout when install fails after migration. # # Parameters: # $paths - decoded path set # # Returns: # none # # Side Effects: # renames config/confdir back to legacy names # ####################################################### sub rollbackLegacyMigration { my $paths = shift; return unless $legacyRollbackState{'legacyExists'}; my $dashConfig = $paths->{'confdir'} . '/sysinfo-client.yaml'; my $underscoreConfig = $paths->{'config'}; my $dashBak = $dashConfig . '.v3.7.1.bak'; my $underscoreBak = $underscoreConfig . '.bak'; if ($legacyRollbackState{'renamedDashConfig'}) { if (!$dryRun) { if (defined($legacyRollbackState{'legacyConfigOriginal'})) { writeTextFile($dashConfig, $legacyRollbackState{'legacyConfigOriginal'}, 0); removeTreeIfExists($underscoreConfig, 0); } } logLine('rollback: restored legacy dash config name and content'); } if ($legacyRollbackState{'createdBackup'}) { removeTreeIfExists($dashBak, $dryRun); logLine('rollback: removed migration backup file'); } if (-f $underscoreBak) { removeTreeIfExists($underscoreBak, $dryRun); logLine('rollback: removed configure backup for underscore config'); } if ($legacyRollbackState{'movedLegacyConfigAcross'} && -f $dashConfig && !-f $paths->{'legacyConfig'}) { movePath($dashConfig, $paths->{'legacyConfig'}, $dryRun); logLine('rollback: moved legacy config file back to legacy confdir'); } if ($legacyRollbackState{'movedConfdir'} && -d $paths->{'confdir'} && !-d $paths->{'legacyConfdir'}) { movePath($paths->{'confdir'}, $paths->{'legacyConfdir'}, $dryRun); logLine('rollback: moved confdir back to legacy path'); } if (-d $paths->{'bindir'} || -l $paths->{'bindir'}) { removeTreeIfExists($paths->{'bindir'}, $dryRun); logLine('rollback: removed new bindir tree'); } if ($paths->{'schedule'} ne '' && (-l $paths->{'schedule'} || -f $paths->{'schedule'})) { removeTreeIfExists($paths->{'schedule'}, $dryRun); logLine('rollback: removed new schedule entry'); } if ($paths->{'opnsenseActions'} ne '' && (-l $paths->{'opnsenseActions'} || -f $paths->{'opnsenseActions'})) { removeTreeIfExists($paths->{'opnsenseActions'}, $dryRun); logLine('rollback: removed new opnsense actions file'); } } ####################################################### # # finalizeResult( $state, $versionText, $message, $code ) # # Prints required RESULT line and exits. # # Parameters: # $state - ok|nochange|fail # $versionText - version string # $message - status message # $code - process exit code # # Returns: # none # # Side Effects: # writes RESULT line and exits # ####################################################### sub finalizeResult { my ($state, $versionText, $message, $code) = @_; print 'RESULT: ' . $state . ' ' . $versionText . ' ' . $message . "\n"; exit $code; } Getopt::Long::Configure('bundling'); my @startArgs = @ARGV; GetOptions( 'os|o=s' => \$os, 'dryrun|n' => \$dryRun, 'quiet|q' => \$quiet, 'seed|s=s' => \$seed, 'upgrade' => \$upgrade, 'force' => \$force, 'root=s' => \$root, 'version|v' => \$showVersion, 'help|h' => \$help, 'debug!' => \$debug, ) or die "Error parsing command line\n"; if ($help) { printHelp(); exit 0; } if ($showVersion) { print basename($0) . ' ' . $VERSION . "\n"; exit 0; } # keep a copy of everything printed from here on (dependency installs included); a restart inherits it my $installLog = '/tmp/sysinfo_client_install.log'; if (teeOutputToLog($installLog)) { print '==== setup ' . $VERSION . ' ' . nowStamp() . ' args: ' . join(' ', @startArgs) . " ====\n" unless $ENV{'SYSINFO_SETUP_RESTARTED'}; } else { print STDERR "could not open $installLog; output is not being logged\n"; } my $rootPrefix = getRootPrefix($root); my $cfg = loadInstallerConfig("$FindBin::Bin/installer_config.pl"); my $osName = detectOs(); if (!exists($cfg->{'operatingSystems'}{$osName})) { finalizeResult('fail', $resultVersion, 'unsupported operating system', 2); } my $paths = decodePathSet($cfg, $osName, $rootPrefix); my $sourceTree = resolveSourceTree($paths); $resultVersion = detectInstallVersion($sourceTree); my $depRc = runDependencies($osName, $rootPrefix); if ($depRc != 0) { finalizeResult('fail', $resultVersion, 'dependency validation failed', 2); } # a package install can replace perl itself (FreeBSD: the p5-* packages pull in a newer perl5). This process is # then the old perl, whose library directories are gone, so it cannot load what was just installed even though a # fresh perl can. Start over once under whatever perl is installed now; the second run finds nothing to install. if (!$dryRun && !$ENV{'SYSINFO_SETUP_RESTARTED'}) { my $freshVersion = slurpCommand(['perl', '-e', 'print $]']); $freshVersion = '' unless defined($freshVersion); $freshVersion =~ s/\s+//g; if ($freshVersion ne '' && ($freshVersion ne "$]" || !eval { require YAML::Tiny; 1 })) { logLine('restarting setup under the perl that is installed now'); $ENV{'SYSINFO_SETUP_RESTARTED'} = 1; exec('perl', "$FindBin::Bin/$FindBin::Script", @startArgs); } } # loadYamlTiny: everything below reads or writes YAML, so load the module now that # validateDependencies has had its chance to install it. A dry run installs nothing, # so on a host without it we stop here instead of failing. if (!eval { require YAML::Tiny; 1 }) { if ($dryRun) { finalizeResult('ok', $resultVersion, 'dry run: YAML::Tiny is missing and would be installed; later steps not simulated', 0); } finalizeResult('fail', $resultVersion, 'YAML::Tiny is not available', 2); } my $migRc = migrateLegacy($paths, $cfg); if ($migRc != 0) { finalizeResult('fail', $resultVersion, 'legacy migration failed', 2); } ensureDir($paths->{'confdir'}, $dryRun) or finalizeResult('fail', $resultVersion, 'could not create confdir', 2); my $installRc = installByCopy($paths, $sourceTree); if ($installRc == 1) { finalizeResult('nochange', $resultVersion, 'already installed', 1); } if ($installRc == 3) { rollbackLegacyMigration($paths) if $legacyRollbackState{'legacyExists'}; finalizeResult('fail', $resultVersion, 'smoke check failed, restored previous', 2); } if ($installRc != 0) { rollbackLegacyMigration($paths) if $legacyRollbackState{'legacyExists'}; finalizeResult('fail', $resultVersion, 'install copy failed', 2); } my $confRc = runConfigure($paths, $seed); if ($confRc != 0) { rollbackLegacyMigration($paths) if $legacyRollbackState{'legacyExists'}; if ($confRc == 3) { finalizeResult('fail', $resultVersion, 'clientName empty: set it in the seed or in server.info (owner:)', 2); } finalizeResult('fail', $resultVersion, 'configure failed', 2); } my $permRc = runPermissions($osName, $rootPrefix); if ($permRc != 0) { rollbackLegacyMigration($paths) if $legacyRollbackState{'legacyExists'}; finalizeResult('fail', $resultVersion, 'permissions failed', 2); } my $schedRc = makeSchedule($paths, $osName); if ($schedRc != 0) { rollbackLegacyMigration($paths) if $legacyRollbackState{'legacyExists'}; finalizeResult('fail', $resultVersion, 'schedule setup failed', 2); } my $smokeRc = smokeCheck($paths); if ($smokeRc != 0) { rollbackLegacyMigration($paths) if $legacyRollbackState{'legacyExists'}; if ($upgrade) { my $prev = $paths->{'bindir'} . '.previous'; if (-d $prev || -f $prev) { removeTreeIfExists($paths->{'bindir'}, $dryRun); movePath($prev, $paths->{'bindir'}, $dryRun); } } finalizeResult('fail', $resultVersion, 'smoke check failed', 2); } my $legacyFinalizeRc = finalizeLegacyAfterSmoke($paths, $osName); if ($legacyFinalizeRc != 0) { finalizeResult('fail', $resultVersion, 'legacy cleanup failed', 2); } finalizeResult('ok', $resultVersion, $upgrade ? 'upgraded' : 'installed', 0);