Files
hydra/src/lib/Hydra/Plugin/RunCommand.pm
Rick van Schijndel e4fe9d43c1 treewide: update split calls to make perlcritic happy
In nixpkgs this started to fail the hydra tests.
It's not completely clear why because it seems the perlcritic
rule has existed for quite some time.

Anyway, this should solve the issues.
2026-01-17 15:55:29 +01:00

275 lines
7.5 KiB
Perl

package Hydra::Plugin::RunCommand;
use strict;
use warnings;
use parent 'Hydra::Plugin';
use experimental 'smartmatch';
use JSON::MaybeXS;
use File::Basename qw(dirname);
use File::Path qw(make_path);
use IPC::Run3;
use Try::Tiny;
sub isEnabled {
my ($self) = @_;
return areStaticCommandsEnabled($self->{config}) || areDynamicCommandsEnabled($self->{config});
}
sub areStaticCommandsEnabled {
my ($config) = @_;
if (defined $config->{runcommand}) {
return 1;
}
return 0;
}
sub areDynamicCommandsEnabled {
my ($config) = @_;
if ((defined $config->{dynamicruncommand})
&& $config->{dynamicruncommand}->{enable}) {
return 1;
}
return 0;
}
sub isBuildEligibleForDynamicRunCommand {
my ($build) = @_;
if ($build->get_column("buildstatus") != 0) {
return 0;
}
if ($build->get_column("job") =~ "^runCommandHook\..+") {
my $out = $build->buildoutputs->find({name => "out"});
if (!defined $out) {
warn "DynamicRunCommand hook on " . $build->job . " (" . $build->id . ") rejected: no output named 'out'.";
return 0;
}
my $path = $out->path;
if (-l $path) {
$path = readlink($path);
}
if (! -e $path) {
warn "DynamicRunCommand hook on " . $build->job . " (" . $build->id . ") rejected: The 'out' output doesn't exist locally. This is a bug.";
return 0;
}
if (! -x $path) {
warn "DynamicRunCommand hook on " . $build->job . " (" . $build->id . ") rejected: The 'out' output is not executable.";
return 0;
}
if (! -f $path) {
warn "DynamicRunCommand hook on " . $build->job . " (" . $build->id . ") rejected: The 'out' output is not a regular file or symlink.";
return 0;
}
if (! $build->jobset->supportsDynamicRunCommand()) {
warn "DynamicRunCommand hook on " . $build->job . " (" . $build->id . ") rejected: The project or jobset don't have dynamic runcommand enabled.";
return 0;
}
return 1;
}
return 0;
}
sub configSectionMatches {
my ($name, $project, $jobset, $job) = @_;
my @elems = split /:/, $name;
die "invalid section name '$name'\n" if scalar(@elems) > 3;
my $project2 = $elems[0] // "*";
return 0 if $project2 ne "*" && $project ne $project2;
my $jobset2 = $elems[1] // "*";
return 0 if $jobset2 ne "*" && $jobset ne $jobset2;
my $job2 = $elems[2] // "*";
return 0 if $job2 ne "*" && $job ne $job2;
return 1;
}
sub eventMatches {
my ($conf, $event) = @_;
for my $x (split " ", ($conf->{events} // "buildFinished")) {
return 1 if $x eq $event;
}
return 0;
}
sub fanoutToCommands {
my ($config, $event, $build) = @_;
my @commands;
# Calculate all the statically defined commands to execute
my $cfg = $config->{runcommand};
my @config = defined $cfg ? ref $cfg eq "ARRAY" ? @$cfg : ($cfg) : ();
foreach my $conf (@config) {
my $matcher = $conf->{job} // "*:*:*";
next unless eventMatches($conf, $event);
next unless configSectionMatches(
$matcher,
$build->jobset->get_column('project'),
$build->jobset->get_column('name'),
$build->get_column('job')
);
if (!defined($conf->{command})) {
warn "<runcommand> section for '$matcher' lacks a 'command' option";
next;
}
push(@commands, {
matcher => $matcher,
command => $conf->{command},
})
}
# Calculate all dynamically defined commands to execute
if (areDynamicCommandsEnabled($config)) {
if (isBuildEligibleForDynamicRunCommand($build)) {
my $job = $build->get_column('job');
my $out = $build->buildoutputs->find({name => "out"});
push(@commands, {
matcher => "DynamicRunCommand($job)",
command => $out->path
})
}
}
return \@commands;
}
sub makeJsonPayload {
my ($event, $build) = @_;
my $json = {
event => $event,
build => $build->id,
finished => $build->get_column('finished') ? JSON::MaybeXS::true : JSON::MaybeXS::false,
timestamp => $build->get_column('timestamp'),
project => $build->project->get_column('name'),
jobset => $build->jobset->get_column('name'),
job => $build->get_column('job'),
drvPath => $build->get_column('drvpath'),
startTime => $build->get_column('starttime'),
stopTime => $build->get_column('stoptime'),
buildStatus => $build->get_column('buildstatus'),
nixName => $build->get_column('nixname'),
system => $build->get_column('system'),
homepage => $build->get_column('homepage'),
description => $build->get_column('description'),
license => $build->get_column('license'),
outputs => [],
products => [],
metrics => [],
};
for my $output ($build->buildoutputs) {
my $j = {
name => $output->name,
path => $output->path,
};
push @{$json->{outputs}}, $j;
}
for my $product ($build->buildproducts) {
my $j = {
productNr => $product->productnr,
type => $product->type,
subtype => $product->subtype,
fileSize => $product->filesize,
sha256hash => $product->sha256hash,
path => $product->path,
name => $product->name,
defaultPath => $product->defaultpath,
};
push @{$json->{products}}, $j;
}
for my $metric ($build->buildmetrics) {
my $j = {
name => $metric->name,
unit => $metric->unit,
value => 0 + $metric->value,
};
push @{$json->{metrics}}, $j;
}
return $json;
}
sub buildFinished {
my ($self, $build, $dependents) = @_;
my $event = "buildFinished";
my $commandsToRun = fanoutToCommands(
$self->{config},
$event,
$build
);
if (@$commandsToRun == 0) {
# No matching jobs, don't bother generating the JSON
return;
}
my $tmp = File::Temp->new(SUFFIX => '.json');
print $tmp encode_json(makeJsonPayload($event, $build)) or die;
$ENV{"HYDRA_JSON"} = $tmp->filename;
foreach my $commandToRun (@{$commandsToRun}) {
my $command = $commandToRun->{command};
# todo: make all the to-run jobs "unstarted" in a batch, then start processing
my $runlog = $self->{db}->resultset("RunCommandLogs")->create({
job_matcher => $commandToRun->{matcher},
build_id => $build->get_column('id'),
command => $command
});
$runlog->started();
my $logPath = Hydra::Helper::Nix::constructRunCommandLogPath($runlog) or die "RunCommandLog not found.";
my $dir = dirname($logPath);
my $oldUmask = umask();
my $f;
try {
# file: 640, dir: 750
umask(0027);
make_path($dir);
open($f, '>', $logPath);
umask($oldUmask);
run3($command, \undef, $f, $f, { return_if_system_error => 1 }) == 1
or warn "notification command '$command' failed with exit status $? ($!)\n";
close($f);
$runlog->completed_with_child_error($?, $!);
1;
} catch {
die "Died while trying to process RunCommand (${\$runlog->uuid}): $_";
} finally {
umask($oldUmask);
};
}
}
1;