From d22d0305033b8c18cead3e9736288c3c69a70dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kemetm=C3=BCller?= Date: Wed, 26 Feb 2025 16:32:42 +0100 Subject: [PATCH 01/15] Fix rendering of metrics with special characters My main motivation here is to get metrics with brackets to work in order to support "pytest" test names: - test_foo.py::test_bar[1] - test_foo.py::test_bar[2] I couldn't find an "HTML escape"-style function that would generate valid html `id` attribute names from random strings, so I went with a hash digest instead. --- src/lib/Hydra/View/TT.pm | 7 +++++++ src/root/job-metrics-tab.tt | 3 +-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/Hydra/View/TT.pm b/src/lib/Hydra/View/TT.pm index 84fcf3e9..241787e0 100644 --- a/src/lib/Hydra/View/TT.pm +++ b/src/lib/Hydra/View/TT.pm @@ -6,6 +6,7 @@ use base 'Catalyst::View::TT'; use Template::Plugin::HTML; use Hydra::Helper::Nix; use Time::Seconds; +use Digest::SHA qw(sha1_hex); __PACKAGE__->config( TEMPLATE_EXTENSION => '.tt', @@ -25,8 +26,14 @@ __PACKAGE__->config( makeNameTextForJobset relativeDuration stripSSHUser + metricDivId /]); +sub metricDivId { + my ($self, $c, $text) = @_; + return "metric-" . sha1_hex($text); +} + sub buildLogExists { my ($self, $c, $build) = @_; return 1 if defined $c->config->{log_prefix}; diff --git a/src/root/job-metrics-tab.tt b/src/root/job-metrics-tab.tt index 23d8ffa3..123a00f1 100644 --- a/src/root/job-metrics-tab.tt +++ b/src/root/job-metrics-tab.tt @@ -18,8 +18,7 @@

Metric: c.uri_for('/job' project.name jobset.name job 'metric' metric.name)) %]>[%HTML.escape(metric.name)%]

