#! /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 v2.0.0 stage-3 dependency policy rewrite # 2026-10-13 Copilot v2.0.1 list-form OS detection and metadata/header alignment # 2026-10-14 Claude v2.0.2 YAML::Tiny: package manager if present, then cpanm; recheck after each try # 2026-10-17 Claude v2.0.3 cpan is tried after cpanm (OPNsense has neither a package nor cpanm); skipped commands are named; # LWP modules for upload_http are installed the same way, a failure only warns (bug 209) # 2026-10-27 Claude v2.0.5 a failed install says which method is tried next # 2026-10-20 Claude v2.0.4 a module counts as installed when a fresh perl can load it (a package install may replace perl itself, FreeBSD) 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 Getopt::Long; use File::Basename; use version; our $VERSION = version->declare('v2.0.5'); use installer_shared qw( loadInstallerConfig runCommandList commandExists slurpCommand getRootPrefix ); my $help = 0; my $showVersion = 0; my $quiet = 0; my $dryRun = 0; my $os = ''; my $root = ''; ####################################################### # # printHelp() # # Displays usage and exits. # # Parameters: # none # # Returns: # none # # Side Effects: # writes STDOUT # ####################################################### sub printHelp { print basename($0) . " $VERSION\n"; print <<'END_HELP'; validateDependencies [options] Options: --os|-o operating system token (debian/freebsd/opnsense) --quiet|-q unattended install where possible --dryrun|-n no filesystem/package changes --root root prefix for test runs --version|-v show version and exit --help|-h show this help END_HELP } ####################################################### # # detectOs() # # Resolves OS token by running determineOS when not provided. # # 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 : ''; } ####################################################### # # installModule( $module, $row, $osName ) # # Installs one missing Perl module: the OS package manager first, if it is # on this machine, then cpanm, then cpan. The module is rechecked after each. # # Parameters: # $module - module name (e.g. YAML::Tiny) # $row - installer commands keyed by OS token, 'cpanm' and 'cpan' # $osName - OS token # # Returns: # 1 when the module is (now) loadable or this is a dry run, 0 otherwise # # Side Effects: # may run package-manager install commands; writes STDOUT/STDERR # ####################################################### sub moduleLoads { my $module = shift; return 1 if eval "require $module; 1"; return 0 unless $module =~ /^[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z0-9_]+)*$/; # a package install can replace perl itself (FreeBSD: the p5-* packages pull in a newer perl5), after which # this running perl has lost its library directories and cannot find what was just installed; ask a fresh perl return runCommandList(['perl', '-e', 'my $m = shift; eval "require $m; 1" or exit 1', $module], 0, 1) == 0 ? 1 : 0; } sub installModule { my ($module, $row, $osName) = @_; return 1 if moduleLoads($module); my @attempts; foreach my $key ($osName, 'cpanm', 'cpan') { my $installer = $row->{$key}; push @attempts, $installer if defined($installer) && commandExists($installer->[0]); } if (!@attempts) { print STDERR "missing $module and none of a package manager, cpanm or cpan is available to install it\n"; return 0; } foreach my $i (0 .. $#attempts) { my $installer = $attempts[$i]; my $next = $i < $#attempts ? $attempts[$i + 1][0] : ''; if (!$quiet) { print "Installing dependency $module using: " . join(' ', @{$installer}) . "\n"; } # cpan asks questions on its first run unless told to take the defaults local $ENV{'PERL_MM_USE_DEFAULT'} = 1; my $rc = runCommandList($installer, $dryRun); if ($rc != 0) { print STDERR "failed installing $module with $installer->[0] (exit $rc)" . ($next ne '' ? "; falling back to $next" : '') . "\n"; next; } # a dry run installs nothing, so there is nothing to check return 1 if $dryRun; return 1 if moduleLoads($module); print STDERR "$module still unavailable after $installer->[0]" . ($next ne '' ? "; falling back to $next" : '') . "\n"; } return 0; } ####################################################### # # installRequired( $deps, $osName ) # # Installs required dependencies when missing. # # Parameters: # $deps - required dependency hash reference # $osName - OS token # # Returns: # integer exit status # # Side Effects: # may run package-manager install commands # ####################################################### sub installRequired { my ($deps, $osName) = @_; if ($] < $deps->{'perl'}{'minVersion'}) { print STDERR "perl version too old; need >= $deps->{'perl'}{'minVersion'}\n"; return 2; } if (!installModule('YAML::Tiny', $deps->{'YAML::Tiny'}, $osName)) { print STDERR "could not install required YAML::Tiny\n"; return 2; } return 0; } ####################################################### # # installTransportModules( $deps, $osName ) # # Installs Perl modules that only some transports need (LWP for upload_http). # A failure is a warning, not an error: a host may use other transports. # # Parameters: # $deps - transport dependency hash reference # $osName - OS token # # Returns: # none # # Side Effects: # may run package-manager install commands; writes STDERR # ####################################################### sub installTransportModules { my ($deps, $osName) = @_; foreach my $module (sort keys %{$deps}) { next if installModule($module, $deps->{$module}, $osName); print STDERR "warning: could not install $module; the $deps->{$module}{'transport'} transport will not work until it is installed\n"; } } ####################################################### # # reportOptional( $optDeps, $osName ) # # Reports optional binary availability without installing. # # Parameters: # $optDeps - optional dependency hash reference # $osName - OS token # # Returns: # none # # Side Effects: # writes STDOUT # ####################################################### sub reportOptional { my ($optDeps, $osName) = @_; foreach my $name (sort keys %{$optDeps}) { my $row = $optDeps->{$name}; next if $row->{'linuxOnly'} && $osName ne 'debian'; my $binary = $row->{'binary'}; my $state = commandExists($binary) ? 'present' : 'missing'; next if $quiet && $state eq 'present'; my $line = "optional dependency $name ($binary): $state"; $line .= " - $row->{'note'}" if defined($row->{'note'}); print "$line\n"; } } Getopt::Long::Configure('bundling'); GetOptions( 'os|o=s' => \$os, 'quiet|q' => \$quiet, 'dryrun|n' => \$dryRun, 'root=s' => \$root, 'version|v' => \$showVersion, 'help|h' => \$help, ) or die "Error parsing command line\n"; if ($help) { printHelp(); exit 0; } if ($showVersion) { print basename($0) . " $VERSION\n"; exit 0; } my $cfg = loadInstallerConfig("$FindBin::Bin/installer_config.pl"); my $osName = detectOs(); if (!exists($cfg->{'operatingSystems'}{$osName})) { print STDERR "unsupported operating system\n"; exit 2; } my $requiredRc = installRequired($cfg->{'requiredDependencies'}, $osName); if ($requiredRc != 0) { exit $requiredRc; } installTransportModules($cfg->{'transportDependencies'}, $osName); reportOptional($cfg->{'optionalDependencies'}, $osName); exit 0;