diff --git a/src/lib/Hydra/Helper/AttributeSet.pm b/src/lib/Hydra/Helper/AttributeSet.pm new file mode 100644 index 00000000..b750d6e1 --- /dev/null +++ b/src/lib/Hydra/Helper/AttributeSet.pm @@ -0,0 +1,56 @@ +package Hydra::Helper::AttributeSet; + +use strict; +use warnings; + +sub new { + my ($self) = @_; + return bless { "paths" => [] }, $self; +} + +sub registerValue { + my ($self, $attributePath) = @_; + + my @pathParts = splitPath($attributePath); + + pop(@pathParts); + if (scalar(@pathParts) == 0) { + return; + } + + my $lineage = ""; + for my $pathPart (@pathParts) { + $lineage = $self->registerChild($lineage, $pathPart); + } +} + +sub registerChild { + my ($self, $parent, $attributePath) = @_; + if ($parent ne "") { + $parent .= "." + } + + my $name = $parent . $attributePath; + if (!grep { $_ eq $name} @{$self->{"paths"}}) { + push(@{$self->{"paths"}}, $name); + } + return $name; +} + +sub splitPath { + my ($s) = @_; + + if ($s eq "") { + return ('') + } + + return split(/\./, $s, -1); +} + +sub enumerate { + my ($self) = @_; + my @paths = sort { length($a) <=> length($b) } @{$self->{"paths"}}; + return wantarray ? @paths : \@paths; +} + +1; diff --git a/src/lib/Hydra/Helper/Escape.pm b/src/lib/Hydra/Helper/Escape.pm new file mode 100644 index 00000000..3037951f --- /dev/null +++ b/src/lib/Hydra/Helper/Escape.pm @@ -0,0 +1,21 @@ +package Hydra::Helper::Escape; + +use strict; +use base qw(Exporter); +use Hydra::Helper::AttributeSet; + +our @EXPORT = qw(escapeString escapeAttributePath); + +sub escapeString { + my ($s) = @_; + $s =~ s|\\|\\\\|g; + $s =~ s|\"|\\\"|g; + $s =~ s|\$|\\\$|g; + return "\"" . $s . "\""; +} + +sub escapeAttributePath { + my ($s) = @_; + + return join ".", map { escapeString($_) } Hydra::Helper::AttributeSet::splitPath($s); +} diff --git a/src/lib/Hydra/View/NixExprs.pm b/src/lib/Hydra/View/NixExprs.pm index 7bfa3109..194c51ec 100644 --- a/src/lib/Hydra/View/NixExprs.pm +++ b/src/lib/Hydra/View/NixExprs.pm @@ -3,18 +3,12 @@ package Hydra::View::NixExprs; use strict; use base qw/Catalyst::View/; use Hydra::Helper::Nix; +use Hydra::Helper::Escape; +use Hydra::Helper::AttributeSet; use Archive::Tar; use IO::Compress::Bzip2 qw(bzip2); use Encode; - - -sub escape { - my ($s) = @_; - $s =~ s|\\|\\\\|g; - $s =~ s|\"|\\\"|g; - $s =~ s|\$|\\\$|g; - return "\"" . $s . "\""; -} +use Data::Dumper; sub process { @@ -62,33 +56,38 @@ EOF my $first = 1; foreach my $system (keys %perSystem) { $res .= "else " if !$first; - $res .= "if system == ${\escape $system} then {\n\n"; - + $res .= "if system == ${\escapeString $system} then {\n\n"; + my $attrsets = Hydra::Helper::AttributeSet->new(); foreach my $job (keys %{$perSystem{$system}}) { my $pkg = $perSystem{$system}->{$job}; my $build = $pkg->{build}; - $res .= " # Hydra build ${\$build->id}\n"; my $attr = $build->get_column('job'); - $attr =~ s/\./-/g; - $res .= " ${\escape $attr} = (mkFakeDerivation {\n"; + $attrsets->registerValue($attr); + + $res .= " # Hydra build ${\$build->id}\n"; + $res .= " ${\escapeAttributePath $attr} = (mkFakeDerivation {\n"; $res .= " type = \"derivation\";\n"; - $res .= " name = ${\escape ($build->get_column('releasename') or $build->nixname)};\n"; - $res .= " system = ${\escape $build->system};\n"; + $res .= " name = ${\escapeString ($build->get_column('releasename') or $build->nixname)};\n"; + $res .= " system = ${\escapeString $build->system};\n"; $res .= " meta = {\n"; - $res .= " description = ${\escape $build->description};\n" + $res .= " description = ${\escapeString $build->description};\n" if $build->description; - $res .= " license = ${\escape $build->license};\n" + $res .= " license = ${\escapeString $build->license};\n" if $build->license; - $res .= " maintainers = ${\escape $build->maintainers};\n" + $res .= " maintainers = ${\escapeString $build->maintainers};\n" if $build->maintainers; $res .= " };\n"; $res .= " } {\n"; my @outputNames = sort (keys %{$pkg->{outputs}}); - $res .= " ${\escape $_} = ${\escape $pkg->{outputs}->{$_}};\n" foreach @outputNames; + $res .= " ${\escapeString $_} = ${\escapeString $pkg->{outputs}->{$_}};\n" foreach @outputNames; my $out = defined $pkg->{outputs}->{"out"} ? "out" : $outputNames[0]; $res .= " }).$out;\n\n"; } + for my $attrset ($attrsets->enumerate()) { + $res .= " ${\escapeAttributePath $attrset}.recurseForDerivations = true;\n\n"; + } + $res .= "}\n\n"; $first = 0; } diff --git a/t/Controller/Jobset/channel.t b/t/Controller/Jobset/channel.t new file mode 100644 index 00000000..2b034025 --- /dev/null +++ b/t/Controller/Jobset/channel.t @@ -0,0 +1,62 @@ +use feature 'unicode_strings'; +use strict; +use Setup; +use IO::Uncompress::Bunzip2 qw(bunzip2); +use Archive::Tar; +use JSON qw(decode_json); +use Data::Dumper; +my %ctx = test_init(); + +require Hydra::Schema; +require Hydra::Model::DB; +require Hydra::Helper::Nix; + +use Test2::V0; +require Catalyst::Test; +Catalyst::Test->import('Hydra'); + +my $db = Hydra::Model::DB->new; +hydra_setup($db); + +my $project = $db->resultset('Projects')->create({name => "tests", displayname => "", owner => "root"}); + +# Most basic test case, no parameters +my $jobset = createBaseJobset("nested-attributes", "nested-attributes.nix", $ctx{jobsdir}); + +ok(evalSucceeds($jobset)); +is(nrQueuedBuildsForJobset($jobset), 4); + +for my $build (queuedBuildsForJobset($jobset)) { + ok(runBuild($build), "Build '".$build->job."' should exit with code 0"); + my $newbuild = $db->resultset('Builds')->find($build->id); + is($newbuild->finished, 1, "Build '".$build->job."' should be finished."); + is($newbuild->buildstatus, 0, "Build '".$build->job."' should have buildstatus 0."); +} + +my $compressed = get('/jobset/tests/nested-attributes/channel/latest/nixexprs.tar.bz2'); +my $tarcontent; +bunzip2(\$compressed => \$tarcontent); +open(my $tarfh, "<", \$tarcontent); +my $tar = Archive::Tar->new($tarfh); + +my $defaultnix = $ctx{"tmpdir"} . "/channel-default.nix"; +$tar->extract_file("channel/default.nix", $defaultnix); + +print STDERR $tar->get_content("channel/default.nix"); + +(my $status, my $stdout, my $stderr) = Hydra::Helper::Nix::captureStdoutStderr(5, "nix-env", "--json", "--query", "--available", "--attr-path", "--file", $defaultnix); +is($stderr, "", "Stderr should be empty"); +is($status, 0, "Querying the packages should succeed"); + +my $packages = decode_json($stdout); +my $keys = [sort keys %$packages]; +is($keys, [ + "packageset-nested", + "packageset.deeper.deeper.nested", + "packageset.nested", + "packageset.nested2", +]); +is($packages->{"packageset-nested"}->{"name"}, "actually-top-level"); +is($packages->{"packageset.nested"}->{"name"}, "actually-nested"); + +done_testing; diff --git a/t/Helper/attributeset.t b/t/Helper/attributeset.t new file mode 100644 index 00000000..112cd9be --- /dev/null +++ b/t/Helper/attributeset.t @@ -0,0 +1,53 @@ +use strict; +use warnings; +use Setup; +use Data::Dumper; +use Test2::V0; +use Hydra::Helper::AttributeSet; + + +subtest "splitting an attribute path in to its component parts" => sub { + my %values = ( + "" => [''], + "." => ['', ''], + "...." => ['', '', '', '', ''], + "foobar" => ['foobar'], + "foo.bar" => ['foo', 'bar'], + "🌮" => ['🌮'], + + # not supported: 'foo."bar.baz".tux' => [ 'foo', 'bar.baz', 'tux' ] + # the edge cases are fairly significant around escaping and unescaping. + ); + + for my $input (keys %values) { + my @value = @{$values{$input}}; + my @components = Hydra::Helper::AttributeSet::splitPath($input); + is(\@components, \@value, "Splitting the attribute path: " . $input); + } +}; + +my $attrs = Hydra::Helper::AttributeSet->new(); +$attrs->registerValue("foo"); +$attrs->registerValue("bar.baz.tux"); +$attrs->registerValue("bar.baz.bux.foo.bar.baz"); + +is( + $attrs->enumerate(), + [ + # "foo": skipped since we're registering values, and we + # only want to track nested attribute sets. + + # "bar.baz.tux": expand the path + "bar", + "bar.baz", + + #"bar.baz.bux.foo.bar.baz": expand the path, but only register new + # attribute set names. + "bar.baz.bux", + "bar.baz.bux.foo", + "bar.baz.bux.foo.bar", + ], + "Attribute set paths are registered." +); + +done_testing; diff --git a/t/Helper/escape.t b/t/Helper/escape.t new file mode 100644 index 00000000..22dd4d47 --- /dev/null +++ b/t/Helper/escape.t @@ -0,0 +1,44 @@ +use strict; +use Setup; +use Data::Dumper; +use Test2::V0; +use Hydra::Helper::Escape; + +subtest "checking individual attribute set elements" => sub { + my %values = ( + "" => '""', + "." => '"."', + "foobar" => '"foobar"', + "foo.bar" => '"foo.bar"', + "🌮" => '"🌮"', + 'foo"bar' => '"foo\"bar"', + 'foo\\bar' => '"foo\\\\bar"', + '$bar' => '"\\$bar"', + ); + + for my $input (keys %values) { + my $value = $values{$input}; + is(escapeString($input), $value, "Escaping the value: " . $input); + } +}; + +subtest "escaping path components of a nested attribute" => sub { + my %values = ( + "" => '""', + "." => '"".""', + "...." => '""."".""."".""', + "foobar" => '"foobar"', + "foo.bar" => '"foo"."bar"', + "🌮" => '"🌮"', + 'foo"bar' => '"foo\"bar"', + 'foo\\bar' => '"foo\\\\bar"', + '$bar' => '"\\$bar"', + ); + + for my $input (keys %values) { + my $value = $values{$input}; + is(escapeAttributePath($input), $value, "Escaping the attribute path: " . $input); + } +}; + +done_testing; diff --git a/t/jobs/nested-attributes.nix b/t/jobs/nested-attributes.nix new file mode 100644 index 00000000..4cd90d9b --- /dev/null +++ b/t/jobs/nested-attributes.nix @@ -0,0 +1,36 @@ +with import ./config.nix; +rec { + # Given a jobset containing a package set named X with an interior member Y, + # expose the interior member Y with the name X-Y. This is to exercise a bug + # in the NixExprs view's generated Nix expression which flattens the + # package set namespace from `X.Y` to `X-Y`. If the bug is present, the + # resulting expression incorrectly renders two `X-Y` packages. + packageset = { + recurseForDerivations = true; + deeper = { + recurseForDerivations = true; + deeper = { + recurseForDerivations = true; + + nested = mkDerivation { + name = "much-too-deep"; + builder = ./empty-dir-builder.sh; + }; + }; + }; + + nested = mkDerivation { + name = "actually-nested"; + builder = ./empty-dir-builder.sh; + }; + + nested2 = mkDerivation { + name = "actually-nested2"; + builder = ./empty-dir-builder.sh; + }; + }; + packageset-nested = mkDerivation { + name = "actually-top-level"; + builder = ./empty-dir-builder.sh; + }; +}