- [% id = "metric-" _ metric.name; - id = id.replace('\.', '_'); + [% id = metricDivId(metric.name); INCLUDE createChart dataUrl=c.uri_for('/job' project.name jobset.name job 'metric' metric.name); %] [% END %] -- 2.48.1 From f1deb22c028baf170f871a5b5d8c1936d04a5875 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sun, 2 Mar 2025 03:08:26 +0100 Subject: [PATCH 02/15] Fix race condition in hydra-compress-logs --- nixos-modules/hydra.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nixos-modules/hydra.nix b/nixos-modules/hydra.nix index 4fc2d311..79d639e6 100644 --- a/nixos-modules/hydra.nix +++ b/nixos-modules/hydra.nix @@ -468,7 +468,7 @@ in elif [[ $compression == zstd ]]; then compression="zstd --rm" fi - find ${baseDir}/build-logs -type f -name "*.drv" -mtime +3 -size +0c | xargs -r "$compression" --force --quiet + find ${baseDir}/build-logs -ignore_readdir_race -type f -name "*.drv" -mtime +3 -size +0c | xargs -r "$compression" --force --quiet ''; startAt = "Sun 01:45"; }; -- 2.48.1 From 9a5bd39d4c89ee7bb1607143a8755d5ab68e7f2f Mon Sep 17 00:00:00 2001 From: John Ericson Date: Mon, 3 Mar 2025 10:10:04 -0500 Subject: [PATCH 03/15] Revert "Use `LegacySSHStore`" There were some hangs caused by this. Need to fix them, ideally reproducing the issue in a test, before trying this again. This reverts commit 4a4a0f901c70676ee47f830d2ff6a72789ba1baf. --- src/hydra-queue-runner/build-remote.cc | 174 ++++++++++++++++--------- src/hydra-queue-runner/state.hh | 8 +- 2 files changed, 116 insertions(+), 66 deletions(-) diff --git a/src/hydra-queue-runner/build-remote.cc b/src/hydra-queue-runner/build-remote.cc index 39970bd3..77bde2c4 100644 --- a/src/hydra-queue-runner/build-remote.cc +++ b/src/hydra-queue-runner/build-remote.cc @@ -9,10 +9,13 @@ #include "path.hh" #include "legacy-ssh-store.hh" #include "serve-protocol.hh" +#include "serve-protocol-impl.hh" #include "state.hh" #include "current-process.hh" #include "processes.hh" #include "util.hh" +#include "serve-protocol.hh" +#include "serve-protocol-impl.hh" #include "ssh.hh" #include "finally.hh" #include "url.hh" @@ -36,6 +39,38 @@ bool ::Machine::isLocalhost() const namespace nix::build_remote { +static std::unique_ptr openConnection( + ::Machine::ptr machine, SSHMaster & master) +{ + Strings command = {"nix-store", "--serve", "--write"}; + if (machine->isLocalhost()) { + command.push_back("--builders"); + command.push_back(""); + } else { + auto remoteStore = machine->storeUri.params.find("remote-store"); + if (remoteStore != machine->storeUri.params.end()) { + command.push_back("--store"); + command.push_back(shellEscape(remoteStore->second)); + } + } + + auto ret = master.startCommand(std::move(command), { + "-a", "-oBatchMode=yes", "-oConnectTimeout=60", "-oTCPKeepAlive=yes" + }); + + // XXX: determine the actual max value we can use from /proc. + + // FIXME: Should this be upstreamed into `startCommand` in Nix? + + int pipesize = 1024 * 1024; + + fcntl(ret->in.get(), F_SETPIPE_SZ, &pipesize); + fcntl(ret->out.get(), F_SETPIPE_SZ, &pipesize); + + return ret; +} + + static void copyClosureTo( ::Machine::Connection & conn, Store & destStore, @@ -52,8 +87,8 @@ static void copyClosureTo( // FIXME: substitute output pollutes our build log /* Get back the set of paths that are already valid on the remote host. */ - auto present = conn.store->queryValidPaths( - closure, true, useSubstitutes); + auto present = conn.queryValidPaths( + destStore, true, closure, useSubstitutes); if (present.size() == closure.size()) return; @@ -68,7 +103,12 @@ static void copyClosureTo( std::unique_lock sendLock(conn.machine->state->sendLock, std::chrono::seconds(600)); - conn.store->addMultipleToStoreLegacy(destStore, missing); + conn.to << ServeProto::Command::ImportPaths; + destStore.exportPaths(missing, conn.to); + conn.to.flush(); + + if (readInt(conn.from) != 1) + throw Error("remote machine failed to import closure"); } @@ -188,7 +228,7 @@ static BuildResult performBuild( counter & nrStepsBuilding ) { - auto kont = conn.store->buildDerivationAsync(drvPath, drv, options); + conn.putBuildDerivationRequest(localStore, drvPath, drv, options); BuildResult result; @@ -197,10 +237,7 @@ static BuildResult performBuild( startTime = time(0); { MaintainCount mc(nrStepsBuilding); - result = kont(); - // Without proper call-once functions, we need to manually - // delete after calling. - kont = {}; + result = ServeProto::Serialise::read(localStore, conn); } stopTime = time(0); @@ -216,7 +253,7 @@ static BuildResult performBuild( // If the protocol was too old to give us `builtOutputs`, initialize // it manually by introspecting the derivation. - if (GET_PROTOCOL_MINOR(conn.store->getProtocol()) < 6) + if (GET_PROTOCOL_MINOR(conn.remoteVersion) < 6) { // If the remote is too old to handle CA derivations, we can’t get this // far anyways @@ -249,25 +286,26 @@ static void copyPathFromRemote( const ValidPathInfo & info ) { - /* Receive the NAR from the remote and add it to the - destination store. Meanwhile, extract all the info from the - NAR that getBuildOutput() needs. */ - auto source2 = sinkToSource([&](Sink & sink) - { - /* Note: we should only send the command to dump the store - path to the remote if the NAR is actually going to get read - by the destination store, which won't happen if this path - is already valid on the destination store. Since this - lambda function only gets executed if someone tries to read - from source2, we will send the command from here rather - than outside the lambda. */ - conn.store->narFromPath(info.path, [&](Source & source) { - TeeSource tee{source, sink}; - extractNarData(tee, conn.store->printStorePath(info.path), narMembers); - }); - }); + /* Receive the NAR from the remote and add it to the + destination store. Meanwhile, extract all the info from the + NAR that getBuildOutput() needs. */ + auto source2 = sinkToSource([&](Sink & sink) + { + /* Note: we should only send the command to dump the store + path to the remote if the NAR is actually going to get read + by the destination store, which won't happen if this path + is already valid on the destination store. Since this + lambda function only gets executed if someone tries to read + from source2, we will send the command from here rather + than outside the lambda. */ + conn.to << ServeProto::Command::DumpStorePath << localStore.printStorePath(info.path); + conn.to.flush(); - destStore.addToStore(info, *source2, NoRepair, NoCheckSigs); + TeeSource tee(conn.from, sink); + extractNarData(tee, localStore.printStorePath(info.path), narMembers); + }); + + destStore.addToStore(info, *source2, NoRepair, NoCheckSigs); } static void copyPathsFromRemote( @@ -366,39 +404,30 @@ void State::buildRemote(ref destStore, updateStep(ssConnecting); - // FIXME: rewrite to use Store. - ::Machine::Connection conn { - .machine = machine, - .store = [&]{ - auto * pSpecified = std::get_if(&machine->storeUri.variant); - if (!pSpecified || pSpecified->scheme != "ssh") { - throw Error("Currently, only (legacy-)ssh stores are supported!"); - } + auto storeRef = machine->completeStoreReference(); - auto remoteStore = machine->openStore().dynamic_pointer_cast(); - assert(remoteStore); + auto * pSpecified = std::get_if(&storeRef.variant); + if (!pSpecified || pSpecified->scheme != "ssh") { + throw Error("Currently, only (legacy-)ssh stores are supported!"); + } - remoteStore->connPipeSize = 1024 * 1024; - - if (machine->isLocalhost()) { - auto rp_new = remoteStore->remoteProgram.get(); - rp_new.push_back("--builders"); - rp_new.push_back(""); - const_cast &>(remoteStore->remoteProgram).assign(rp_new); - } - remoteStore->extraSshArgs = { - "-a", "-oBatchMode=yes", "-oConnectTimeout=60", "-oTCPKeepAlive=yes" - }; - const_cast &>(remoteStore->logFD).assign(logFD.get()); - - return nix::ref{remoteStore}; - }(), + LegacySSHStoreConfig storeConfig { + pSpecified->scheme, + pSpecified->authority, + storeRef.params }; + auto master = storeConfig.createSSHMaster( + false, // no SSH master yet + logFD.get()); + + // FIXME: rewrite to use Store. + auto child = build_remote::openConnection(machine, master); + { auto activeStepState(activeStep->state_.lock()); if (activeStepState->cancelled) throw Error("step cancelled"); - activeStepState->pid = conn.store->getConnectionPid(); + activeStepState->pid = child->sshPid; } Finally clearPid([&]() { @@ -413,12 +442,35 @@ void State::buildRemote(ref destStore, process. Meh. */ }); + ::Machine::Connection conn { + { + .to = child->in.get(), + .from = child->out.get(), + /* Handshake. */ + .remoteVersion = 0xdadbeef, // FIXME avoid dummy initialize + }, + /*.machine =*/ machine, + }; + Finally updateStats([&]() { - auto stats = conn.store->getConnectionStats(); - bytesReceived += stats.bytesReceived; - bytesSent += stats.bytesSent; + bytesReceived += conn.from.read; + bytesSent += conn.to.written; }); + constexpr ServeProto::Version our_version = 0x206; + + try { + conn.remoteVersion = decltype(conn)::handshake( + conn.to, + conn.from, + our_version, + machine->storeUri.render()); + } catch (EndOfFile & e) { + child->sshPid.wait(); + std::string s = chomp(readFile(result.logFile)); + throw Error("cannot connect to ‘%1%’: %2%", machine->storeUri.render(), s); + } + { auto info(machine->state->connectInfo.lock()); info->consecutiveFailures = 0; @@ -487,7 +539,7 @@ void State::buildRemote(ref destStore, auto now1 = std::chrono::steady_clock::now(); - auto infos = conn.store->queryPathInfosUncached(outputs); + auto infos = conn.queryPathInfos(*localStore, outputs); size_t totalNarSize = 0; for (auto & [_, info] : infos) totalNarSize += info.narSize; @@ -522,11 +574,9 @@ void State::buildRemote(ref destStore, } } - /* Shut down the connection done by RAII. - - Only difference is kill() instead of wait() (i.e. send signal - then wait()) - */ + /* Shut down the connection. */ + child->in = -1; + child->sshPid.wait(); } catch (Error & e) { /* Disable this machine until a certain period of time has diff --git a/src/hydra-queue-runner/state.hh b/src/hydra-queue-runner/state.hh index e2d31434..30e01c74 100644 --- a/src/hydra-queue-runner/state.hh +++ b/src/hydra-queue-runner/state.hh @@ -20,7 +20,9 @@ #include "store-api.hh" #include "sync.hh" #include "nar-extractor.hh" -#include "legacy-ssh-store.hh" +#include "serve-protocol.hh" +#include "serve-protocol-impl.hh" +#include "serve-protocol-connection.hh" #include "machines.hh" @@ -290,11 +292,9 @@ struct Machine : nix::Machine bool isLocalhost() const; // A connection to a machine - struct Connection { + struct Connection : nix::ServeProto::BasicClientConnection { // Backpointer to the machine ptr machine; - // Opened store - nix::ref store; }; }; -- 2.48.1 From 987dad3371bb661e31c1c4da90a8cc5081d34002 Mon Sep 17 00:00:00 2001 From: Robin Stumm Date: Wed, 26 Mar 2025 20:23:26 +0100 Subject: [PATCH 04/15] hydra-eval-jobset: do not wait on n-e-j inside transaction fixes #1429 --- src/script/hydra-eval-jobset | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/script/hydra-eval-jobset b/src/script/hydra-eval-jobset index 5b4026a5..cf3fa294 100755 --- a/src/script/hydra-eval-jobset +++ b/src/script/hydra-eval-jobset @@ -773,6 +773,9 @@ sub checkJobsetWrapped { my $jobsetChanged = 0; my %buildMap; + my @jobs; + push @jobs, $_ while defined($_ = $jobsIter->()); + $db->txn_do(sub { my $prevEval = getPrevJobsetEval($db, $jobset, 1); @@ -796,7 +799,7 @@ sub checkJobsetWrapped { my @jobsWithConstituents; - while (defined(my $job = $jobsIter->())) { + foreach my $job (@jobs) { if ($jobsetsJobset) { die "The .jobsets jobset must only have a single job named 'jobsets'" unless $job->{attr} eq "jobsets"; -- 2.48.1 From 4bcbed2f1b4bb517a4ec02328d934f3c5c0cc5da Mon Sep 17 00:00:00 2001 From: zowoq <59103226+zowoq@users.noreply.github.com> Date: Fri, 28 Mar 2025 11:11:26 +1000 Subject: [PATCH 05/15] hydraTest: remove outdated postgresql version error: postgresql_12 has been removed since it reached its EOL upstream --- nixos-modules/default.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/nixos-modules/default.nix b/nixos-modules/default.nix index 62b18406..d12d8338 100644 --- a/nixos-modules/default.nix +++ b/nixos-modules/default.nix @@ -15,7 +15,6 @@ systemd.services.hydra-send-stats.enable = false; services.postgresql.enable = true; - services.postgresql.package = pkgs.postgresql_12; # The following is to work around the following error from hydra-server: # [error] Caught exception in engine "Cannot determine local time zone" -- 2.48.1 From feebb618978a39b07ca040417d1e6fcb63b402ec Mon Sep 17 00:00:00 2001 From: zowoq <59103226+zowoq@users.noreply.github.com> Date: Fri, 28 Mar 2025 11:07:25 +1000 Subject: [PATCH 06/15] flake.lock: Update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flake lock file updates: • Updated input 'nix-eval-jobs': 'github:nix-community/nix-eval-jobs/4b392b284877d203ae262e16af269f702df036bc?narHash=sha256-3wIReAqdTALv39gkWXLMZQvHyBOc3yPkWT2ZsItxedY%3D' (2025-02-14) → 'github:nix-community/nix-eval-jobs/f7418fc1fa45b96d37baa95ff3c016dd5be3876b?narHash=sha256-Lo4KFBNcY8tmBuCmEr2XV0IUZtxXHmbXPNLkov/QSU0%3D' (2025-03-26) --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index c47a3b88..6f1b1347 100644 --- a/flake.lock +++ b/flake.lock @@ -29,11 +29,11 @@ "nix-eval-jobs": { "flake": false, "locked": { - "lastModified": 1739500569, - "narHash": "sha256-3wIReAqdTALv39gkWXLMZQvHyBOc3yPkWT2ZsItxedY=", + "lastModified": 1743008255, + "narHash": "sha256-Lo4KFBNcY8tmBuCmEr2XV0IUZtxXHmbXPNLkov/QSU0=", "owner": "nix-community", "repo": "nix-eval-jobs", - "rev": "4b392b284877d203ae262e16af269f702df036bc", + "rev": "f7418fc1fa45b96d37baa95ff3c016dd5be3876b", "type": "github" }, "original": { -- 2.48.1 From 9911f0107fc0a7a8142f3b9b194e83d3cbcd08bd Mon Sep 17 00:00:00 2001 From: Maximilian Bosch Date: Thu, 16 Jan 2025 15:50:00 +0100 Subject: [PATCH 07/15] Reimplement (named) constituent jobs (+globbing) based on nix-eval-jobs Depends on https://github.com/nix-community/nix-eval-jobs/pull/349 & #1421. Almost equivalent to #1425, but with a small change: when having e.g. an aggregate job with a glob that matches nothing, the jobset evaluation is failed now. This was the intended behavior before (hydra-eval-jobset fails hard if an aggregate is broken), the code-path was never reached however since the aggregate was never marked as broken in this case before. --- t/evaluator/evaluate-constituents-broken.t | 68 ++++++--- t/evaluator/evaluate-constituents-globbing.t | 138 +++++++++++++++++++ t/jobs/config.nix | 14 ++ t/jobs/constituents-cycle-glob.nix | 34 +++++ t/jobs/constituents-cycle.nix | 21 +++ t/jobs/constituents-glob-all.nix | 22 +++ t/jobs/constituents-glob.nix | 31 +++++ t/jobs/constituents-no-matches.nix | 20 +++ t/jobs/declarative/project.json | 24 ++++ t/queue-runner/constituents.t | 4 +- 10 files changed, 354 insertions(+), 22 deletions(-) create mode 100644 t/evaluator/evaluate-constituents-globbing.t create mode 100644 t/jobs/config.nix create mode 100644 t/jobs/constituents-cycle-glob.nix create mode 100644 t/jobs/constituents-cycle.nix create mode 100644 t/jobs/constituents-glob-all.nix create mode 100644 t/jobs/constituents-glob.nix create mode 100644 t/jobs/constituents-no-matches.nix create mode 100644 t/jobs/declarative/project.json diff --git a/t/evaluator/evaluate-constituents-broken.t b/t/evaluator/evaluate-constituents-broken.t index 0e5960bf..1391b618 100644 --- a/t/evaluator/evaluate-constituents-broken.t +++ b/t/evaluator/evaluate-constituents-broken.t @@ -6,27 +6,55 @@ use Hydra::Helper::Exec; my $ctx = test_context(); -my $jobsetCtx = $ctx->makeJobset( - expression => 'constituents-broken.nix', -); -my $jobset = $jobsetCtx->{"jobset"}; +subtest "broken constituents expression" => sub { + my $jobsetCtx = $ctx->makeJobset( + expression => 'constituents-broken.nix', + ); + my $jobset = $jobsetCtx->{"jobset"}; -my ($res, $stdout, $stderr) = captureStdoutStderr(60, - ("hydra-eval-jobset", $jobsetCtx->{"project"}->name, $jobset->name) -); -isnt($res, 0, "hydra-eval-jobset exits non-zero"); -ok(utf8::decode($stderr), "Stderr output is UTF8-clean"); -like( - $stderr, - qr/aggregate job ‘mixed_aggregate’ failed with the error: "constituentA": does not exist/, - "The stderr record includes a relevant error message" -); + my ($res, $stdout, $stderr) = captureStdoutStderr(60, + ("hydra-eval-jobset", $jobsetCtx->{"project"}->name, $jobset->name) + ); + isnt($res, 0, "hydra-eval-jobset exits non-zero"); + ok(utf8::decode($stderr), "Stderr output is UTF8-clean"); + like( + $stderr, + qr/aggregate job 'mixed_aggregate' references non-existent job 'constituentA'/, + "The stderr record includes a relevant error message" + ); -$jobset->discard_changes({ '+columns' => {'errormsg' => 'errormsg'} }); # refresh from DB -like( - $jobset->errormsg, - qr/aggregate job ‘mixed_aggregate’ failed with the error: "constituentA": does not exist/, - "The jobset records a relevant error message" -); + $jobset->discard_changes({ '+columns' => {'errormsg' => 'errormsg'} }); # refresh from DB + like( + $jobset->errormsg, + qr/aggregate job ‘mixed_aggregate’ failed with the error: constituentA: does not exist/, + "The jobset records a relevant error message" + ); +}; + +subtest "no matches" => sub { + my $jobsetCtx = $ctx->makeJobset( + expression => 'constituents-no-matches.nix', + ); + my $jobset = $jobsetCtx->{"jobset"}; + + my ($res, $stdout, $stderr) = captureStdoutStderr(60, + ("hydra-eval-jobset", $jobsetCtx->{"project"}->name, $jobset->name) + ); + isnt($res, 0, "hydra-eval-jobset exits non-zero"); + ok(utf8::decode($stderr), "Stderr output is UTF8-clean"); + like( + $stderr, + qr/aggregate job 'non_match_aggregate' references constituent glob pattern 'tests\.\*' with no matches/, + "The stderr record includes a relevant error message" + ); + + $jobset->discard_changes({ '+columns' => {'errormsg' => 'errormsg'} }); # refresh from DB + like( + $jobset->errormsg, + qr/aggregate job ‘non_match_aggregate’ failed with the error: tests\.\*: constituent glob pattern had no matches/, + qr/in job ‘non_match_aggregate’:\ntests\.\*: constituent glob pattern had no matches/, + "The jobset records a relevant error message" + ); +}; done_testing; diff --git a/t/evaluator/evaluate-constituents-globbing.t b/t/evaluator/evaluate-constituents-globbing.t new file mode 100644 index 00000000..49315d58 --- /dev/null +++ b/t/evaluator/evaluate-constituents-globbing.t @@ -0,0 +1,138 @@ +use strict; +use warnings; +use Setup; +use Test2::V0; +use Hydra::Helper::Exec; +use Data::Dumper; + +my $ctx = test_context(); + +subtest "general glob testing" => sub { + my $jobsetCtx = $ctx->makeJobset( + expression => 'constituents-glob.nix', + ); + my $jobset = $jobsetCtx->{"jobset"}; + + my ($res, $stdout, $stderr) = captureStdoutStderr(60, + ("hydra-eval-jobset", $jobsetCtx->{"project"}->name, $jobset->name) + ); + is($res, 0, "hydra-eval-jobset exits zero"); + + my $builds = {}; + for my $build ($jobset->builds) { + $builds->{$build->job} = $build; + } + + subtest "basic globbing works" => sub { + ok(defined $builds->{"ok_aggregate"}, "'ok_aggregate' is part of the jobset evaluation"); + my @constituents = $builds->{"ok_aggregate"}->constituents->all; + is(2, scalar @constituents, "'ok_aggregate' has two constituents"); + + my @sortedConstituentNames = sort (map { $_->nixname } @constituents); + + is($sortedConstituentNames[0], "empty-dir-A", "first constituent of 'ok_aggregate' is 'empty-dir-A'"); + is($sortedConstituentNames[1], "empty-dir-B", "second constituent of 'ok_aggregate' is 'empty-dir-B'"); + }; + + subtest "transitivity is OK" => sub { + ok(defined $builds->{"indirect_aggregate"}, "'indirect_aggregate' is part of the jobset evaluation"); + my @constituents = $builds->{"indirect_aggregate"}->constituents->all; + is(1, scalar @constituents, "'indirect_aggregate' has one constituent"); + is($constituents[0]->nixname, "direct_aggregate", "'indirect_aggregate' has 'direct_aggregate' as single constituent"); + }; +}; + +subtest "* selects all except current aggregate" => sub { + my $jobsetCtx = $ctx->makeJobset( + expression => 'constituents-glob-all.nix', + ); + my $jobset = $jobsetCtx->{"jobset"}; + + my ($res, $stdout, $stderr) = captureStdoutStderr(60, + ("hydra-eval-jobset", $jobsetCtx->{"project"}->name, $jobset->name) + ); + + subtest "no eval errors" => sub { + ok(utf8::decode($stderr), "Stderr output is UTF8-clean"); + ok( + $stderr !~ "aggregate job ‘ok_aggregate’ has a constituent .* that doesn't correspond to a Hydra build", + "Catchall wildcard must not select itself as constituent" + ); + + $jobset->discard_changes; # refresh from DB + is( + $jobset->errormsg, + "", + "eval-errors non-empty" + ); + }; + + my $builds = {}; + for my $build ($jobset->builds) { + $builds->{$build->job} = $build; + } + + subtest "two constituents" => sub { + ok(defined $builds->{"ok_aggregate"}, "'ok_aggregate' is part of the jobset evaluation"); + my @constituents = $builds->{"ok_aggregate"}->constituents->all; + is(2, scalar @constituents, "'ok_aggregate' has two constituents"); + + my @sortedConstituentNames = sort (map { $_->nixname } @constituents); + + is($sortedConstituentNames[0], "empty-dir-A", "first constituent of 'ok_aggregate' is 'empty-dir-A'"); + is($sortedConstituentNames[1], "empty-dir-B", "second constituent of 'ok_aggregate' is 'empty-dir-B'"); + }; +}; + +subtest "trivial cycle check" => sub { + my $jobsetCtx = $ctx->makeJobset( + expression => 'constituents-cycle.nix', + ); + my $jobset = $jobsetCtx->{"jobset"}; + + my ($res, $stdout, $stderr) = captureStdoutStderr(60, + ("hydra-eval-jobset", $jobsetCtx->{"project"}->name, $jobset->name) + ); + + ok( + $stderr =~ "Found dependency cycle between jobs 'indirect_aggregate' and 'ok_aggregate'", + "Dependency cycle error is on stderr" + ); + + ok(utf8::decode($stderr), "Stderr output is UTF8-clean"); + + $jobset->discard_changes; # refresh from DB + like( + $jobset->errormsg, + qr/Dependency cycle: indirect_aggregate <-> ok_aggregate/, + "eval-errors non-empty" + ); + + is(0, $jobset->builds->count, "No builds should be scheduled"); +}; + +subtest "cycle check with globbing" => sub { + my $jobsetCtx = $ctx->makeJobset( + expression => 'constituents-cycle-glob.nix', + ); + my $jobset = $jobsetCtx->{"jobset"}; + + my ($res, $stdout, $stderr) = captureStdoutStderr(60, + ("hydra-eval-jobset", $jobsetCtx->{"project"}->name, $jobset->name) + ); + + ok(utf8::decode($stderr), "Stderr output is UTF8-clean"); + + $jobset->discard_changes; # refresh from DB + like( + $jobset->errormsg, + qr/aggregate job ‘indirect_aggregate’ failed with the error: Dependency cycle: indirect_aggregate <-> packages.constituentA/, + "packages.constituentA error missing" + ); + + # on this branch of Hydra, hydra-eval-jobset fails hard if an aggregate + # job is broken. + is(0, $jobset->builds->count, "Zero jobs are scheduled"); +}; + +done_testing; diff --git a/t/jobs/config.nix b/t/jobs/config.nix new file mode 100644 index 00000000..91fd1d1a --- /dev/null +++ b/t/jobs/config.nix @@ -0,0 +1,14 @@ +rec { + path = "/nix/store/l9mg93sgx50y88p5rr6x1vib6j1rjsds-coreutils-9.1/bin"; + + mkDerivation = args: + derivation ({ + system = builtins.currentSystem; + PATH = path; + } // args); + mkContentAddressedDerivation = args: mkDerivation ({ + __contentAddressed = true; + outputHashMode = "recursive"; + outputHashAlgo = "sha256"; + } // args); +} diff --git a/t/jobs/constituents-cycle-glob.nix b/t/jobs/constituents-cycle-glob.nix new file mode 100644 index 00000000..efc152ce --- /dev/null +++ b/t/jobs/constituents-cycle-glob.nix @@ -0,0 +1,34 @@ +with import ./config.nix; +{ + packages.constituentA = mkDerivation { + name = "empty-dir-A"; + builder = ./empty-dir-builder.sh; + _hydraAggregate = true; + _hydraGlobConstituents = true; + constituents = [ "*_aggregate" ]; + }; + + packages.constituentB = mkDerivation { + name = "empty-dir-B"; + builder = ./empty-dir-builder.sh; + }; + + ok_aggregate = mkDerivation { + name = "direct_aggregate"; + _hydraAggregate = true; + _hydraGlobConstituents = true; + constituents = [ + "packages.*" + ]; + builder = ./empty-dir-builder.sh; + }; + + indirect_aggregate = mkDerivation { + name = "indirect_aggregate"; + _hydraAggregate = true; + constituents = [ + "ok_aggregate" + ]; + builder = ./empty-dir-builder.sh; + }; +} diff --git a/t/jobs/constituents-cycle.nix b/t/jobs/constituents-cycle.nix new file mode 100644 index 00000000..7e086aa1 --- /dev/null +++ b/t/jobs/constituents-cycle.nix @@ -0,0 +1,21 @@ +with import ./config.nix; +{ + ok_aggregate = mkDerivation { + name = "direct_aggregate"; + _hydraAggregate = true; + _hydraGlobConstituents = true; + constituents = [ + "indirect_aggregate" + ]; + builder = ./empty-dir-builder.sh; + }; + + indirect_aggregate = mkDerivation { + name = "indirect_aggregate"; + _hydraAggregate = true; + constituents = [ + "ok_aggregate" + ]; + builder = ./empty-dir-builder.sh; + }; +} diff --git a/t/jobs/constituents-glob-all.nix b/t/jobs/constituents-glob-all.nix new file mode 100644 index 00000000..d671fd70 --- /dev/null +++ b/t/jobs/constituents-glob-all.nix @@ -0,0 +1,22 @@ +with import ./config.nix; +{ + packages.constituentA = mkDerivation { + name = "empty-dir-A"; + builder = ./empty-dir-builder.sh; + }; + + packages.constituentB = mkDerivation { + name = "empty-dir-B"; + builder = ./empty-dir-builder.sh; + }; + + ok_aggregate = mkDerivation { + name = "direct_aggregate"; + _hydraAggregate = true; + _hydraGlobConstituents = true; + constituents = [ + "*" + ]; + builder = ./empty-dir-builder.sh; + }; +} diff --git a/t/jobs/constituents-glob.nix b/t/jobs/constituents-glob.nix new file mode 100644 index 00000000..f566dbfd --- /dev/null +++ b/t/jobs/constituents-glob.nix @@ -0,0 +1,31 @@ +with import ./config.nix; +{ + packages.constituentA = mkDerivation { + name = "empty-dir-A"; + builder = ./empty-dir-builder.sh; + }; + + packages.constituentB = mkDerivation { + name = "empty-dir-B"; + builder = ./empty-dir-builder.sh; + }; + + ok_aggregate = mkDerivation { + name = "direct_aggregate"; + _hydraAggregate = true; + _hydraGlobConstituents = true; + constituents = [ + "packages.*" + ]; + builder = ./empty-dir-builder.sh; + }; + + indirect_aggregate = mkDerivation { + name = "indirect_aggregate"; + _hydraAggregate = true; + constituents = [ + "ok_aggregate" + ]; + builder = ./empty-dir-builder.sh; + }; +} diff --git a/t/jobs/constituents-no-matches.nix b/t/jobs/constituents-no-matches.nix new file mode 100644 index 00000000..699cad91 --- /dev/null +++ b/t/jobs/constituents-no-matches.nix @@ -0,0 +1,20 @@ +with import ./config.nix; +{ + non_match_aggregate = mkDerivation { + name = "mixed_aggregate"; + _hydraAggregate = true; + _hydraGlobConstituents = true; + constituents = [ + "tests.*" + ]; + builder = ./empty-dir-builder.sh; + }; + + # Without a second job no jobset is attempted to be created + # (the only job would be broken) + # and thus the constituent validation is never reached. + dummy = mkDerivation { + name = "dummy"; + builder = ./empty-dir-builder.sh; + }; +} diff --git a/t/jobs/declarative/project.json b/t/jobs/declarative/project.json new file mode 100644 index 00000000..47d6ecf2 --- /dev/null +++ b/t/jobs/declarative/project.json @@ -0,0 +1,24 @@ +{ + "enabled": 1, + "hidden": false, + "description": "declarative-jobset-example", + "nixexprinput": "src", + "nixexprpath": "declarative/generator.nix", + "checkinterval": 300, + "schedulingshares": 100, + "enableemail": false, + "emailoverride": "", + "keepnr": 3, + "inputs": { + "src": { + "type": "path", + "value": "/home/ma27/Projects/hydra-cppnix/t/jobs", + "emailresponsible": false + }, + "jobspath": { + "type": "string", + "value": "/home/ma27/Projects/hydra-cppnix/t/jobs", + "emailresponsible": false + } + } +} diff --git a/t/queue-runner/constituents.t b/t/queue-runner/constituents.t index e1b8d733..8e048a73 100644 --- a/t/queue-runner/constituents.t +++ b/t/queue-runner/constituents.t @@ -22,11 +22,11 @@ is(nrQueuedBuildsForJobset($jobset), 0, "Evaluating jobs/broken-constituent.nix like( $jobset->errormsg, - qr/^"does-not-exist": does not exist$/m, + qr/^does-not-exist: does not exist$/m, "Evaluating jobs/broken-constituent.nix should log an error for does-not-exist"); like( $jobset->errormsg, - qr/^"does-not-evaluate": "error: assertion 'false' failed/m, + qr/^does-not-evaluate: error: assertion 'false' failed/m, "Evaluating jobs/broken-constituent.nix should log an error for does-not-evaluate"); done_testing; -- 2.48.1 From ae18a7b3ae44051161f92e7ae54acc72f338089b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Sat, 29 Mar 2025 11:09:32 +0000 Subject: [PATCH 08/15] fix development workflow after switching to meson-based build --- README.md | 27 ++++++++++++++------------- doc/manual/src/hacking.md | 27 +++++++-------------------- package.nix | 2 +- 3 files changed, 22 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 8ac18ac9..54b95549 100644 --- a/README.md +++ b/README.md @@ -72,17 +72,16 @@ Make sure **State** at the top of the page is set to "_Enabled_" and click on "_ You can build Hydra via `nix-build` using the provided [default.nix](./default.nix): ``` -$ nix-build +$ nix build ``` ### Development Environment You can use the provided shell.nix to get a working development environment: ``` -$ nix-shell -$ autoreconfPhase -$ configurePhase # NOTE: not ./configure -$ make +$ nix develop +$ mesonConfigurePhase +$ ninja ``` ### Executing Hydra During Development @@ -91,9 +90,9 @@ When working on new features or bug fixes you need to be able to run Hydra from can be done using [foreman](https://github.com/ddollar/foreman): ``` -$ nix-shell +$ nix develop $ # hack hack -$ make +$ ninja -C build $ foreman start ``` @@ -115,22 +114,24 @@ Start by following the steps in [Development Environment](#development-environme Then, you can run the tests and the perlcritic linter together with: ```console -$ nix-shell -$ make check +$ nix develop +$ ninja -C build test ``` You can run a single test with: ``` -$ nix-shell -$ yath test ./t/foo/bar.t +$ nix develop +$ cd build +$ meson test --test-args=../t/Hydra/Event.t testsuite ``` And you can run just perlcritic with: ``` -$ nix-shell -$ make perlcritic +$ nix develop +$ cd build +$ meson test perlcritic ``` ### JSON API diff --git a/doc/manual/src/hacking.md b/doc/manual/src/hacking.md index ec96b8c6..8b2b13ba 100644 --- a/doc/manual/src/hacking.md +++ b/doc/manual/src/hacking.md @@ -11,12 +11,6 @@ $ cd hydra To enter a shell in which all environment variables (such as `PERL5LIB`) and dependencies can be found: -```console -$ nix-shell -``` - -of when flakes are enabled: - ```console $ nix develop ``` @@ -24,15 +18,15 @@ $ nix develop To build Hydra, you should then do: ```console -[nix-shell]$ autoreconfPhase -[nix-shell]$ configurePhase -[nix-shell]$ make -j$(nproc) +$ mesonConfigurePhase +$ ninja ``` You start a local database, the webserver, and other components with foreman: ```console +$ ninja -C build $ foreman start ``` @@ -47,18 +41,11 @@ $ ./src/script/hydra-server You can run Hydra's test suite with the following: ```console -[nix-shell]$ make check -[nix-shell]$ # to run as many tests as you have cores: -[nix-shell]$ make check YATH_JOB_COUNT=$NIX_BUILD_CORES -[nix-shell]$ # or run yath directly: -[nix-shell]$ yath test -[nix-shell]$ # to run as many tests as you have cores: -[nix-shell]$ yath test -j $NIX_BUILD_CORES +$ meson test +# to run as many tests as you have cores: +$ YATH_JOB_COUNT=$NIX_BUILD_CORES meson test ``` -When using `yath` instead of `make check`, ensure you have run `make` -in the root of the repository at least once. - **Warning**: Currently, the tests can fail if run with high parallelism [due to an issue in `Test::PostgreSQL`](https://github.com/TJC/Test-postgresql/issues/40) @@ -75,7 +62,7 @@ will reload the page every time you save. To build Hydra and its dependencies: ```console -$ nix-build release.nix -A build.x86_64-linux +$ nix build .#packages.x86_64-linux.default ``` ## Development Tasks diff --git a/package.nix b/package.nix index 12fac1d8..07701bc9 100644 --- a/package.nix +++ b/package.nix @@ -241,7 +241,7 @@ stdenv.mkDerivation (finalAttrs: { shellHook = '' pushd $(git rev-parse --show-toplevel) >/dev/null - PATH=$(pwd)/src/hydra-evaluator:$(pwd)/src/script:$(pwd)/src/hydra-queue-runner:$PATH + PATH=$(pwd)/build/src/hydra-evaluator:$(pwd)/build/src/script:$(pwd)/build/src/hydra-queue-runner:$PATH PERL5LIB=$(pwd)/src/lib:$PERL5LIB export HYDRA_HOME="$(pwd)/src/" mkdir -p .hydra-data -- 2.48.1 From 3fef32b3643e04626f4fe6903eb7afc3ba383fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Sat, 29 Mar 2025 11:10:17 +0000 Subject: [PATCH 09/15] gitignore hydra-data as created by foreman --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ddcbadc4..12df926f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /src/sql/hydra-postgresql.sql /src/sql/hydra-sqlite.sql /src/sql/tmp.sqlite +.hydra-data result result-* outputs -- 2.48.1 From 0f3fcf5e80cb6206a7379f9e49e9d00dafc35481 Mon Sep 17 00:00:00 2001 From: Faye Chun Date: Wed, 18 Dec 2024 04:27:22 -0500 Subject: [PATCH 10/15] Add a plugin to poll Gitea pull requests Based off the existing GithubPulls.pm and GitlabPulls.pm plugins. Also adds an integration test for the new 'giteapulls' input type to the existing 'gitea' test. --- nixos-tests.nix | 73 +++++++++++++++++++++----- src/lib/Hydra/Plugin/GiteaPulls.pm | 84 ++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 src/lib/Hydra/Plugin/GiteaPulls.pm diff --git a/nixos-tests.nix b/nixos-tests.nix index 9efe68c8..2de75eeb 100644 --- a/nixos-tests.nix +++ b/nixos-tests.nix @@ -145,10 +145,18 @@ in git -C /tmp/repo add . git config --global user.email test@localhost git config --global user.name test + + # Create initial commit git -C /tmp/repo commit -m 'Initial import' git -C /tmp/repo remote add origin gitea@machine:root/repo - GIT_SSH_COMMAND='ssh -i $HOME/.ssh/privk -o StrictHostKeyChecking=no' \ - git -C /tmp/repo push origin master + export GIT_SSH_COMMAND='ssh -i $HOME/.ssh/privk -o StrictHostKeyChecking=no' + git -C /tmp/repo push origin master + git -C /tmp/repo log >&2 + + # Create PR branch + git -C /tmp/repo checkout -b pr + git -C /tmp/repo commit --allow-empty -m 'Additional change' + git -C /tmp/repo push origin pr git -C /tmp/repo log >&2 ''; @@ -185,7 +193,7 @@ in cat >data.json < $out; exit 0"]; + { pulls, ... }: + + let + genDrv = name: builtins.derivation { + inherit name; + system = "${system}"; + builder = "/bin/sh"; + allowSubstitutes = false; + preferLocalBuild = true; + args = ["-c" "echo success > $out; exit 0"]; }; - } + + prs = builtins.fromJSON (builtins.readFile pulls); + prJobNames = map (n: "pr-''${n}") (builtins.attrNames prs); + prJobset = builtins.listToAttrs ( + map ( + name: { + inherit name; + value = genDrv name; + } + ) prJobNames + ); + in { + trivial = genDrv "trivial"; + } // prJobset ''; in '' @@ -279,18 +308,34 @@ in + '| jq .buildstatus | xargs test 0 -eq' ) + machine.sleep(3) + data = machine.succeed( - 'curl -Lf -s "http://localhost:3001/api/v1/repos/root/repo/statuses/$(cd /tmp/repo && git show | head -n1 | awk "{print \\$2}")" ' + 'curl -Lf -s "http://localhost:3001/api/v1/repos/root/repo/statuses/$(cd /tmp/repo && git show master | head -n1 | awk "{print \\$2}")?sort=leastindex" ' + "-H 'Accept: application/json' -H 'Content-Type: application/json' " + f"-H 'Authorization: token ${api_token}'" ) response = json.loads(data) - assert len(response) == 2, "Expected exactly three status updates for latest commit (queued, finished)!" + assert len(response) == 2, "Expected exactly two status updates for latest commit (queued, finished)!" assert response[0]['status'] == "success", "Expected finished status to be success!" assert response[1]['status'] == "pending", "Expected queued status to be pending!" + # giteapulls test + + machine.succeed( + "curl --fail -X POST http://localhost:3001/api/v1/repos/root/repo/pulls " + + "-H 'Accept: application/json' -H 'Content-Type: application/json' " + + f"-H 'Authorization: token ${api_token}'" + + ' -d \'{"title":"Test PR", "base":"master", "head": "pr"}\''' + ) + + machine.wait_until_succeeds( + 'curl -Lf -s http://localhost:3000/build/2 -H "Accept: application/json" ' + + '| jq .buildstatus | xargs test 0 -eq' + ) + machine.shutdown() ''; }); diff --git a/src/lib/Hydra/Plugin/GiteaPulls.pm b/src/lib/Hydra/Plugin/GiteaPulls.pm new file mode 100644 index 00000000..c43d207d --- /dev/null +++ b/src/lib/Hydra/Plugin/GiteaPulls.pm @@ -0,0 +1,84 @@ +# Allow building based on Gitea pull requests. +# +# Example input: +# "pulls": { +# "type": "giteapulls", +# "value": "example.com alice repo" +# "emailresponsible": false +# } + +package Hydra::Plugin::GiteaPulls; + +use strict; +use warnings; +use parent 'Hydra::Plugin'; +use HTTP::Request; +use LWP::UserAgent; +use JSON::MaybeXS; +use Hydra::Helper::CatalystUtils; +use File::Temp; +use POSIX qw(strftime); + +sub supportedInputTypes { + my ($self, $inputTypes) = @_; + $inputTypes->{'giteapulls'} = 'Open Gitea Pull Requests'; +} + +sub _iterate { + my ($url, $auth, $pulls, $ua) = @_; + + my $req = HTTP::Request->new('GET', $url); + $req->header('Authorization' => 'token ' . $auth) if defined $auth; + + my $res = $ua->request($req); + my $content = $res->decoded_content; + die "Error pulling from the gitea pulls API: $content\n" + unless $res->is_success; + + my $pulls_list = decode_json $content; + + foreach my $pull (@$pulls_list) { + $pulls->{$pull->{number}} = $pull; + } + + # TODO Make Link header parsing more robust!!! + my @links = split ',', ($res->header("Link") // ""); + my $next = ""; + foreach my $link (@links) { + my ($url, $rel) = split ";", $link; + if (trim($rel) eq 'rel="next"') { + $next = substr trim($url), 1, -1; + last; + } + } + _iterate($next, $auth, $pulls, $ua) unless $next eq ""; +} + +sub fetchInput { + my ($self, $type, $name, $value, $project, $jobset) = @_; + return undef if $type ne "giteapulls"; + + my ($baseUrl, $owner, $repo, $proto) = split ' ', $value; + if (not defined $proto) { # the protocol handler is exposed as an option in order to do integration testing + $proto = "https" + } + my $auth = $self->{config}->{gitea_authorization}->{$owner}; + + my $ua = LWP::UserAgent->new(); + my %pulls; + _iterate("$proto://$baseUrl/api/v1/repos/$owner/$repo/pulls?limit=100", $auth, \%pulls, $ua); + + my $tempdir = File::Temp->newdir("gitea-pulls" . "XXXXX", TMPDIR => 1); + my $filename = "$tempdir/gitea-pulls.json"; + open(my $fh, ">", $filename) or die "Cannot open $filename for writing: $!"; + print $fh encode_json \%pulls; + close $fh; + + my $storePath = trim(`nix-store --add "$filename"` + or die "cannot copy path $filename to the Nix store.\n"); + chomp $storePath; + my $timestamp = time; + return { storePath => $storePath, revision => strftime "%Y%m%d%H%M%S", gmtime($timestamp) }; +} + +1; -- 2.48.1 From ff5a37edc46d8ff19d4614a93aa6115253b19f3d Mon Sep 17 00:00:00 2001 From: ahuston-0 Date: Mon, 31 Mar 2025 00:56:15 -0400 Subject: [PATCH 11/15] add Gitea pulls docs entry Signed-off-by: ahuston-0 --- doc/manual/src/plugins/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/doc/manual/src/plugins/README.md b/doc/manual/src/plugins/README.md index 93aa80b4..d3712e67 100644 --- a/doc/manual/src/plugins/README.md +++ b/doc/manual/src/plugins/README.md @@ -92,6 +92,14 @@ Sets Gitea CI status - `gitea_authorization.` +## Gitea pulls + +Create jobs based on open Gitea pull requests + +### Configuration options + +- `gitea_authorization.` + ## GitHub pulls Create jobs based on open GitHub pull requests -- 2.48.1 From a1a2d37290ac2dbcdcf21d5e7b33e1d6aa5c3fe3 Mon Sep 17 00:00:00 2001 From: ahuston-0 Date: Mon, 31 Mar 2025 01:55:37 -0400 Subject: [PATCH 12/15] add gitea refs Signed-off-by: ahuston-0 --- nixos-tests.nix | 13 ++- src/lib/Hydra/Plugin/GiteaRefs.pm | 129 ++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 src/lib/Hydra/Plugin/GiteaRefs.pm diff --git a/nixos-tests.nix b/nixos-tests.nix index 2de75eeb..cf50f38c 100644 --- a/nixos-tests.nix +++ b/nixos-tests.nix @@ -158,6 +158,12 @@ in git -C /tmp/repo commit --allow-empty -m 'Additional change' git -C /tmp/repo push origin pr git -C /tmp/repo log >&2 + + # Create release branch + git -C /tmp/repo checkout -b release/release-1.0 + git -C /tmp/repo commit --allow-empty -m 'Additional change' + git -C /tmp/repo push origin release/release-1.0 + git -C /tmp/repo log >&2 ''; scripts.hydra-setup = pkgs.writeShellScript "hydra.sh" '' @@ -212,6 +218,11 @@ in "type": "giteapulls", "value": "localhost:3001 root repo http", "emailresponsible": false + }, + "releases": { + "type": "gitea_refs", + "value": "localhost:3001 root repo heads http - release", + "emailresponseible": false } } } @@ -240,7 +251,7 @@ in }; smallDrv = pkgs.writeText "jobset.nix" '' - { pulls, ... }: + { pulls, releases, ... }: let genDrv = name: builtins.derivation { diff --git a/src/lib/Hydra/Plugin/GiteaRefs.pm b/src/lib/Hydra/Plugin/GiteaRefs.pm new file mode 100644 index 00000000..c9915ae4 --- /dev/null +++ b/src/lib/Hydra/Plugin/GiteaRefs.pm @@ -0,0 +1,129 @@ +package Hydra::Plugin::GiteaRefs; + +use strict; +use warnings; +use parent 'Hydra::Plugin'; +use HTTP::Request; +use LWP::UserAgent; +use JSON::MaybeXS; +use Hydra::Helper::CatalystUtils; +use File::Temp; +use POSIX qw(strftime); + +=head1 NAME + +GiteaRefs - Hydra plugin for retrieving the list of references (branches or +tags) from Gitea following a certain naming scheme + +=head1 DESCRIPTION + +This plugin reads the list of branches or tags using Gitea's REST API. The name +of the reference must follow a particular prefix. This list is stored in the +nix-store and used as an input to declarative jobsets. + +=head1 CONFIGURATION + +The plugin doesn't require any dedicated configuration block, but it has to +consult C entry for obtaining the API token. In addition, + +The declarative project C file must contains an input such as + + "pulls": { + "type": "gitea_refs", + "value": "[gitea_hostname] [owner] [repo] heads|tags [scheme] - [prefix]", + "emailresponsible": false + } + +In the above snippet, C<[gitea_hostname]> must be set to the hostname of the +repository's Gitea instance. + +C<[owner]> is the repository owner and C<[repo]> is the repository name. Also +note a literal C<->, which is placed there for the future use. + +C denotes that one of these two is allowed, that is, the third +position should hold either the C or the C keyword. In case of the former, the plugin +will fetch all branches, while in case of the latter, it will fetch the tags. + +C should be set to either https or http, depending on what the Gitea +host supports. + +C denotes the prefix the reference name must start with, in order to be +included. + +For example, C<"value": "projects.blender.org blender blender heads https - blender-v/"> refers to +L repository, and will fetch all branches that +begin with C. + +=head1 USE + +The result is stored in the nix-store as a JSON I, where the key is the +name of the reference, while the value is the complete Gitea response. Thus, +any of the values listed in +L can be +used to build the git input value in C. + +=cut + +sub supportedInputTypes { + my ($self, $inputTypes) = @_; + $inputTypes->{'gitea_refs'} = 'Open Gitea Refs'; +} + +sub _iterate { + my ($url, $auth, $refs, $ua) = @_; + my $req = HTTP::Request->new('GET', $url); + $req->header('Accept' => 'application/json'); + $req->header('Authorization' => $auth) if defined $auth; + my $res = $ua->request($req); + my $content = $res->decoded_content; + die "Error pulling from the gitea refs API: $content\n" + unless $res->is_success; + my $refs_list = decode_json $content; + # TODO Stream out the json instead + foreach my $ref (@$refs_list) { + my $ref_name = $ref->{ref}; + $ref_name =~ s,^refs/(?:heads|tags)/,,o; + $refs->{$ref_name} = $ref; + } + # TODO Make Link header parsing more robust!!! + my @links = split ',', $res->header("Link"); + my $next = ""; + foreach my $link (@links) { + my ($url, $rel) = split ";", $link; + if (trim($rel) eq 'rel="next"') { + $next = substr trim($url), 1, -1; + last; + } + } + _iterate($next, $auth, $refs, $ua) unless $next eq ""; +} + +sub fetchInput { + my ($self, $input_type, $name, $value, $project, $jobset) = @_; + return undef if $input_type ne "gitea_refs"; + + my ($giteaHostname, $owner, $repo, $type, $scheme, $fut, $prefix) = split ' ', $value; + die "type field is neither 'heads' nor 'tags', but '$type'" + unless $type eq 'heads' or $type eq 'tags'; + die "scheme field is neither 'https' nor 'http' but '$scheme'" + unless $scheme eq 'https' or $scheme eq 'http'; + + my $auth = $self->{config}->{gitea_authorization}->{$owner}; + my $giteaEndpoint = "$scheme://$giteaHostname"; + my %refs; + my $ua = LWP::UserAgent->new(); + _iterate("$giteaEndpoint/repos/$owner/$repo/git/refs/$type/$prefix?per_page=100", $auth, \%refs, $ua); + my $tempdir = File::Temp->newdir("gitea-refs" . "XXXXX", TMPDIR => 1); + my $filename = "$tempdir/gitea-refs.json"; + open(my $fh, ">", $filename) or die "Cannot open $filename for writing: $!"; + print $fh encode_json \%refs; + close $fh; + system("jq -S . < $filename > $tempdir/gitea-refs-sorted.json"); + my $storePath = trim(qx{nix-store --add "$tempdir/gitea-refs-sorted.json"} + or die "cannot copy path $filename to the Nix store.\n"); + chomp $storePath; + my $timestamp = time; + return { storePath => $storePath, revision => strftime "%Y%m%d%H%M%S", gmtime($timestamp) }; +} + +1; -- 2.48.1 From 230f71e7bbb4ad3a27c9809dad7bf7992217912e Mon Sep 17 00:00:00 2001 From: ahuston-0 Date: Mon, 31 Mar 2025 03:20:15 -0400 Subject: [PATCH 13/15] fix api endpoint --- src/lib/Hydra/Plugin/GiteaRefs.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/Hydra/Plugin/GiteaRefs.pm b/src/lib/Hydra/Plugin/GiteaRefs.pm index c9915ae4..1b728009 100644 --- a/src/lib/Hydra/Plugin/GiteaRefs.pm +++ b/src/lib/Hydra/Plugin/GiteaRefs.pm @@ -112,7 +112,7 @@ sub fetchInput { my $giteaEndpoint = "$scheme://$giteaHostname"; my %refs; my $ua = LWP::UserAgent->new(); - _iterate("$giteaEndpoint/repos/$owner/$repo/git/refs/$type/$prefix?per_page=100", $auth, \%refs, $ua); + _iterate("$giteaEndpoint/api/v1/repos/$owner/$repo/git/refs/$type/$prefix?per_page=100", $auth, \%refs, $ua); my $tempdir = File::Temp->newdir("gitea-refs" . "XXXXX", TMPDIR => 1); my $filename = "$tempdir/gitea-refs.json"; open(my $fh, ">", $filename) or die "Cannot open $filename for writing: $!"; -- 2.48.1 From 082fb6d40ca6a67da224c0fb793d7e2aea97580a Mon Sep 17 00:00:00 2001 From: ahuston-0 Date: Mon, 31 Mar 2025 04:04:39 -0400 Subject: [PATCH 14/15] add gitea ref tests --- nixos-tests.nix | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/nixos-tests.nix b/nixos-tests.nix index cf50f38c..948359b0 100644 --- a/nixos-tests.nix +++ b/nixos-tests.nix @@ -273,9 +273,19 @@ in } ) prJobNames ); + rels = builtins.fromJSON (builtins.readFile releases); + relJobNames = builtins.attrNames rels; + relJobset = builtins.listToAttrs ( + map ( + name: { + inherit name; + value = genDrv name; + } + ) relJobNames + ); in { trivial = genDrv "trivial"; - } // prJobset + } // prJobset // relJobset ''; in '' -- 2.48.1 From fed0c517bf09148c56c258770bc344e52ee60fc2 Mon Sep 17 00:00:00 2001 From: ahuston-0 Date: Mon, 31 Mar 2025 04:04:52 -0400 Subject: [PATCH 15/15] add gitea refs to docs --- doc/manual/src/plugins/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/doc/manual/src/plugins/README.md b/doc/manual/src/plugins/README.md index d3712e67..a9012e7b 100644 --- a/doc/manual/src/plugins/README.md +++ b/doc/manual/src/plugins/README.md @@ -100,6 +100,15 @@ Create jobs based on open Gitea pull requests - `gitea_authorization.` +## Gitea refs + +Hydra plugin for retrieving the list of references (branches or tags) from +Gitea following a certain naming scheme. + +### Configuration options + +- `gitea_authorization.` + ## GitHub pulls Create jobs based on open GitHub pull requests -- 2.48.1