sudo/scripts/build_pkgs

1330 lines
37 KiB
Perl
Executable File

#!/usr/bin/env perl
#
# Build sudo packages on the various build hosts and copy back artifacts.
# Uses the ssh agent to avoid prompting for password when connecting.
# This script is used to build sudo release packages and probably not
# useful for anything else.
use strict;
use warnings;
use Fcntl ":flock";
use File::Temp ":mktemp";
use FileHandle;
use Getopt::Long qw(:config no_ignore_case bundling);
use IO::Socket::IP -register;
use IPC::Run qw(harness start pump finish run timeout);
use POSIX qw(setsid :sys_wait_h :signal_h);
use Parse::CPAN::Meta;
use Pod::Usage;
my $checklogs = 0;
my $cleanup_only = 0;
my $conf_file = 'build_pkgs.conf';
my $debug = 0;
my $help = 0;
my $ignore_cache = 0;
my $quiet = 0;
my $repo = "/repos/sudo";
my $rev = "tip";
my $sudo_tmpdir;
my $tarball;
my $tarball_base;
my $verbose = 0;
my $main_pid = $$;
GetOptions("check-logs" => \$checklogs,
"cleanup" => \$cleanup_only,
"config|c=s" => \$conf_file,
"debug|d" => \$debug,
"help|h" => \$help,
"quiet|q" => \$quiet,
"verbose|v" => \$verbose,
"ignore-cache|i" => \$ignore_cache,
"file|f=s" => \$tarball,
"repository|R=s" => \$repo,
"revision|r=s" => \$rev)
or pod2usage(2);
pod2usage(1) if $help;
# Debug and verbose override quiet.
$verbose = 1 if $debug;
$quiet = 0 if $verbose;
# Read config data (using Parse::CPAN::Meta is cheating a bit).
my @yaml = Parse::CPAN::Meta->load_file($conf_file) ||
die "$0: unable to read $conf_file: $!\n";
die "$0: missing required home setting in $conf_file\n"
unless (defined($yaml[0]) && exists $yaml[0]->{'home'});
die "$0: missing required platforms setting in $conf_file\n"
unless (defined($yaml[0]) && exists $yaml[0]->{'platforms'});
my $configs = $yaml[0]->{'platforms'};
# If no platforms were specified on the command line, build everything.
my @platforms;
if (scalar @ARGV != 0) {
foreach (@ARGV) {
die "Unknown platform $_\n" unless exists $configs->{$_};
push(@platforms, $_);
}
} else {
@platforms = keys %{$configs};
}
my $top = $yaml[0]->{'home'};
my $artifacts = $yaml[0]->{'artifacts'} // "${top}/artifacts";
my $logs = $yaml[0]->{'logs'} // "${top}/logs";
my $sockets = "${top}/connections";
my $cache = "${top}/cache";
my $exiting = 0;
my $failed_jobs = 0;
my $build_script = $yaml[0]->{'build_script'};
my $test_script = $yaml[0]->{'test_script'};
my ($build_script_base, $test_script_base, $srcdir, $sudo_version);
# In check-only mode just grep the build logs.
check_logs() if $checklogs;
# Start ssh agent if one is not already running.
my $agent_pid = start_ssh_agent();
unless ($cleanup_only) {
# If no tarball specified, create an archive from the repo.
$sudo_tmpdir = mkdtemp("/var/tmp/sudo-XXXXXXXX") ||
die "$0: unable to create temporary directory: $!\n";
if (defined($tarball)) {
warn "$0: building packages from $tarball\n";
die "$0: can't access $tarball\n" unless -f $tarball;
} else {
warn "$0: building packages from $rev of $repo\n";
$tarball = create_tarball() ||
die "$0: unable to create tarball from $repo\n";
}
$tarball =~ m:^(.*/)?([^/]+)$:;
$tarball_base = $2;
# Find top-level source directory in tarball
$srcdir = `tar -ztf $tarball | sed 1q`;
chomp($srcdir);
$srcdir =~ s:/.*::;
die "$0: unable to determine source dir from tarball\n" unless $srcdir;
if ($srcdir =~ /^sudo-(.*)$/) {
$sudo_version = $1;
} else {
$sudo_version = $rev;
}
# If no build or test script specified, use defaults.
$build_script = default_build_script() unless defined($build_script);
$build_script =~ m:^(.*/)?([^/]+)$:;
$build_script_base = $2;
$test_script = default_test_script() unless defined($test_script);
$test_script =~ m:^(.*/)?([^/]+)$:;
$test_script_base = $2;
# Create required directories
foreach my $dir (sort $top, $artifacts, $cache, $logs, $sockets, "$artifacts/$sudo_version") {
unless (-d $dir) {
mkdir($dir, 0755) || die "$0: unable to mkdir $dir: $!\n";
}
}
$artifacts .= "/$sudo_version";
}
# Linux and SYSV don't support SIGINFO
my $INFO = "INFO";
$INFO = "USR1" unless exists $SIG{INFO};
$SIG{$INFO} = \&info;
$SIG{HUP} = \&shut_down;
$SIG{INT} = \&shut_down;
$SIG{QUIT} = \&shut_down;
$SIG{TERM} = \&shut_down;
$SIG{__DIE__} = \&die_hook;
# Prevent macOS from going to sleep while we are running.
system("/usr/bin/caffeinate -i -w $main_pid") if -x "/usr/bin/caffeinate";
# Keep track of which platforms belong to which VM server.
my %pdu_servers;
my %vm_servers;
my %active_hosts;
foreach my $platform (@platforms) {
my $conf = $configs->{$platform};
if (exists $conf->{'vm_host'}) {
my $vm_host = $conf->{'vm_host'};
push(@{$vm_servers{$vm_host}}, $conf);
}
# Track the number of times an ssh host is used
$active_hosts{$conf->{'ssh_host'}}++;
if (exists $conf->{'pdu_server'} && exists $conf->{'pdu_oid'}) {
my $agent = $conf->{"pdu_server"};
my $oid = $conf->{"pdu_oid"};
push(@{$pdu_servers{$agent, $oid}}, $conf);
}
}
# Open persistent ssh connections to VM servers
foreach my $server (keys %vm_servers) {
$active_hosts{$server} = 1 if open_persistent_connection($server) == 0;
}
# Power on VMs as needed
foreach my $vm_host (keys %vm_servers) {
my $vm_confs = $vm_servers{$vm_host};
delete $vm_servers{$vm_host};
while (my $conf = pop @{$vm_confs}) {
if (!vm_is_running($vm_host, $conf)) {
if (vm_poweron($vm_host, $conf)) {
push(@{$vm_servers{$vm_host}}, $conf);
} else {
warn "unable to start VM " . $conf->{'ssh_host'} . " on $vm_host";
}
}
}
}
# Power on servers connected to a PDU as needed
foreach my $pdu_host (keys %pdu_servers) {
my $pdu_confs = $pdu_servers{$pdu_host};
delete $pdu_servers{$pdu_host};
while (my $conf = pop @{$pdu_confs}) {
if (!pdu_is_running($pdu_host, $conf)) {
if (pdu_poweron($pdu_host, $conf)) {
push(@{$pdu_servers{$pdu_host}}, $conf);
} else {
my ($agent, $oid) = split(/$;/, $pdu_host);
warn "unable to start server " . $conf->{'ssh_host'} . " on $agent ($oid)";
}
}
}
}
# We want to catch the jobs as they finish but not any of the other
# commands started via run() or system() above.
$SIG{CHLD} = \&reaper;
# We limit the number of concurrent jobs a single host can support.
# In the future, this may be set on a per-host basis.
# If a host already is at the limit, we queue the job until a worker
# for that host completes.
my %workers;
my %concurrency;
my $max_jobs = $yaml[0]->{'max_jobs'} // 2;
foreach my $platform (@platforms) {
my $conf = $configs->{$platform};
if (!exists $conf->{'ssh_host'}) {
warn "$platform: ssh_host not defined\n";
return;
}
run_job($platform, $conf);
}
print "Waiting for workers to finish...\n" if $verbose;
while (scalar(keys %workers) != 0) {
# This sleep will be interrupted by SIGCHLD
sleep(5);
}
# Disable reaper now that we've waited for our children
$SIG{CHLD} = undef;
# Summarize results
if (!$quiet && !$cleanup_only) {
printf("%-16s%-16s%s\n", "Platform", "Build", "Test");
foreach my $platform (@platforms) {
my $conf = $configs->{$platform};
printf("%-16s%-16s%s\n", $platform, $conf->{'status_build'},
$conf->{'status_test'});
}
}
exit($failed_jobs);
# Signal handler for worker to kill existing jobs.
sub worker_cleanup {
my $signo = shift;
# Avoid warnings when we kill the worker jobs
$exiting = $signo;
# Kill everything else in our process group (ssh jobs, etc)
$SIG{$signo} = "IGNORE";
kill($signo, -getpgrp());
# Worker will clean up and exit
sleep 1;
# Restore default handler so parent can kill us if cleanup fails
$SIG{$signo} = "DEFAULT";
}
# Connect to build host and remove stale temporary directories.
sub cleanup_build_dir {
my ($host, $conf) = @_;
# Wait for VM to power on
if (exists $conf->{"vm_host"} || exists $conf->{"pdu_server"}) {
die "unable to connect to $host\n" unless wait_for_ssh($conf);
}
# Remove any remove temporary directories
my $status = run_remote_command($conf, "rm -rf sudo_build.????????",
\*STDERR);
exit($status >> 8);
}
# Run a build job for the platform or queue it if no worker is available.
sub run_job {
my ($platform, $conf) = @_;
my ($vm_host, $status, $logfile);
my $host = $conf->{'ssh_host'};
my $exit_value = 0;
# Block signals during critical section
my $sigset = POSIX::SigSet->new(SIGCHLD, SIGHUP, SIGINT, SIGTERM);
my $oldsigset = POSIX::SigSet->new();
sigprocmask(SIG_BLOCK, $sigset, $oldsigset);
# Check max concurrent connections for the build host.
if (exists $concurrency{$host} && $concurrency{$host}->{'jobs'} >= $max_jobs) {
# Defer job until an existing build job completes.
push(@{$concurrency{$host}->{'queue'}}, $platform);
sigprocmask(SIG_SETMASK, $oldsigset);
return;
}
$concurrency{$host}->{'jobs'}++;
defined(my $child = fork) || die "Can't fork: $!\n";
if ($child) {
# Parent. Stash pid to map child to host in reaper and return.
$workers{$child} = { "platform" => $platform, "ssh_host" => $host };
sigprocmask(SIG_SETMASK, $oldsigset);
print "Started $platform \@ $host ($child)\n" if $verbose;
return;
}
# Worker runs in its own session to simplify signal handling.
setsid();
# Update signal handlers for worker.
$SIG{CHLD} = "DEFAULT";
$SIG{HUP} = \&worker_cleanup;
$SIG{$INFO} = "DEFAULT";
$SIG{INT} = \&worker_cleanup;
$SIG{TERM} = \&worker_cleanup;
$SIG{__DIE__} = undef;
sigprocmask(SIG_SETMASK, $oldsigset);
# Show platform and ssh host in ps.
$0 = "build_pkgs: $platform ($host)";
# Cleanup and exit in --clean mode
cleanup_build_dir($host, $conf) if $cleanup_only;
# Output goes to log file
if (!open(BUILDLOG, ">", "${logs}/${platform}.build")) {
die "unable to create ${logs}/${platform}.build: $!\n";
}
my $now = localtime();
print BUILDLOG "Build for ${platform} \@ ${host} started $now\n\n";
# Wait for VM to power on
if (exists $conf->{"vm_host"} || exists $conf->{"pdu_server"}) {
if (!wait_for_ssh($conf)) {
print BUILDLOG "$platform: unable to connect to $host\n";
exit(1);
}
}
# Create temporary directory on build host
my $temporary;
for (my $i = 0; $i < 20; $i++) {
$temporary = mktemp("sudo_build.XXXXXXXX");
$status = run_remote_command($conf, "mkdir $temporary", \*BUILDLOG);
last if $status == 0;
}
if ($status != 0) {
print BUILDLOG "unable to mkdir ${host}:${temporary}\n";
exit(1);
}
my @files = ($build_script, $test_script, $tarball);
printf BUILDLOG "copy %s -> %s:%s\n", join(' ', @files), $host, "$temporary/";
$status = copy_to_remote(\@files, "$temporary/", $host, \*BUILDLOG,
$conf->{"proxy_cmd"});
if ($status != 0) {
warn "$platform: unable to copy files to $host:$temporary/" unless $exiting;
$exit_value = 1;
goto remove_tmp;
}
# Copy config.cache file if there is one
if (!$ignore_cache && -f "${cache}/config.cache.${platform}") {
printf BUILDLOG "copy %s -> %s:%s\n",
"${cache}/config.cache.${platform}",
$host, "$temporary/config.cache";
copy_to_remote("${cache}/config.cache.${platform}",
"$temporary/config.cache", $host, \*BUILDLOG,
$conf->{"proxy_cmd"});
}
# Unpack tarball and run build script.
my $build_args = "";
if (exists $conf->{"crossbuild"}) {
# When cross-building, pass the target system on the command line.
$build_args .= "--osversion " . $conf->{"crossbuild"} . " ";
}
if (!$ignore_cache) {
# Name of the config.cache file, if using.
$build_args .= "--cache-file config.cache ";
}
if (exists $conf->{"no_packages"} && $conf->{"no_packages"}) {
$build_args .= "--no-packages ";
}
$status = run_remote_command($conf,
"cd $temporary && ./$build_script_base ${build_args}$tarball_base",
\*BUILDLOG);
if ($status != 0) {
warn "$platform: build failed on $host\n" unless $exiting;
$exit_value = 2;
goto remove_tmp;
}
if (!exists $conf->{'omit_artifacts'} || !$conf->{'omit_artifacts'}) {
printf BUILDLOG "copy %s:%s -> %s\n", $host,
"$temporary/artifacts/*", $artifacts;
$status = copy_from_remote("$temporary/artifacts/\*", $artifacts,
$host, \*BUILDLOG, $conf->{"proxy_cmd"});
if ($status != 0) {
warn "$platform: unable to scp $host:$temporary/artifacts/* $artifacts" unless $exiting;
$exit_value = 4;
goto remove_tmp;
}
}
# Update config.cache file if using.
if (!$ignore_cache) {
printf BUILDLOG "copy %s:%s -> %s\n", $host,
"$temporary/config.cache", "${cache}/config.cache.${platform}";
$status = copy_from_remote("$temporary/config.cache",
"${cache}/config.cache.${platform}", $host,
\*BUILDLOG, $conf->{"proxy_cmd"});
}
$now = localtime();
print BUILDLOG "\nBuild completed $now\n";
# Run tests unless cross-compiling
unless (exists $conf->{'crossbuild'}) {
if (!open(TESTLOG, ">", "${logs}/${platform}.test")) {
warn "unable to create ${logs}/${platform}.test: $!\n";
$exit_value = 128;
goto remove_tmp;
}
print TESTLOG "Test started $now\n\n";
$status = run_remote_command($conf,
"cd $temporary && ./$test_script_base $srcdir", \*TESTLOG, "-tt");
print TESTLOG "\nTest completed $now\n";
if ($status != 0) {
warn "$platform: test failure on $host\n" unless $exiting;
$exit_value = 129;
}
close(TESTLOG);
}
# Remove temporary build dir
remove_tmp:
run_remote_command($conf, "rm -rf \"$temporary\"", \*BUILDLOG);
close(BUILDLOG);
exit($exit_value);
}
sub reaper {
my $child;
for (;;) {
$child = waitpid(-1, WNOHANG);
last if $child <= 0;
if (exists $workers{$child}) {
my $w = $workers{$child};
my $platform = $w->{'platform'};
my $host = $w->{'ssh_host'};
my $conf = $configs->{$platform};
print "Finished $platform \@ $host ($child)\n" if $verbose;
# Check for warnings/errors in the log file
my @warnings = grep_log("${logs}/${platform}.build");
foreach (@warnings) {
warn "$platform: $_\n";
}
if ($? == 0) {
$conf->{'status_build'} = scalar @warnings ? "WARNING" : "SUCCESS";
$conf->{'status_test'} =
$conf->{'crossbuild'} ? "N/A" : "SUCCESS";
} else {
if (($? >> 8) < 128) {
# Build failed
$conf->{'status_build'} = "FAILED";
$conf->{'status_test'} = "N/A"
} else {
# Build OK, test failed
$conf->{'status_build'} = scalar @warnings ? "WARNING" : "SUCCESS";
$conf->{'status_test'} = "FAILED";
}
$failed_jobs++;
}
# Remove the dead process
delete $workers{$child};
# One less worker is now using the host
$active_hosts{$host}--;
if ($active_hosts{$host} == 0) {
# Close persistent connection if no longer needed
close_persistent_connection($host);
if (exists $conf->{'vm_host'}) {
my $vm_host = $conf->{'vm_host'};
if (exists $vm_servers{$vm_host}) {
for (my $i = 0; $i < scalar @{$vm_servers{$vm_host}}; $i++) {
# Shut down now-unused VM and remove from list.
if (${$vm_servers{$vm_host}}[$i] eq $conf) {
splice(@{$vm_servers{$vm_host}}, $i, 1);
vm_shutdown($vm_host, $conf);
}
}
}
}
}
# Check for deferred jobs if invoked via signal.
if (defined($_[0])) {
$concurrency{$host}->{'jobs'}--;
if (exists $concurrency{$host}->{'queue'}) {
if (int(@{$concurrency{$host}->{'queue'}})) {
my $platform = pop(@{$concurrency{$host}->{'queue'}});
run_job($platform, $configs->{$platform});
}
}
}
}
}
$SIG{CHLD} = \&reaper;
}
# Kill all workers and wait for them to finish.
# Note that the reaper is not called for the workers.
sub kill_workers {
my $signame = shift;
my @jobs = keys %workers;
if (@jobs) {
$SIG{CHLD} = undef;
foreach (@jobs) {
kill($signame, $_);
}
sleep(2);
foreach (@jobs) {
kill(SIGKILL, $_);
}
for (;;) {
last unless waitpid(-1, WNOHANG) > 0;
}
}
}
# Shut down cleanly on signal and exit.
sub shut_down {
my $signame = shift;
print "Shutting down...\n" if $verbose;
kill_workers($signame);
exit(1);
}
# Hook that is called when we die().
sub die_hook {
# Don't do anything special if called from an eval block
die @_ if $^S;
kill_workers(SIGTERM);
}
# Signal handler for SIGINFO (or SIGUSR1 on non-BSD).
sub info {
while (my ($host, $c) = each %concurrency) {
if (exists $c->{'queue'}) {
foreach my $platform (@{$c->{'queue'}}) {
warn "Queued $platform @ $host\n";
}
}
}
while (my ($pid, $w) = each %workers) {
my $platform = $w->{'platform'};
my $host = $w->{'ssh_host'};
warn "Running $platform @ $host ($pid)\n";
}
$SIG{$INFO} = \&info if defined($_[0]);
}
# Build up the ssh command line
sub ssh_cmdline {
my ($host, $cmd, @opts) = @_;
my @cmdline = qw(ssh -x -oPreferredAuthentications=publickey -oStrictHostKeyChecking=no -oServerAliveInterval=15 -oServerAliveCountMax=3);
push(@cmdline, @opts) if @opts;
push(@cmdline, $host);
push(@cmdline, $cmd) if defined($cmd);
@cmdline;
}
# Use scp to copy one or more files from local to remote
sub copy_to_remote {
my ($src, $dst, $host, $output, $proxy) = @_;
# Open persistent connection if needed
unless (-S "$sockets/$host") {
my $status = open_persistent_connection($host, $output, $proxy);
return $status if $status != 0;
}
my @cmd = qw(scp -Bq -oPreferredAuthentications=publickey -oStrictHostKeyChecking=no);
push(@cmd, "-oControlPath=$sockets/$host");
push(@cmd, ref $src ? @$src : $src);
push(@cmd, "$host:$dst");
run(\@cmd, '<', \undef, '>&', $output, debug => $debug);
return $?;
}
# Use scp to copy a file from remote to local
sub copy_from_remote {
my ($src, $dst, $host, $output, $proxy) = @_;
# Open persistent connection if needed
unless (-S "$sockets/$host") {
my $status = open_persistent_connection($host, $output, $proxy);
return $status if $status != 0;
}
my @cmd = qw(scp -Bq -oPreferredAuthentications=publickey -oStrictHostKeyChecking=no);
push(@cmd, "-oControlPath=$sockets/$host");
push(@cmd, "$host:$src", $dst);
run(\@cmd, '<', \undef, '>&', $output, debug => $debug);
return $?;
}
# Run a command over ssh on the remote host and to output handle.
# A persistent connection is opened if one does not already exit.
sub run_remote_command {
my ($conf, $cmd, $output, @opts) = @_;
my $host = $conf->{'ssh_host'};
my $sock_file = "$sockets/$host";
my ($writer, $child);
# Open persistent connection if needed
unless (-S $sock_file) {
my $status = open_persistent_connection($host, $output, $conf->{'proxy_cmd'});
return $status if $status != 0;
}
# Run command in schroot if necessary
if (exists $conf->{'schroot'}) {
my $schroot = $conf->{'schroot'};
# Wrap in a shell if command uses special characters
if ($cmd =~ /[\$\{\}\[\]\&\|\;\?\*]/) {
$cmd =~ s/'/\\\'/g;
$cmd = "schroot -c $schroot -- sh -c '$cmd'";
} else {
$cmd = "schroot -c $schroot -- $cmd";
}
}
# No need for proxy here, the persistent connection handles it.
# For tty mode, we need to allocate a pty and do things the hard way.
my @cmdline = ssh_cmdline($host, $cmd, "-S", $sock_file, @opts);
if (grep(@opts, "-t")) {
my ($inbuf, $outbuf);
my $h = harness(\@cmdline, '<pty<', \$inbuf, '>pty>', \$outbuf, debug => $debug);
for (;;) {
pump $h;
if (defined($outbuf)) {
$outbuf =~ s/\r+$//gm;
print $output $outbuf;
undef $outbuf;
}
last unless $h->pumpable;
}
$h->finish();
} else {
run(\@cmdline, '<', \undef, '>&', $output, debug => $debug);
}
return $?;
}
# Open a persistent connection to the specified host.
# The connection will persist until shut down.
sub open_persistent_connection {
my ($dest, $output, $proxy) = @_;
my ($outbuf, @cmdline);
# Handle user@host form
$dest =~ /([^@]+)$/;
my $host = $1;
my @ssh_opts = qw(-M -N -oControlPersist=yes);
push(@ssh_opts, "-S", "$sockets/$host");
push(@ssh_opts, "-oProxyCommand=$proxy") if defined($proxy);
# Lock socket dir to prevent race conditions
sysopen(SOCKDIR, $sockets, O_RDONLY) || die "$0: unable to open $sockets: $!\n";
flock(SOCKDIR, LOCK_EX) || die "$0: unable to lock $sockets: $!\n";
if (-S "$sockets/$host") {
@cmdline = ("ssh", "-S", "$sockets/$host", "-Ocheck", $host);
run(\@cmdline, '<', \undef, '>&', \$outbuf, debug => $debug);
if ($outbuf =~ /^Master running/) {
close(SOCKDIR);
return 0;
}
return 0 if $outbuf =~ /^Master running/;
unlink("$sockets/$host");
}
@cmdline = ssh_cmdline($dest, undef, @ssh_opts);
run(\@cmdline, '<', \undef, '>&', \$outbuf, debug => $debug);
if (length($outbuf) > 0) {
$output = \*STDERR unless defined($output);
print $output $outbuf;
}
close(SOCKDIR);
return $?;
}
# Close a persistent connection
sub close_persistent_connection {
my $host = shift;
my $ret = 1;
# Strip off optional user@ if present.
$host =~ s/^.*@//;
if (-S "$sockets/$host") {
my $outbuf;
my @cmdline = ("ssh", "-S", "$sockets/$host", "-Oexit", $host);
run(\@cmdline, '<', \undef, '>&', \$outbuf, debug => $debug);
$ret = 0 unless ($? == 0 && $outbuf =~ /Exit request sent/);
}
$ret;
}
# Close all active persistent connections.
sub close_persistent_connections {
if (defined($sockets)) {
foreach my $host (keys %active_hosts) {
close_persistent_connection($host);
}
}
}
sub vm_is_running {
# vim-cmd vmsvc/power.getstate VMID
my ($host, $conf) = @_;
my @ssh_opts = ( "-S", "$sockets/$host" );
my $ssh_host = $conf->{'ssh_host'};
my $outbuf;
my @cmdline = ssh_cmdline($host,
"vim-cmd vmsvc/power.getstate " . $conf->{'vmid'}, @ssh_opts);
run(\@cmdline, '<', \undef, '>&', \$outbuf, debug => $debug);
$outbuf =~ /Powered on/;
}
sub vm_poweron {
# vim-cmd vmsvc/power.on VMID
my ($host, $conf) = @_;
my @ssh_opts = ( "-S", "$sockets/$host" );
my $ssh_host = $conf->{'ssh_host'};
my $outbuf;
printf("Powering on VM %s on %s\n", $ssh_host, $host) if $verbose;
my @cmdline = ssh_cmdline($host,
"vim-cmd vmsvc/power.on " . $conf->{'vmid'}, @ssh_opts);
run(\@cmdline, '<', \undef, '>&', \$outbuf, debug => $debug);
$outbuf =~ /Powering on VM/;
}
sub vm_shutdown {
# vim-cmd vmsvc/power.shutdown VMID
my ($host, $conf) = @_;
my @ssh_opts = ( "-S", "$sockets/$host" );
my $ssh_host = $conf->{'ssh_host'};
my $outbuf;
printf("Shutting down VM %s on %s\n", $ssh_host, $host) if $verbose;
my @cmdline = ssh_cmdline($host,
"vim-cmd vmsvc/power.shutdown " . $conf->{'vmid'}, @ssh_opts);
run(\@cmdline, '<', \undef, '>&', \$outbuf, debug => $debug);
# Check for, e.g. vim.fault.ToolsUnavailable or vim.fault.InvalidPowerState
if ($outbuf =~ /vim\.fault\.ToolsUnavailable/) {
# VM tools not installed, login directly to shut down
if (exists $conf->{'shutdown'}) {
# login directly to shut down
@cmdline = ssh_cmdline($ssh_host, $conf->{'shutdown'}, "-S",
"$sockets/$ssh_host");
run(\@cmdline, '<', \undef, '>&', \$outbuf, debug => $debug);
} else {
warn "unable to shut down $ssh_host on $host: VM tools not installed\n";
}
} elsif ($outbuf =~ /vim\.fault\.InvalidPowerState/) {
# Not powered on, ignore the error
$outbuf = "";
}
$outbuf !~ /vim\.fault\./;
}
sub pdu_is_running {
my ($host, $conf) = @_;
my ($agent, $oid) = split(/$;/, $host);
my $outbuf;
# Assumes module name matches first level of OID
my $module = $oid;
$module =~ s/::.*//;
my @cmdline = ("snmpget", "-m", "+$module", "-c", "public", "-v1",
$agent, $oid);
run(\@cmdline, '<', \undef, '>&', \$outbuf, debug => $debug);
$outbuf =~ /:\s*1$/;
}
sub pdu_poweron {
my ($host, $conf) = @_;
my ($agent, $oid) = split(/$;/, $host);
my $ssh_host = $conf->{'ssh_host'};
my $outbuf;
# Assumes module name matches first level of OID
my $module = $oid;
$module =~ s/::.*//;
printf("Powering on %s via %s\n", $ssh_host, $agent) if $verbose;
my @cmdline = ("snmpset", "-m", "+$module", "-c", "private", "-v1",
$agent, $oid, "i", "1");
run(\@cmdline, '<', \undef, '>&', \$outbuf, debug => $debug);
$outbuf =~ /:\s*1$/;
}
sub pdu_shutdown {
my ($host, $conf) = @_;
my ($agent, $oid) = split(/$;/, $host);
my $ssh_host = $conf->{'ssh_host'};
my @ssh_opts = ( "-S", "$sockets/$ssh_host" );
my $outbuf;
# Assumes module name matches first level of OID
my $module = $oid;
$module =~ s/::.*//;
printf("Powering off %s via %s\n", $ssh_host, $agent) if $verbose;
# Login via ssh to shut down (assume user can run poweroff w/o privs)
my @cmdline = ssh_cmdline($ssh_host, 'poweroff', @ssh_opts);
run(\@cmdline, '<', \undef, '>&', \$outbuf, debug => $debug);
# XXX - wait until we can't ping it?
sleep(1);
# Turn off power to the outlet using snmp
@cmdline = ("snmpset", "-m", "+$module", "-c", "private", "-v1", $agent,
$oid, "i", "0");
run(\@cmdline, '<', \undef, '>&', \$outbuf, debug => $debug);
$outbuf =~ /:\s*1$/;
}
# Try to connect to the specified port on the host looking for an SSH banner.
# Returns 1 on success, 0 on failure
sub ping_ssh {
my ($host, $port) = @_;
my $ret = 0;
# Create socket and connect
my $sock = IO::Socket::IP->new(
PeerHost => $host,
PeerPort => "$port",
Timeout => 20,
Type => SOCK_STREAM
);
if (defined($sock) && defined($_ = <$sock>)) {
$ret = 1 if /^SSH/;
close($sock);
}
return $ret;
}
sub wait_for_ssh {
my $conf = shift;
my $host = $conf->{'ssh_host'};
my $port = 22;
# Sleep until sshd is available (or we time out)
my $sleeptime = 10;
my $timeout = $sleeptime * 30;
while ($timeout > 0) {
sleep($sleeptime);
$timeout -= $sleeptime;
return 1 if ping_ssh($host, $port);
}
return 0;
}
# Create a tarball from an archive $repo
sub create_tarball {
die "$0: unable to create directory $sudo_tmpdir/sudo: $!\n"
unless mkdir("$sudo_tmpdir/sudo", 0755);
system("hg archive -R $repo -r $rev --type=files $sudo_tmpdir/sudo && hg log -R $repo --template=changelog -r 'sort(branch(.) or follow(), -date)' > $sudo_tmpdir/sudo/ChangeLog && cd $sudo_tmpdir && sed -e 's/^/sudo\\//' -e 's/[ ].*//' sudo/MANIFEST | GZIP=-9 pax -wz -x ustar -f sudo.tar.gz && rm -rf sudo");
return $? ? undef : "$sudo_tmpdir/sudo.tar.gz";
}
sub start_ssh_agent {
# Use existing agent if possible
if (exists $ENV{'SSH_AUTH_SOCK'} && -S $ENV{'SSH_AUTH_SOCK'}) {
return undef;
}
# Need to start a new agent and add keys
my @cmdline = qw(ssh-agent -s);
run(\@cmdline, '<', \undef, '>&', sub {
$_ = shift;
s/;[^;]*$//;
my ($var, $val) = (/^([^=]+)=(.*)$/);
if (defined($var) && defined($val)) {
$ENV{$var} = $val;
}
});
system("ssh-add");
return $ENV{'SSH_AGENT_PID'};
}
sub default_build_script {
my $fname = "${sudo_tmpdir}/build.sh";
open(my $fh, ">", $fname);
print $fh <<~'EOF';
#!/bin/sh
configure_opts=
while test $# != 0; do
case "$1" in
--cache-file)
configure_opts="${configure_opts} --cache-file=../$2"
shift 2
;;
--osversion)
osversion="$2"
shift 2
;;
--no-packages)
configure_opts="${configure_opts} --build-only"
shift
;;
*)
break
;;
esac
done
if [ -z "$1" ] || [ \! -r "$1" ]; then
echo "No tarball specified"
exit 1
fi
gunzip -c $1 | tar xf -
if [ $? -ne 0 ]; then
echo "Unable to extract tarball"
exit 1
fi
rm -f $1
mkdir artifacts || exit 1
# Assume src directory in tar file matches its name.
sudodir="`echo $1|sed 's/.tar.gz$//'`"
cd "$sudodir" || exit 1
if [ -z "$osversion" ]; then
osversion="`./scripts/pp --probe`"
fi
./scripts/mkpkg $configure_opts --osversion="$osversion" || exit 1
for f in *.rpm *.deb *.bff *.depot *.pkg *.txz ../artifacts; do
if [ -f "$f" ]; then
mv "$f" ../artifacts
fi
done
EOF
chmod(0755, $fh);
close($fh);
return $fname;
}
sub default_test_script {
my $fname = "${sudo_tmpdir}/test.sh";
open(my $fh, ">", $fname);
print $fh <<~'EOF';
#!/bin/sh
if [ -z "$1" ]; then
echo "usage: test.sh workdir"
exit 1
fi
cd "$1" || exit 1
exec make check
EOF
chmod(0755, $fh);
close($fh);
return $fname;
}
# Look for warnings/errors in the log file
sub grep_log {
my $log = shift;
my @warnings;
if (open(my $fh, "<", $log)) {
while (<$fh>) {
s/[\r\n]+$//; # kill carriage returns too
s/\x1b\[[\d;]*m//g; # remove escape codes
if (/[Ww]arning:|[Ee]rror:|\*\*\* |ftp: /) {
# Some things we ignore
next if /ermanently added .* to the list of known hosts/;
next if /warning\/error mail subject/;
next if /must be cleared at boot time|Clock skew detected|in the future|-no(-fast)?-install|remember to run 'libtool --finish|has not been installed in|relinking '/;
# RPM warnings we don't care about
next if /warning: (Installed .but unpackaged. file.s. found:|Deprecated external dependency generator is used!|absolute symlink)/;
# Solaris ld may warn about the gcc dwarf2 unwinder
next if /warning: unwind table: .* suspicious InitialLoc value 0: binary search table will be incomplete if section requires runtime relocation/;
# Solaris Studio compiler warns about anonymous unions
next if /warning: anonymous union declaration/;
# HP-UX shl_t uses type-punning
next if /sudo_dso.c:.*warning: type-punning to incomplete type might break strict-aliasing rules/;
# Bogus HP-UX 11.23 warning about PTHREAD_MUTEX_INITIALIZER
next if /arc4random.h:.*(missing braces around initializer|near initialization for 'arc4random_mtx)/;
if ($log eq "vasaix53.vintela.com.log") {
# Bogus wait(2) macros on AIX 5.3
next if /warning: signed and unsigned type in conditional expression/;
}
push(@warnings, $_);
}
}
close($fh);
}
return @warnings;
}
sub check_logs {
foreach my $platform (@platforms) {
my @warnings = grep_log("${logs}/${platform}.build");
foreach (@warnings) {
warn "$platform: $_\n";
}
}
exit(0);
}
1;
END {
# Only do cleanup in the main process
return unless $$ == $main_pid;
# Power down VMs cleanly if any are still running
while (my ($vm_host, $configs) = each %vm_servers) {
foreach my $conf (@{$configs}) {
vm_shutdown($vm_host, $conf);
}
}
# Power down servers on a PDU cleanly if any are still running
while (my ($pdu_host, $configs) = each %pdu_servers) {
foreach my $conf (@{$configs}) {
pdu_shutdown($pdu_host, $conf);
}
}
# Close any persistent ssh connections still running
close_persistent_connections();
# Remove temp directory used to generate the tarball.
if (defined($sudo_tmpdir)) {
system("rm -rf \"$sudo_tmpdir\"");
}
# Kill ssh-agent if we started one ourselves
if (defined($agent_pid)) {
kill(SIGINT, $agent_pid);
waitpid($agent_pid, 0);
}
}
__END__
=head1 NAME
build_pkgs - Build sudo packages on multiple hosts simultaneously
=head1 SYNOPSIS
build_pkgs [-c config_file] [-f tarball] [-h] [-i] [-q] [-R repo] [-r rev] [-v] [platform ...]
build_pkgs [--check-logs] [--config config_file] [--file tarball] [--help] [--ignore-cache] [--quiet] [--repository repo] [--revision rev] [--verbose] [platform ...]
=head1 OPTIONS
=over 4
=item B<--check-logs>
Check the existing build logs for warnings and errors and exit.
If no platform is specified, check the build logs of all configured
platforms.
=item B<-c> I<config_file>, B<--config> I<config_file>
Use the specified configuration file for settings and platform
configuration.
=item B<-f> I<tarball>, B<--file> I<tarball>
Build packages from the specified tar file. If this option is not
specified, a distribution tarball will be generated from the tip
of the source repo.
=item B<-h>, B<--help>
Print a brief help message and exit.
=item B<-i>, B<--ignore-cache>
Do not use an existing config.cache file when configuring sudo.
=item B<-q>, B<--quiet>
Do not print a summary at the end detailing the success or failure
of each build and test run.
=item B<-R> I<repo>, B<--repository> I<repo>
The URL of the sudo Mercurial source repo to use if the B<--tarball>
option is not specified.
=item B<-r> I<rev>, B<--revision> I<rev>
The revision to check out if the B<--tarball> option is not specified.
If no revision is specified, the tip of the repo will be used.
=item B<-v>, B<--verbose>
Print additional information about the progress of builds as the
script runs.
=back
=head1 DESCRIPTION
B<build_pkgs> builds sudo packages on multiple systems at the same
time, logging each build and test job to a separate log file. When
B<build_pkgs> finishes all its jobs, it prints a summary of each
platform with the status of the build and test scripts.
For each build host, B<build_pkgs> performs the following steps:
=over 4
=item 1.
Open a persistent ssh connection to the build host.
This connection is used for all build server -> client interaction.
=item 2.
Create a temporary directory on the build host.
=item 3.
Copy the release tarball (or repo archive) to the build host along
with a config.cache file (if using), build and test scripts.
=item 4.
Run the build script on the build host, which will unpack the tarball
and build the package(s).
=item 5.
If the build was successful, run the test script on the build host.
=item 6.
If the build was successful, copy the artifacts from the build host
to the artifacts directory.
=item 7.
The temporary directory is removed from the build host.
=back
=head2 Configuration file
The configuration file is in YAML format. The supported settings
are as follows:
=over 4
=item home
The top directory that B<build_pkgs> will use. By default, this
is the parent directory of the logs, artifacts, cache and connections
directories.
=item logs
The directory to store log files in. By default, this is I<logs>
under the I<home> directory.
=item artifacts
The directory in which to store built artifacts. By default, this
is I<artifacts> under the I<home> directory.
=item build_script
A custom build script to run on the build host. By default,
B<sudo_sudo> will use a script that just unpacks the tarball and
runs the I<mkpkg> script.
=item test_script
A custom test script to run on the build host. By default,
B<sudo_sudo> will use a script that just runs I<make check> from
within the build directory.
=item max_jobs
The number of build jobs to run simultaneously on a single host.
=item platforms
The platform configuration. Platform are generally of the form
OS-architecture, for example I<rhel8-x86_64> or I<aix72-powerpc>.
Each platform supports the following settings:
=over 4
=item omit_artifacts
If set to true, avoid copying any artifacts built for this platform.
This can be used to build on systems where we don't publish packages.
=item crossbuild
The name of the platform to cross-compile for. If specified, this
value is passed via the B<--osversion> option to the B<mkpkg> script.
=item proxy_cmd
The proxy command to pass to ssh via the I<ProxyCommand> option.
=item schroot
The name of the I<schroot> on I<ssh_host> to run the build inside.
=item shutdown
A command to run to shut down a virtual machine when the OS doesn't
support performing a graceful VM shutdown.
=item ssh_host
The build host to connect to. A username may be prefixed in the
form user@host.
=item vm_host
The ESXi server that hosts I<ssh_host>. If a I<vmid> is also set,
B<build_pkgs> will power on/off the VM on demand.
=item vmid
The ESXi virtual machine ID for the build host. If I<vm_host> is
also set, B<build_pkgs> will power on/off the VM on demand.
=back
=back
The I<home> and I<platform> settings are mandatory.
=head1 EXAMPLES
Below is an example configuration file:
# File locations.
home: /home/sudo-build
logs: /var/log/sudo-build
artifacts: /home/sudo-build/artifacts
# Custom build and test scripts.
build_script: /home/sudo-build/scripts/build.sh
test_script: /home/sudo-build/scripts/test.sh
# Run up to two simultaneous jobs on each build host.
max_jobs: 2
# Ubuntu 22.04 LTS (Jammy Jellyfish)
jammy-x86_64:
schroot: jammy
ssh_host: linux-build
# macOS 11 and 12 builds on a single macOS 12 virtual machine.
# If the VM is not running, it will be started and shut down when done.
macos12:
ssh_host: macos-build
vm_host: root@esxi
vmid: 150
macos11:
crossbuild: macos1100-i386
ssh_host: macos-build
vm_host: root@esxi
vmid: 150
=head1 SIGNALS
When sent the C<SIGINFO> signal (or C<SIGUSR1> on system that don't
support C<SIGINFO>), B<build_pkgs> will output the currently running
build jobs as well as those jobs that are queued up waiting for a
slot on the build host.