Merge branch 'master' into master

This commit is contained in:
Janne Heß
2026-01-04 16:29:27 +01:00
committed by GitHub
200 changed files with 5323 additions and 3231 deletions

View File

@@ -1,3 +0,0 @@
SUBDIRS = hydra-evaluator hydra-eval-jobs hydra-queue-runner sql script lib root ttf
BOOTCLEAN_SUBDIRS = $(SUBDIRS)
DIST_SUBDIRS = $(SUBDIRS)

View File

@@ -1,5 +0,0 @@
bin_PROGRAMS = hydra-eval-jobs
hydra_eval_jobs_SOURCES = hydra-eval-jobs.cc
hydra_eval_jobs_LDADD = $(NIX_LIBS) -lnixcmd
hydra_eval_jobs_CXXFLAGS = $(NIX_CFLAGS) -I ../libhydra

View File

@@ -1,558 +0,0 @@
#include <iostream>
#include <thread>
#include <optional>
#include <unordered_map>
#include "shared.hh"
#include "store-api.hh"
#include "eval.hh"
#include "eval-inline.hh"
#include "util.hh"
#include "get-drvs.hh"
#include "globals.hh"
#include "common-eval-args.hh"
#include "flake/flakeref.hh"
#include "flake/flake.hh"
#include "attr-path.hh"
#include "derivations.hh"
#include "local-fs-store.hh"
#include "hydra-config.hh"
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/resource.h>
#include <nlohmann/json.hpp>
void check_pid_status_nonblocking(pid_t check_pid) {
// Only check 'initialized' and known PID's
if (check_pid <= 0) { return; }
int wstatus = 0;
pid_t pid = waitpid(check_pid, &wstatus, WNOHANG);
// -1 = failure, WNOHANG: 0 = no change
if (pid <= 0) { return; }
std::cerr << "child process (" << pid << ") ";
if (WIFEXITED(wstatus)) {
std::cerr << "exited with status=" << WEXITSTATUS(wstatus) << std::endl;
} else if (WIFSIGNALED(wstatus)) {
std::cerr << "killed by signal=" << WTERMSIG(wstatus) << std::endl;
} else if (WIFSTOPPED(wstatus)) {
std::cerr << "stopped by signal=" << WSTOPSIG(wstatus) << std::endl;
} else if (WIFCONTINUED(wstatus)) {
std::cerr << "continued" << std::endl;
}
}
using namespace nix;
static Path gcRootsDir;
static size_t maxMemorySize;
struct MyArgs : MixEvalArgs, MixCommonArgs
{
Path releaseExpr;
bool flake = false;
bool dryRun = false;
MyArgs() : MixCommonArgs("hydra-eval-jobs")
{
addFlag({
.longName = "gc-roots-dir",
.description = "garbage collector roots directory",
.labels = {"path"},
.handler = {&gcRootsDir}
});
addFlag({
.longName = "dry-run",
.description = "don't create store derivations",
.handler = {&dryRun, true}
});
addFlag({
.longName = "flake",
.description = "build a flake",
.handler = {&flake, true}
});
expectArg("expr", &releaseExpr);
}
};
static MyArgs myArgs;
static std::string queryMetaStrings(EvalState & state, DrvInfo & drv, const std::string & name, const std::string & subAttribute)
{
Strings res;
std::function<void(Value & v)> rec;
rec = [&](Value & v) {
state.forceValue(v, noPos);
if (v.type() == nString)
res.push_back(v.string.s);
else if (v.isList())
for (unsigned int n = 0; n < v.listSize(); ++n)
rec(*v.listElems()[n]);
else if (v.type() == nAttrs) {
auto a = v.attrs->find(state.symbols.create(subAttribute));
if (a != v.attrs->end())
res.push_back(std::string(state.forceString(*a->value)));
}
};
Value * v = drv.queryMeta(name);
if (v) rec(*v);
return concatStringsSep(", ", res);
}
static void worker(
EvalState & state,
Bindings & autoArgs,
AutoCloseFD & to,
AutoCloseFD & from)
{
Value vTop;
if (myArgs.flake) {
using namespace flake;
auto flakeRef = parseFlakeRef(myArgs.releaseExpr);
auto vFlake = state.allocValue();
auto lockedFlake = lockFlake(state, flakeRef,
LockFlags {
.updateLockFile = false,
.useRegistries = false,
.allowMutable = false,
});
callFlake(state, lockedFlake, *vFlake);
auto vOutputs = vFlake->attrs->get(state.symbols.create("outputs"))->value;
state.forceValue(*vOutputs, noPos);
auto aHydraJobs = vOutputs->attrs->get(state.symbols.create("hydraJobs"));
if (!aHydraJobs)
aHydraJobs = vOutputs->attrs->get(state.symbols.create("checks"));
if (!aHydraJobs)
throw Error("flake '%s' does not provide any Hydra jobs or checks", flakeRef);
vTop = *aHydraJobs->value;
} else {
state.evalFile(lookupFileArg(state, myArgs.releaseExpr), vTop);
}
auto vRoot = state.allocValue();
state.autoCallFunction(autoArgs, vTop, *vRoot);
while (true) {
/* Wait for the master to send us a job name. */
writeLine(to.get(), "next");
auto s = readLine(from.get());
if (s == "exit") break;
if (!hasPrefix(s, "do ")) abort();
std::string attrPath(s, 3);
debug("worker process %d at '%s'", getpid(), attrPath);
/* Evaluate it and send info back to the master. */
nlohmann::json reply;
try {
auto vTmp = findAlongAttrPath(state, attrPath, autoArgs, *vRoot).first;
auto v = state.allocValue();
state.autoCallFunction(autoArgs, *vTmp, *v);
if (auto drv = getDerivation(state, *v, false)) {
DrvInfo::Outputs outputs = drv->queryOutputs();
if (drv->querySystem() == "unknown")
throw EvalError("derivation must have a 'system' attribute");
auto drvPath = state.store->printStorePath(drv->requireDrvPath());
nlohmann::json job;
job["nixName"] = drv->queryName();
job["system"] =drv->querySystem();
job["drvPath"] = drvPath;
job["description"] = drv->queryMetaString("description");
job["license"] = queryMetaStrings(state, *drv, "license", "shortName");
job["homepage"] = drv->queryMetaString("homepage");
job["maintainers"] = queryMetaStrings(state, *drv, "maintainers", "email");
job["schedulingPriority"] = drv->queryMetaInt("schedulingPriority", 100);
job["timeout"] = drv->queryMetaInt("timeout", 36000);
job["maxSilent"] = drv->queryMetaInt("maxSilent", 7200);
job["isChannel"] = drv->queryMetaBool("isHydraChannel", false);
/* If this is an aggregate, then get its constituents. */
auto a = v->attrs->get(state.symbols.create("_hydraAggregate"));
if (a && state.forceBool(*a->value, a->pos)) {
auto a = v->attrs->get(state.symbols.create("constituents"));
if (!a)
throw EvalError("derivation must have a constituents attribute");
PathSet context;
state.coerceToString(a->pos, *a->value, context, true, false);
for (auto & i : context)
if (i.at(0) == '!') {
size_t index = i.find("!", 1);
job["constituents"].push_back(std::string(i, index + 1));
}
state.forceList(*a->value, a->pos);
for (unsigned int n = 0; n < a->value->listSize(); ++n) {
auto v = a->value->listElems()[n];
state.forceValue(*v, noPos);
if (v->type() == nString)
job["namedConstituents"].push_back(state.forceStringNoCtx(*v));
}
}
/* Register the derivation as a GC root. !!! This
registers roots for jobs that we may have already
done. */
auto localStore = state.store.dynamic_pointer_cast<LocalFSStore>();
if (gcRootsDir != "" && localStore) {
Path root = gcRootsDir + "/" + std::string(baseNameOf(drvPath));
if (!pathExists(root))
localStore->addPermRoot(localStore->parseStorePath(drvPath), root);
}
nlohmann::json out;
for (auto & j : outputs)
// FIXME: handle CA/impure builds.
if (j.second)
out[j.first] = state.store->printStorePath(*j.second);
job["outputs"] = std::move(out);
reply["job"] = std::move(job);
}
else if (v->type() == nAttrs) {
auto attrs = nlohmann::json::array();
StringSet ss;
for (auto & i : v->attrs->lexicographicOrder(state.symbols)) {
std::string name(state.symbols[i->name]);
if (name.find('.') != std::string::npos || name.find(' ') != std::string::npos) {
printError("skipping job with illegal name '%s'", name);
continue;
}
attrs.push_back(name);
}
reply["attrs"] = std::move(attrs);
}
else if (v->type() == nNull)
;
else throw TypeError("attribute '%s' is %s, which is not supported", attrPath, showType(*v));
} catch (EvalError & e) {
auto msg = e.msg();
// Transmits the error we got from the previous evaluation
// in the JSON output.
reply["error"] = filterANSIEscapes(msg, true);
// Don't forget to print it into the STDERR log, this is
// what's shown in the Hydra UI.
printError(msg);
}
writeLine(to.get(), reply.dump());
/* If our RSS exceeds the maximum, exit. The master will
start a new process. */
struct rusage r;
getrusage(RUSAGE_SELF, &r);
if ((size_t) r.ru_maxrss > maxMemorySize * 1024) break;
}
writeLine(to.get(), "restart");
}
int main(int argc, char * * argv)
{
/* Prevent undeclared dependencies in the evaluation via
$NIX_PATH. */
unsetenv("NIX_PATH");
return handleExceptions(argv[0], [&]() {
auto config = std::make_unique<HydraConfig>();
auto nrWorkers = config->getIntOption("evaluator_workers", 1);
maxMemorySize = config->getIntOption("evaluator_max_memory_size", 4096);
initNix();
initGC();
myArgs.parseCmdline(argvToStrings(argc, argv));
auto pureEval = config->getBoolOption("evaluator_pure_eval", myArgs.flake);
/* FIXME: The build hook in conjunction with import-from-derivation is causing "unexpected EOF" during eval */
settings.builders = "";
/* Prevent access to paths outside of the Nix search path and
to the environment. */
evalSettings.restrictEval = true;
/* When building a flake, use pure evaluation (no access to
'getEnv', 'currentSystem' etc. */
evalSettings.pureEval = pureEval;
if (myArgs.dryRun) settings.readOnlyMode = true;
if (myArgs.releaseExpr == "") throw UsageError("no expression specified");
if (gcRootsDir == "") printMsg(lvlError, "warning: `--gc-roots-dir' not specified");
struct State
{
std::set<std::string> todo{""};
std::set<std::string> active;
nlohmann::json jobs;
std::exception_ptr exc;
};
std::condition_variable wakeup;
Sync<State> state_;
/* Start a handler thread per worker process. */
auto handler = [&]()
{
pid_t pid = -1;
try {
AutoCloseFD from, to;
while (true) {
/* Start a new worker process if necessary. */
if (pid == -1) {
Pipe toPipe, fromPipe;
toPipe.create();
fromPipe.create();
pid = startProcess(
[&,
to{std::make_shared<AutoCloseFD>(std::move(fromPipe.writeSide))},
from{std::make_shared<AutoCloseFD>(std::move(toPipe.readSide))}
]()
{
try {
EvalState state(myArgs.searchPath, openStore());
Bindings & autoArgs = *myArgs.getAutoArgs(state);
worker(state, autoArgs, *to, *from);
} catch (Error & e) {
nlohmann::json err;
auto msg = e.msg();
err["error"] = filterANSIEscapes(msg, true);
printError(msg);
writeLine(to->get(), err.dump());
// Don't forget to print it into the STDERR log, this is
// what's shown in the Hydra UI.
writeLine(to->get(), "restart");
}
},
ProcessOptions { .allowVfork = false });
from = std::move(fromPipe.readSide);
to = std::move(toPipe.writeSide);
debug("created worker process %d", pid);
}
/* Check whether the existing worker process is still there. */
auto s = readLine(from.get());
if (s == "restart") {
pid = -1;
continue;
} else if (s != "next") {
auto json = nlohmann::json::parse(s);
throw Error("worker error: %s", (std::string) json["error"]);
}
/* Wait for a job name to become available. */
std::string attrPath;
while (true) {
checkInterrupt();
auto state(state_.lock());
if ((state->todo.empty() && state->active.empty()) || state->exc) {
writeLine(to.get(), "exit");
return;
}
if (!state->todo.empty()) {
attrPath = *state->todo.begin();
state->todo.erase(state->todo.begin());
state->active.insert(attrPath);
break;
} else
state.wait(wakeup);
}
/* Tell the worker to evaluate it. */
writeLine(to.get(), "do " + attrPath);
/* Wait for the response. */
auto response = nlohmann::json::parse(readLine(from.get()));
/* Handle the response. */
StringSet newAttrs;
if (response.find("job") != response.end()) {
auto state(state_.lock());
state->jobs[attrPath] = response["job"];
}
if (response.find("attrs") != response.end()) {
for (auto & i : response["attrs"]) {
auto s = (attrPath.empty() ? "" : attrPath + ".") + (std::string) i;
newAttrs.insert(s);
}
}
if (response.find("error") != response.end()) {
auto state(state_.lock());
state->jobs[attrPath]["error"] = response["error"];
}
/* Add newly discovered job names to the queue. */
{
auto state(state_.lock());
state->active.erase(attrPath);
for (auto & s : newAttrs)
state->todo.insert(s);
wakeup.notify_all();
}
}
} catch (...) {
check_pid_status_nonblocking(pid);
auto state(state_.lock());
state->exc = std::current_exception();
wakeup.notify_all();
}
};
std::vector<std::thread> threads;
for (size_t i = 0; i < nrWorkers; i++)
threads.emplace_back(std::thread(handler));
for (auto & thread : threads)
thread.join();
auto state(state_.lock());
if (state->exc)
std::rethrow_exception(state->exc);
/* For aggregate jobs that have named consistuents
(i.e. constituents that are a job name rather than a
derivation), look up the referenced job and add it to the
dependencies of the aggregate derivation. */
auto store = openStore();
for (auto i = state->jobs.begin(); i != state->jobs.end(); ++i) {
auto jobName = i.key();
auto & job = i.value();
auto named = job.find("namedConstituents");
if (named == job.end()) continue;
std::unordered_map<std::string, std::string> brokenJobs;
auto getNonBrokenJobOrRecordError = [&brokenJobs, &jobName, &state](
const std::string & childJobName) -> std::optional<nlohmann::json> {
auto childJob = state->jobs.find(childJobName);
if (childJob == state->jobs.end()) {
printError("aggregate job '%s' references non-existent job '%s'", jobName, childJobName);
brokenJobs[childJobName] = "does not exist";
return std::nullopt;
}
if (childJob->find("error") != childJob->end()) {
std::string error = (*childJob)["error"];
printError("aggregate job '%s' references broken job '%s': %s", jobName, childJobName, error);
brokenJobs[childJobName] = error;
return std::nullopt;
}
return *childJob;
};
if (myArgs.dryRun) {
for (std::string jobName2 : *named) {
auto job2 = getNonBrokenJobOrRecordError(jobName2);
if (!job2) {
continue;
}
std::string drvPath2 = (*job2)["drvPath"];
job["constituents"].push_back(drvPath2);
}
} else {
auto drvPath = store->parseStorePath((std::string) job["drvPath"]);
auto drv = store->readDerivation(drvPath);
for (std::string jobName2 : *named) {
auto job2 = getNonBrokenJobOrRecordError(jobName2);
if (!job2) {
continue;
}
auto drvPath2 = store->parseStorePath((std::string) (*job2)["drvPath"]);
auto drv2 = store->readDerivation(drvPath2);
job["constituents"].push_back(store->printStorePath(drvPath2));
drv.inputDrvs[drvPath2] = {drv2.outputs.begin()->first};
}
if (brokenJobs.empty()) {
std::string drvName(drvPath.name());
assert(hasSuffix(drvName, drvExtension));
drvName.resize(drvName.size() - drvExtension.size());
auto hashModulo = hashDerivationModulo(*store, drv, true);
if (hashModulo.kind != DrvHash::Kind::Regular) continue;
auto h = hashModulo.hashes.find("out");
if (h == hashModulo.hashes.end()) continue;
auto outPath = store->makeOutputPath("out", h->second, drvName);
drv.env["out"] = store->printStorePath(outPath);
drv.outputs.insert_or_assign("out", DerivationOutput::InputAddressed { .path = outPath });
auto newDrvPath = store->printStorePath(writeDerivation(*store, drv));
debug("rewrote aggregate derivation %s -> %s", store->printStorePath(drvPath), newDrvPath);
job["drvPath"] = newDrvPath;
job["outputs"]["out"] = store->printStorePath(outPath);
}
}
job.erase("namedConstituents");
/* Register the derivation as a GC root. !!! This
registers roots for jobs that we may have already
done. */
auto localStore = store.dynamic_pointer_cast<LocalFSStore>();
if (gcRootsDir != "" && localStore) {
auto drvPath = job["drvPath"].get<std::string>();
Path root = gcRootsDir + "/" + std::string(baseNameOf(drvPath));
if (!pathExists(root))
localStore->addPermRoot(localStore->parseStorePath(drvPath), root);
}
if (!brokenJobs.empty()) {
std::stringstream ss;
for (const auto& [jobName, error] : brokenJobs) {
ss << jobName << ": " << error << "\n";
}
job["error"] = ss.str();
}
}
std::cout << state->jobs.dump(2) << "\n";
});
}

View File

@@ -1,5 +0,0 @@
bin_PROGRAMS = hydra-evaluator
hydra_evaluator_SOURCES = hydra-evaluator.cc
hydra_evaluator_LDADD = $(NIX_LIBS) -lpqxx
hydra_evaluator_CXXFLAGS = $(NIX_CFLAGS) -Wall -I ../libhydra -Wno-deprecated-declarations

View File

@@ -1,7 +1,8 @@
#include "db.hh"
#include "hydra-config.hh"
#include "pool.hh"
#include "shared.hh"
#include <nix/util/pool.hh>
#include <nix/main/shared.hh>
#include <nix/util/signals.hh>
#include <algorithm>
#include <thread>
@@ -37,7 +38,7 @@ class JobsetId {
friend bool operator!= (const JobsetId & lhs, const JobsetName & rhs);
std::string display() const {
return str(format("%1%:%2% (jobset#%3%)") % project % jobset % id);
return boost::str(boost::format("%1%:%2% (jobset#%3%)") % project % jobset % id);
}
};
bool operator==(const JobsetId & lhs, const JobsetId & rhs)
@@ -179,10 +180,8 @@ struct Evaluator
{
auto conn(dbPool.get());
pqxx::work txn(*conn);
txn.exec_params0
("update Jobsets set startTime = $1 where id = $2",
now,
jobset.name.id);
txn.exec("update Jobsets set startTime = $1 where id = $2",
pqxx::params{now, jobset.name.id}).no_rows();
txn.commit();
}
@@ -233,7 +232,7 @@ struct Evaluator
pqxx::work txn(*conn);
if (jobset.evaluation_style == EvaluationStyle::ONE_AT_A_TIME) {
auto evaluation_res = txn.exec_params
auto evaluation_res = txn.exec
("select id from JobsetEvals "
"where jobset_id = $1 "
"order by id desc limit 1"
@@ -249,7 +248,7 @@ struct Evaluator
auto evaluation_id = evaluation_res[0][0].as<int>();
auto unfinished_build_res = txn.exec_params
auto unfinished_build_res = txn.exec
("select id from Builds "
"join JobsetEvalMembers "
" on (JobsetEvalMembers.build = Builds.id) "
@@ -366,6 +365,9 @@ struct Evaluator
printInfo("received jobset event");
}
} catch (pqxx::broken_connection & e) {
printError("Database connection broken: %s", e.what());
std::_Exit(1);
} catch (std::exception & e) {
printError("exception in database monitor thread: %s", e.what());
sleep(30);
@@ -416,21 +418,18 @@ struct Evaluator
/* Clear the trigger time to prevent this
jobset from getting stuck in an endless
failing eval loop. */
txn.exec_params0
txn.exec
("update Jobsets set triggerTime = null where id = $1 and startTime is not null and triggerTime <= startTime",
jobset.name.id);
jobset.name.id).no_rows();
/* Clear the start time. */
txn.exec_params0
txn.exec
("update Jobsets set startTime = null where id = $1",
jobset.name.id);
jobset.name.id).no_rows();
if (!WIFEXITED(status) || WEXITSTATUS(status) > 1) {
txn.exec_params0
("update Jobsets set errorMsg = $1, lastCheckedTime = $2, errorTime = $2, fetchErrorMsg = null where id = $3",
fmt("evaluation %s", statusToString(status)),
now,
jobset.name.id);
txn.exec("update Jobsets set errorMsg = $1, lastCheckedTime = $2, errorTime = $2, fetchErrorMsg = null where id = $3",
pqxx::params{fmt("evaluation %s", statusToString(status)), now, jobset.name.id}).no_rows();
}
txn.commit();
@@ -455,7 +454,7 @@ struct Evaluator
{
auto conn(dbPool.get());
pqxx::work txn(*conn);
txn.exec("update Jobsets set startTime = null");
txn.exec("update Jobsets set startTime = null").no_rows();
txn.commit();
}
@@ -473,6 +472,9 @@ struct Evaluator
while (true) {
try {
loop();
} catch (pqxx::broken_connection & e) {
printError("Database connection broken: %s", e.what());
std::_Exit(1);
} catch (std::exception & e) {
printError("exception in main loop: %s", e.what());
sleep(30);

View File

@@ -0,0 +1,10 @@
hydra_evaluator = executable('hydra-evaluator',
'hydra-evaluator.cc',
dependencies: [
libhydra_dep,
nix_util_dep,
nix_main_dep,
pqxx_dep,
],
install: true,
)

View File

@@ -1,8 +0,0 @@
bin_PROGRAMS = hydra-queue-runner
hydra_queue_runner_SOURCES = hydra-queue-runner.cc queue-monitor.cc dispatcher.cc \
builder.cc build-result.cc build-remote.cc \
hydra-build-result.hh counter.hh state.hh db.hh \
nar-extractor.cc nar-extractor.hh
hydra_queue_runner_LDADD = $(NIX_LIBS) -lpqxx -lprometheus-cpp-pull -lprometheus-cpp-core
hydra_queue_runner_CXXFLAGS = $(NIX_CFLAGS) -Wall -I ../libhydra -Wno-deprecated-declarations

View File

@@ -5,107 +5,78 @@
#include <sys/stat.h>
#include <fcntl.h>
#include "build-result.hh"
#include "serve-protocol.hh"
#include <nix/store/build-result.hh>
#include <nix/store/path.hh>
#include <nix/store/legacy-ssh-store.hh>
#include <nix/store/serve-protocol.hh>
#include <nix/store/serve-protocol-impl.hh>
#include "state.hh"
#include "util.hh"
#include "worker-protocol.hh"
#include "finally.hh"
#include "url.hh"
#include <nix/util/current-process.hh>
#include <nix/util/processes.hh>
#include <nix/util/util.hh>
#include <nix/store/export-import.hh>
#include <nix/store/serve-protocol.hh>
#include <nix/store/serve-protocol-impl.hh>
#include <nix/store/ssh.hh>
#include <nix/util/finally.hh>
#include <nix/util/url.hh>
using namespace nix;
struct Child
bool ::Machine::isLocalhost() const
{
Pid pid;
AutoCloseFD to, from;
};
static void append(Strings & dst, const Strings & src)
{
dst.insert(dst.end(), src.begin(), src.end());
return storeUri.params.empty() && std::visit(overloaded {
[](const StoreReference::Auto &) {
return true;
},
[](const StoreReference::Specified & s) {
return
(s.scheme == "local" || s.scheme == "unix") ||
((s.scheme == "ssh" || s.scheme == "ssh-ng") &&
s.authority == "localhost");
},
}, storeUri.variant);
}
static Strings extraStoreArgs(std::string & machine)
namespace nix::build_remote {
static std::unique_ptr<SSHMaster::Connection> openConnection(
::Machine::ptr machine, SSHMaster & master)
{
Strings result;
try {
auto parsed = parseURL(machine);
if (parsed.scheme != "ssh") {
throw SysError("Currently, only (legacy-)ssh stores are supported!");
}
machine = parsed.authority.value_or("");
auto remoteStore = parsed.query.find("remote-store");
if (remoteStore != parsed.query.end()) {
result = {"--store", shellEscape(remoteStore->second)};
}
} catch (BadURL &) {
// We just try to continue with `machine->sshName` here for backwards compat.
}
return result;
}
static void openConnection(Machine::ptr machine, Path tmpDir, int stderrFD, Child & child)
{
std::string pgmName;
Pipe to, from;
to.create();
from.create();
Strings argv;
Strings command = {"nix-store", "--serve", "--write"};
if (machine->isLocalhost()) {
pgmName = "nix-store";
argv = {"nix-store", "--builders", "", "--serve", "--write"};
command.push_back("--builders");
command.push_back("");
} else {
pgmName = "ssh";
auto sshName = machine->sshName;
Strings extraArgs = extraStoreArgs(sshName);
argv = {"ssh", sshName};
if (machine->sshKey != "") append(argv, {"-i", machine->sshKey});
if (machine->sshPublicHostKey != "") {
Path fileName = tmpDir + "/host-key";
auto p = machine->sshName.find("@");
std::string host = p != std::string::npos ? std::string(machine->sshName, p + 1) : machine->sshName;
writeFile(fileName, host + " " + machine->sshPublicHostKey + "\n");
append(argv, {"-oUserKnownHostsFile=" + fileName});
auto remoteStore = machine->storeUri.params.find("remote-store");
if (remoteStore != machine->storeUri.params.end()) {
command.push_back("--store");
command.push_back(escapeShellArgAlways(remoteStore->second));
}
append(argv,
{ "-x", "-a", "-oBatchMode=yes", "-oConnectTimeout=60", "-oTCPKeepAlive=yes"
, "--", "nix-store", "--serve", "--write" });
append(argv, extraArgs);
}
child.pid = startProcess([&]() {
restoreProcessContext();
if (dup2(to.readSide.get(), STDIN_FILENO) == -1)
throw SysError("cannot dup input pipe to stdin");
if (dup2(from.writeSide.get(), STDOUT_FILENO) == -1)
throw SysError("cannot dup output pipe to stdout");
if (dup2(stderrFD, STDERR_FILENO) == -1)
throw SysError("cannot dup stderr");
execvp(argv.front().c_str(), (char * *) stringsToCharPtrs(argv).data()); // FIXME: remove cast
throw SysError("cannot start %s", pgmName);
auto ret = master.startCommand(std::move(command), {
"-a", "-oBatchMode=yes", "-oConnectTimeout=60", "-oTCPKeepAlive=yes"
});
to.readSide = -1;
from.writeSide = -1;
// XXX: determine the actual max value we can use from /proc.
child.to = to.writeSide.release();
child.from = from.readSide.release();
// 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(std::timed_mutex & sendMutex, Store & destStore,
FdSource & from, FdSink & to, const StorePathSet & paths,
bool useSubstitutes = false)
static void copyClosureTo(
::Machine::Connection & conn,
Store & destStore,
const StorePathSet & paths,
SubstituteFlag useSubstitutes = NoSubstitute)
{
StorePathSet closure;
destStore.computeFSClosure(paths, closure);
@@ -115,13 +86,10 @@ static void copyClosureTo(std::timed_mutex & sendMutex, Store & destStore,
garbage-collect paths that are already there. Optionally, ask
the remote host to substitute missing paths. */
// FIXME: substitute output pollutes our build log
to << cmdQueryValidPaths << 1 << useSubstitutes;
worker_proto::write(destStore, to, closure);
to.flush();
/* Get back the set of paths that are already valid on the remote
host. */
auto present = worker_proto::read(destStore, from, Phantom<StorePathSet> {});
auto present = conn.queryValidPaths(
destStore, true, closure, useSubstitutes);
if (present.size() == closure.size()) return;
@@ -133,20 +101,20 @@ static void copyClosureTo(std::timed_mutex & sendMutex, Store & destStore,
printMsg(lvlDebug, "sending %d missing paths", missing.size());
std::unique_lock<std::timed_mutex> sendLock(sendMutex,
std::unique_lock<std::timed_mutex> sendLock(conn.machine->state->sendLock,
std::chrono::seconds(600));
to << cmdImportPaths;
destStore.exportPaths(missing, to);
to.flush();
conn.to << ServeProto::Command::ImportPaths;
exportPaths(destStore, missing, conn.to);
conn.to.flush();
if (readInt(from) != 1)
if (readInt(conn.from) != 1)
throw Error("remote machine failed to import closure");
}
// FIXME: use Store::topoSortPaths().
StorePaths reverseTopoSortPaths(const std::map<StorePath, ValidPathInfo> & paths)
static StorePaths reverseTopoSortPaths(const std::map<StorePath, UnkeyedValidPathInfo> & paths)
{
StorePaths sorted;
StorePathSet visited;
@@ -174,40 +142,320 @@ StorePaths reverseTopoSortPaths(const std::map<StorePath, ValidPathInfo> & paths
return sorted;
}
static std::pair<Path, AutoCloseFD> openLogFile(const std::string & logDir, const StorePath & drvPath)
{
std::string base(drvPath.to_string());
auto logFile = logDir + "/" + std::string(base, 0, 2) + "/" + std::string(base, 2);
createDirs(dirOf(logFile));
AutoCloseFD logFD = open(logFile.c_str(), O_CREAT | O_TRUNC | O_WRONLY, 0666);
if (!logFD) throw SysError("creating log file %s", logFile);
return {std::move(logFile), std::move(logFD)};
}
static BasicDerivation sendInputs(
State & state,
Step & step,
Store & localStore,
Store & destStore,
::Machine::Connection & conn,
unsigned int & overhead,
counter & nrStepsWaiting,
counter & nrStepsCopyingTo
)
{
/* Replace the input derivations by their output paths to send a
minimal closure to the builder.
`tryResolve` currently does *not* rewrite input addresses, so it
is safe to do this in all cases. (It should probably have a mode
to do that, however, but we would not use it here.)
*/
BasicDerivation basicDrv = ({
auto maybeBasicDrv = step.drv->tryResolve(destStore, &localStore);
if (!maybeBasicDrv)
throw Error(
"the derivation '%s' cant be resolved. Its probably "
"missing some outputs",
localStore.printStorePath(step.drvPath));
*maybeBasicDrv;
});
/* Ensure that the inputs exist in the destination store. This is
a no-op for regular stores, but for the binary cache store,
this will copy the inputs to the binary cache from the local
store. */
if (&localStore != &destStore) {
copyClosure(localStore, destStore,
step.drv->inputSrcs,
NoRepair, NoCheckSigs, NoSubstitute);
}
{
auto mc1 = std::make_shared<MaintainCount<counter>>(nrStepsWaiting);
mc1.reset();
MaintainCount<counter> mc2(nrStepsCopyingTo);
printMsg(lvlDebug, "sending closure of %s to %s",
localStore.printStorePath(step.drvPath), conn.machine->storeUri.render());
auto now1 = std::chrono::steady_clock::now();
/* Copy the input closure. */
if (conn.machine->isLocalhost()) {
StorePathSet closure;
destStore.computeFSClosure(basicDrv.inputSrcs, closure);
copyPaths(destStore, localStore, closure, NoRepair, NoCheckSigs, NoSubstitute);
} else {
copyClosureTo(conn, destStore, basicDrv.inputSrcs, Substitute);
}
auto now2 = std::chrono::steady_clock::now();
overhead += std::chrono::duration_cast<std::chrono::milliseconds>(now2 - now1).count();
}
return basicDrv;
}
static BuildResult performBuild(
::Machine::Connection & conn,
Store & localStore,
StorePath drvPath,
const BasicDerivation & drv,
const ServeProto::BuildOptions & options,
counter & nrStepsBuilding
)
{
conn.putBuildDerivationRequest(localStore, drvPath, drv, options);
BuildResult result;
time_t startTime, stopTime;
startTime = time(0);
{
MaintainCount<counter> mc(nrStepsBuilding);
result = ServeProto::Serialise<BuildResult>::read(localStore, conn);
}
stopTime = time(0);
if (!result.startTime) {
// If the builder gave `startTime = 0`, use our measurements
// instead of the builder's.
//
// Note: this represents the duration of a single round, rather
// than all rounds.
result.startTime = startTime;
result.stopTime = stopTime;
}
// If the protocol was too old to give us `builtOutputs`, initialize
// it manually by introspecting the derivation.
if (GET_PROTOCOL_MINOR(conn.remoteVersion) < 6)
{
// If the remote is too old to handle CA derivations, we cant get this
// far anyways
assert(drv.type().hasKnownOutputPaths());
DerivationOutputsAndOptPaths drvOutputs = drv.outputsAndOptPaths(localStore);
// Since this a `BasicDerivation`, `staticOutputHashes` will not
// do any real work.
auto outputHashes = staticOutputHashes(localStore, drv);
if (auto * successP = result.tryGetSuccess()) {
for (auto & [outputName, output] : drvOutputs) {
auto outputPath = output.second;
// Weve just asserted that the output paths of the derivation
// were known
assert(outputPath);
auto outputHash = outputHashes.at(outputName);
auto drvOutput = DrvOutput { outputHash, outputName };
successP->builtOutputs.insert_or_assign(
std::move(outputName),
Realisation { drvOutput, *outputPath });
}
}
}
return result;
}
static void copyPathFromRemote(
::Machine::Connection & conn,
NarMemberDatas & narMembers,
Store & localStore,
Store & destStore,
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.to << ServeProto::Command::DumpStorePath << localStore.printStorePath(info.path);
conn.to.flush();
TeeSource tee(conn.from, sink);
extractNarData(tee, localStore.printStorePath(info.path), narMembers);
});
destStore.addToStore(info, *source2, NoRepair, NoCheckSigs);
}
static void copyPathsFromRemote(
::Machine::Connection & conn,
NarMemberDatas & narMembers,
Store & localStore,
Store & destStore,
const std::map<StorePath, UnkeyedValidPathInfo> & infos
)
{
auto pathsSorted = reverseTopoSortPaths(infos);
for (auto & path : pathsSorted) {
auto & info = infos.find(path)->second;
copyPathFromRemote(
conn, narMembers, localStore, destStore,
ValidPathInfo { path, info });
}
}
}
/* using namespace nix::build_remote; */
void RemoteResult::updateWithBuildResult(const nix::BuildResult & buildResult)
{
startTime = buildResult.startTime;
stopTime = buildResult.stopTime;
timesBuilt = buildResult.timesBuilt;
std::visit(overloaded{
[&](const BuildResult::Success & success) {
stepStatus = bsSuccess;
switch (success.status) {
case BuildResult::Success::Built:
break;
case BuildResult::Success::Substituted:
case BuildResult::Success::AlreadyValid:
case BuildResult::Success::ResolvesToAlreadyValid:
isCached = true;
break;
default:
assert(false);
}
},
[&](const BuildResult::Failure & failure) {
errorMsg = failure.errorMsg;
isNonDeterministic = failure.isNonDeterministic;
switch (failure.status) {
case BuildResult::Failure::PermanentFailure:
stepStatus = bsFailed;
canCache = true;
errorMsg = "";
break;
case BuildResult::Failure::InputRejected:
case BuildResult::Failure::OutputRejected:
stepStatus = bsFailed;
canCache = true;
break;
case BuildResult::Failure::TransientFailure:
stepStatus = bsFailed;
canRetry = true;
errorMsg = "";
break;
case BuildResult::Failure::TimedOut:
stepStatus = bsTimedOut;
errorMsg = "";
break;
case BuildResult::Failure::MiscFailure:
stepStatus = bsAborted;
canRetry = true;
break;
case BuildResult::Failure::LogLimitExceeded:
stepStatus = bsLogLimitExceeded;
break;
case BuildResult::Failure::NotDeterministic:
stepStatus = bsNotDeterministic;
canRetry = false;
canCache = true;
break;
case BuildResult::Failure::CachedFailure:
case BuildResult::Failure::DependencyFailed:
case BuildResult::Failure::NoSubstituters:
case BuildResult::Failure::HashMismatch:
stepStatus = bsAborted;
break;
default:
assert(false);
}
},
}, buildResult.inner);
}
/* Utility guard object to auto-release a semaphore on destruction. */
template <typename T>
class SemaphoreReleaser {
public:
SemaphoreReleaser(T* s) : sem(s) {}
~SemaphoreReleaser() { sem->release(); }
private:
T* sem;
};
void State::buildRemote(ref<Store> destStore,
Machine::ptr machine, Step::ptr step,
unsigned int maxSilentTime, unsigned int buildTimeout, unsigned int repeats,
std::unique_ptr<MachineReservation> reservation,
::Machine::ptr machine, Step::ptr step,
const ServeProto::BuildOptions & buildOptions,
RemoteResult & result, std::shared_ptr<ActiveStep> activeStep,
std::function<void(StepState)> updateStep,
NarMemberDatas & narMembers)
{
assert(BuildResult::TimedOut == 8);
assert(BuildResult::Failure::TimedOut == 8);
std::string base(step->drvPath.to_string());
result.logFile = logDir + "/" + std::string(base, 0, 2) + "/" + std::string(base, 2);
AutoDelete autoDelete(result.logFile, false);
createDirs(dirOf(result.logFile));
AutoCloseFD logFD = open(result.logFile.c_str(), O_CREAT | O_TRUNC | O_WRONLY, 0666);
if (!logFD) throw SysError("creating log file %s", result.logFile);
nix::Path tmpDir = createTempDir();
AutoDelete tmpDirDel(tmpDir, true);
auto [logFile, logFD] = build_remote::openLogFile(logDir, step->drvPath);
AutoDelete logFileDel(logFile, false);
result.logFile = logFile;
try {
updateStep(ssConnecting);
auto storeRef = machine->completeStoreReference();
auto * pSpecified = std::get_if<StoreReference::Specified>(&storeRef.variant);
if (!pSpecified || pSpecified->scheme != "ssh") {
throw Error("Currently, only (legacy-)ssh stores are supported!");
}
LegacySSHStoreConfig storeConfig {
pSpecified->scheme,
pSpecified->authority,
storeRef.params
};
auto master = storeConfig.createSSHMaster(
false, // no SSH master yet
logFD.get());
// FIXME: rewrite to use Store.
Child child;
openConnection(machine, tmpDir, logFD.get(), child);
auto child = build_remote::openConnection(machine, master);
{
auto activeStepState(activeStep->state_.lock());
if (activeStepState->cancelled) throw Error("step cancelled");
activeStepState->pid = child.pid;
activeStepState->pid = child->sshPid;
}
Finally clearPid([&]() {
@@ -222,34 +470,33 @@ void State::buildRemote(ref<Store> destStore,
process. Meh. */
});
FdSource from(child.from.get());
FdSink to(child.to.get());
::Machine::Connection conn {
{
.to = child->in.get(),
.from = child->out.get(),
/* Handshake. */
.remoteVersion = 0xdadbeef, // FIXME avoid dummy initialize
},
/*.machine =*/ machine,
};
Finally updateStats([&]() {
bytesReceived += from.read;
bytesSent += to.written;
bytesReceived += conn.from.read;
bytesSent += conn.to.written;
});
/* Handshake. */
unsigned int remoteVersion;
constexpr ServeProto::Version our_version = 0x206;
try {
to << SERVE_MAGIC_1 << 0x206;
to.flush();
unsigned int magic = readInt(from);
if (magic != SERVE_MAGIC_2)
throw Error("protocol mismatch with nix-store --serve on %1%", machine->sshName);
remoteVersion = readInt(from);
if (GET_PROTOCOL_MAJOR(remoteVersion) != 0x200)
throw Error("unsupported nix-store --serve protocol version on %1%", machine->sshName);
if (GET_PROTOCOL_MINOR(remoteVersion) < 3 && repeats > 0)
throw Error("machine %1% does not support repeating a build; please upgrade it to Nix 1.12", machine->sshName);
conn.remoteVersion = decltype(conn)::handshake(
conn.to,
conn.from,
our_version,
machine->storeUri.render());
} catch (EndOfFile & e) {
child.pid.wait();
child->sshPid.wait();
std::string s = chomp(readFile(result.logFile));
throw Error("cannot connect to %1%: %2%", machine->sshName, s);
throw Error("cannot connect to %1%: %2%", machine->storeUri.render(), s);
}
{
@@ -263,62 +510,12 @@ void State::buildRemote(ref<Store> destStore,
copy the immediate sources of the derivation and the required
outputs of the input derivations. */
updateStep(ssSendingInputs);
BasicDerivation resolvedDrv = build_remote::sendInputs(*this, *step, *localStore, *destStore, conn, result.overhead, nrStepsWaiting, nrStepsCopyingTo);
StorePathSet inputs;
BasicDerivation basicDrv(*step->drv);
for (auto & p : step->drv->inputSrcs)
inputs.insert(p);
for (auto & input : step->drv->inputDrvs) {
auto drv2 = localStore->readDerivation(input.first);
for (auto & name : input.second) {
if (auto i = get(drv2.outputs, name)) {
auto outPath = i->path(*localStore, drv2.name, name);
inputs.insert(*outPath);
basicDrv.inputSrcs.insert(*outPath);
}
}
}
/* Ensure that the inputs exist in the destination store. This is
a no-op for regular stores, but for the binary cache store,
this will copy the inputs to the binary cache from the local
store. */
if (localStore != std::shared_ptr<Store>(destStore)) {
copyClosure(*localStore, *destStore,
step->drv->inputSrcs,
NoRepair, NoCheckSigs, NoSubstitute);
}
{
auto mc1 = std::make_shared<MaintainCount<counter>>(nrStepsWaiting);
mc1.reset();
MaintainCount<counter> mc2(nrStepsCopyingTo);
printMsg(lvlDebug, "sending closure of %s to %s",
localStore->printStorePath(step->drvPath), machine->sshName);
auto now1 = std::chrono::steady_clock::now();
/* Copy the input closure. */
if (machine->isLocalhost()) {
StorePathSet closure;
destStore->computeFSClosure(inputs, closure);
copyPaths(*destStore, *localStore, closure, NoRepair, NoCheckSigs, NoSubstitute);
} else {
copyClosureTo(machine->state->sendLock, *destStore, from, to, inputs, true);
}
auto now2 = std::chrono::steady_clock::now();
result.overhead += std::chrono::duration_cast<std::chrono::milliseconds>(now2 - now1).count();
}
autoDelete.cancel();
logFileDel.cancel();
/* Truncate the log to get rid of messages about substitutions
etc. on the remote system. */
etc. on the remote system. */
if (lseek(logFD.get(), SEEK_SET, 0) != 0)
throw SysError("seeking to the start of log file %s", result.logFile);
@@ -330,89 +527,21 @@ void State::buildRemote(ref<Store> destStore,
/* Do the build. */
printMsg(lvlDebug, "building %s on %s",
localStore->printStorePath(step->drvPath),
machine->sshName);
machine->storeUri.render());
updateStep(ssBuilding);
to << cmdBuildDerivation << localStore->printStorePath(step->drvPath);
writeDerivation(to, *localStore, basicDrv);
to << maxSilentTime << buildTimeout;
if (GET_PROTOCOL_MINOR(remoteVersion) >= 2)
to << maxLogSize;
if (GET_PROTOCOL_MINOR(remoteVersion) >= 3) {
to << repeats // == build-repeat
<< step->isDeterministic; // == enforce-determinism
}
to.flush();
auto buildResult = build_remote::performBuild(
conn,
*localStore,
step->drvPath,
resolvedDrv,
buildOptions,
nrStepsBuilding
);
result.startTime = time(0);
int res;
{
MaintainCount<counter> mc(nrStepsBuilding);
res = readInt(from);
}
result.stopTime = time(0);
result.updateWithBuildResult(buildResult);
result.errorMsg = readString(from);
if (GET_PROTOCOL_MINOR(remoteVersion) >= 3) {
result.timesBuilt = readInt(from);
result.isNonDeterministic = readInt(from);
auto start = readInt(from);
auto stop = readInt(from);
if (start && start) {
/* Note: this represents the duration of a single
round, rather than all rounds. */
result.startTime = start;
result.stopTime = stop;
}
}
if (GET_PROTOCOL_MINOR(remoteVersion) >= 6) {
worker_proto::read(*localStore, from, Phantom<DrvOutputs> {});
}
switch ((BuildResult::Status) res) {
case BuildResult::Built:
result.stepStatus = bsSuccess;
break;
case BuildResult::Substituted:
case BuildResult::AlreadyValid:
result.stepStatus = bsSuccess;
result.isCached = true;
break;
case BuildResult::PermanentFailure:
result.stepStatus = bsFailed;
result.canCache = true;
result.errorMsg = "";
break;
case BuildResult::InputRejected:
case BuildResult::OutputRejected:
result.stepStatus = bsFailed;
result.canCache = true;
break;
case BuildResult::TransientFailure:
result.stepStatus = bsFailed;
result.canRetry = true;
result.errorMsg = "";
break;
case BuildResult::TimedOut:
result.stepStatus = bsTimedOut;
result.errorMsg = "";
break;
case BuildResult::MiscFailure:
result.stepStatus = bsAborted;
result.canRetry = true;
break;
case BuildResult::LogLimitExceeded:
result.stepStatus = bsLogLimitExceeded;
break;
case BuildResult::NotDeterministic:
result.stepStatus = bsNotDeterministic;
result.canRetry = false;
result.canCache = true;
break;
default:
result.stepStatus = bsAborted;
break;
}
if (result.stepStatus != bsSuccess) return;
result.errorMsg = "";
@@ -421,11 +550,33 @@ void State::buildRemote(ref<Store> destStore,
get a build log. */
if (result.isCached) {
printMsg(lvlInfo, "outputs of %s substituted or already valid on %s",
localStore->printStorePath(step->drvPath), machine->sshName);
localStore->printStorePath(step->drvPath), machine->storeUri.render());
unlink(result.logFile.c_str());
result.logFile = "";
}
/* Throttle CPU-bound work. Opportunistically skip updating the current
* step, since this requires a DB roundtrip. */
if (!localWorkThrottler.try_acquire()) {
MaintainCount<counter> mc(nrStepsWaitingForDownloadSlot);
updateStep(ssWaitingForLocalSlot);
localWorkThrottler.acquire();
}
SemaphoreReleaser releaser(&localWorkThrottler);
/* Once we've started copying outputs, release the machine reservation
* so further builds can happen. We do not release the machine earlier
* to avoid situations where the queue runner is bottlenecked on
* copying outputs and we end up building too many things that we
* haven't been able to allow copy slots for. */
reservation.reset();
wakeDispatcher();
StorePathSet outputs;
if (auto * successP = buildResult.tryGetSuccess())
for (auto & [_, realisation] : successP->builtOutputs)
outputs.insert(realisation.outPath);
/* Copy the output paths. */
if (!machine->isLocalhost() || localStore != std::shared_ptr<Store>(destStore)) {
updateStep(ssReceivingOutputs);
@@ -434,39 +585,10 @@ void State::buildRemote(ref<Store> destStore,
auto now1 = std::chrono::steady_clock::now();
StorePathSet outputs;
for (auto & i : step->drv->outputsAndOptPaths(*localStore)) {
if (i.second.second)
outputs.insert(*i.second.second);
}
auto infos = conn.queryPathInfos(*localStore, outputs);
/* Get info about each output path. */
std::map<StorePath, ValidPathInfo> infos;
size_t totalNarSize = 0;
to << cmdQueryPathInfos;
worker_proto::write(*localStore, to, outputs);
to.flush();
while (true) {
auto storePathS = readString(from);
if (storePathS == "") break;
auto deriver = readString(from); // deriver
auto references = worker_proto::read(*localStore, from, Phantom<StorePathSet> {});
readLongLong(from); // download size
auto narSize = readLongLong(from);
auto narHash = Hash::parseAny(readString(from), htSHA256);
auto ca = parseContentAddressOpt(readString(from));
readStrings<StringSet>(from); // sigs
ValidPathInfo info(localStore->parseStorePath(storePathS), narHash);
assert(outputs.count(info.path));
info.references = references;
info.narSize = narSize;
totalNarSize += info.narSize;
info.narHash = narHash;
info.ca = ca;
if (deriver != "")
info.deriver = localStore->parseStorePath(deriver);
infos.insert_or_assign(info.path, info);
}
for (auto & [_, info] : infos) totalNarSize += info.narSize;
if (totalNarSize > maxOutputSize) {
result.stepStatus = bsNarSizeLimitExceeded;
@@ -475,43 +597,34 @@ void State::buildRemote(ref<Store> destStore,
/* Copy each path. */
printMsg(lvlDebug, "copying outputs of %s from %s (%d bytes)",
localStore->printStorePath(step->drvPath), machine->sshName, totalNarSize);
auto pathsSorted = reverseTopoSortPaths(infos);
for (auto & path : pathsSorted) {
auto & info = infos.find(path)->second;
/* 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. */
to << cmdDumpStorePath << localStore->printStorePath(path);
to.flush();
TeeSource tee(from, sink);
extractNarData(tee, localStore->printStorePath(path), narMembers);
});
destStore->addToStore(info, *source2, NoRepair, NoCheckSigs);
}
localStore->printStorePath(step->drvPath), machine->storeUri.render(), totalNarSize);
build_remote::copyPathsFromRemote(conn, narMembers, *localStore, *destStore, infos);
auto now2 = std::chrono::steady_clock::now();
result.overhead += std::chrono::duration_cast<std::chrono::milliseconds>(now2 - now1).count();
}
/* Register the outputs of the newly built drv */
if (experimentalFeatureSettings.isEnabled(Xp::CaDerivations)) {
auto outputHashes = staticOutputHashes(*localStore, *step->drv);
if (auto * successP = buildResult.tryGetSuccess()) {
for (auto & [outputName, realisation] : successP->builtOutputs) {
// Register the resolved drv output
destStore->registerDrvOutput(realisation);
// Also register the unresolved one
auto unresolvedRealisation = realisation;
unresolvedRealisation.signatures.clear();
unresolvedRealisation.id.drvHash = outputHashes.at(outputName);
destStore->registerDrvOutput(unresolvedRealisation);
}
}
}
/* Shut down the connection. */
child.to = -1;
child.pid.wait();
child->in = -1;
child->sshPid.wait();
} catch (Error & e) {
/* Disable this machine until a certain period of time has
@@ -525,7 +638,7 @@ void State::buildRemote(ref<Store> destStore,
info->consecutiveFailures = std::min(info->consecutiveFailures + 1, (unsigned int) 4);
info->lastFailure = now;
int delta = retryInterval * std::pow(retryBackoff, info->consecutiveFailures - 1) + (rand() % 30);
printMsg(lvlInfo, "will disable machine %1% for %2%s", machine->sshName, delta);
printMsg(lvlInfo, "will disable machine %1% for %2%s", machine->storeUri.render(), delta);
info->disabledUntil = now + std::chrono::seconds(delta);
}
throw;

View File

@@ -1,7 +1,7 @@
#include "hydra-build-result.hh"
#include "store-api.hh"
#include "util.hh"
#include "fs-accessor.hh"
#include <nix/store/store-api.hh>
#include <nix/util/util.hh>
#include <nix/util/source-accessor.hh>
#include <regex>
@@ -11,18 +11,18 @@ using namespace nix;
BuildOutput getBuildOutput(
nix::ref<Store> store,
NarMemberDatas & narMembers,
const Derivation & drv)
const OutputPathMap derivationOutputs)
{
BuildOutput res;
/* Compute the closure size. */
StorePathSet outputs;
StorePathSet closure;
for (auto & i : drv.outputsAndOptPaths(*store))
if (i.second.second) {
store->computeFSClosure(*i.second.second, closure);
outputs.insert(*i.second.second);
}
for (auto& [outputName, outputPath] : derivationOutputs) {
store->computeFSClosure(outputPath, closure);
outputs.insert(outputPath);
res.outputs.insert({outputName, outputPath});
}
for (auto & path : closure) {
auto info = store->queryPathInfo(path);
res.closureSize += info->narSize;
@@ -51,8 +51,8 @@ BuildOutput getBuildOutput(
"[[:space:]]+"
"([a-zA-Z0-9_-]+)" // subtype (e.g. "readme")
"[[:space:]]+"
"(\"[^\"]+\"|[^[:space:]\"]+)" // path (may be quoted)
"([[:space:]]+([^[:space:]]+))?" // entry point
"(\"[^\"]+\"|[^[:space:]<>\"]+)" // path (may be quoted)
"([[:space:]]+([^[:space:]<>]+))?" // entry point
, std::regex::extended);
for (auto & output : outputs) {
@@ -63,7 +63,7 @@ BuildOutput getBuildOutput(
auto productsFile = narMembers.find(outputS + "/nix-support/hydra-build-products");
if (productsFile == narMembers.end() ||
productsFile->second.type != FSAccessor::Type::tRegular)
productsFile->second.type != SourceAccessor::Type::tRegular)
continue;
assert(productsFile->second.contents);
@@ -78,7 +78,7 @@ BuildOutput getBuildOutput(
product.type = match[1];
product.subtype = match[2];
std::string s(match[3]);
product.path = s[0] == '"' ? std::string(s, 1, s.size() - 2) : s;
product.path = s[0] == '"' && s.back() == '"' ? std::string(s, 1, s.size() - 2) : s;
product.defaultPath = match[5];
/* Ensure that the path exists and points into the Nix
@@ -93,8 +93,10 @@ BuildOutput getBuildOutput(
if (file == narMembers.end()) continue;
product.name = product.path == store->printStorePath(output) ? "" : baseNameOf(product.path);
if (!std::regex_match(product.name, std::regex("[a-zA-Z0-9.@:_ -]*")))
product.name = "";
if (file->second.type == FSAccessor::Type::tRegular) {
if (file->second.type == SourceAccessor::Type::tRegular) {
product.isRegular = true;
product.fileSize = file->second.fileSize.value();
product.sha256hash = file->second.sha256.value();
@@ -107,17 +109,16 @@ BuildOutput getBuildOutput(
/* If no build products were explicitly declared, then add all
outputs as a product of type "nix-build". */
if (!explicitProducts) {
for (auto & [name, output] : drv.outputs) {
for (auto & [name, output] : derivationOutputs) {
BuildProduct product;
auto outPath = output.path(*store, drv.name, name);
product.path = store->printStorePath(*outPath);
product.path = store->printStorePath(output);
product.type = "nix-build";
product.subtype = name == "out" ? "" : name;
product.name = outPath->name();
product.name = output.name();
auto file = narMembers.find(product.path);
assert(file != narMembers.end());
if (file->second.type == FSAccessor::Type::tDirectory)
if (file->second.type == SourceAccessor::Type::tDirectory)
res.products.push_back(product);
}
}
@@ -126,25 +127,34 @@ BuildOutput getBuildOutput(
for (auto & output : outputs) {
auto file = narMembers.find(store->printStorePath(output) + "/nix-support/hydra-release-name");
if (file == narMembers.end() ||
file->second.type != FSAccessor::Type::tRegular)
file->second.type != SourceAccessor::Type::tRegular)
continue;
res.releaseName = trim(file->second.contents.value());
// FIXME: validate release name
auto contents = trim(file->second.contents.value());
if (std::regex_match(contents, std::regex("[a-zA-Z0-9.@:_-]+")))
res.releaseName = contents;
}
/* Get metrics. */
for (auto & output : outputs) {
auto file = narMembers.find(store->printStorePath(output) + "/nix-support/hydra-metrics");
if (file == narMembers.end() ||
file->second.type != FSAccessor::Type::tRegular)
file->second.type != SourceAccessor::Type::tRegular)
continue;
for (auto & line : tokenizeString<Strings>(file->second.contents.value(), "\n")) {
auto fields = tokenizeString<std::vector<std::string>>(line);
if (fields.size() < 2) continue;
if (!std::regex_match(fields[0], std::regex("[a-zA-Z0-9._-]+")))
continue;
BuildMetric metric;
metric.name = fields[0]; // FIXME: validate
metric.value = atof(fields[1].c_str()); // FIXME
metric.name = fields[0];
try {
metric.value = std::stod(fields[1]);
} catch (...) {
continue; // skip this metric
}
metric.unit = fields.size() >= 3 ? fields[2] : "";
if (!std::regex_match(metric.unit, std::regex("[a-zA-Z0-9._%-]+")))
metric.unit = "";
res.metrics[metric.name] = metric;
}
}

View File

@@ -2,8 +2,8 @@
#include "state.hh"
#include "hydra-build-result.hh"
#include "finally.hh"
#include "binary-cache-store.hh"
#include <nix/util/finally.hh>
#include <nix/store/binary-cache-store.hh>
using namespace nix;
@@ -16,7 +16,7 @@ void setThreadName(const std::string & name)
}
void State::builder(MachineReservation::ptr reservation)
void State::builder(std::unique_ptr<MachineReservation> reservation)
{
setThreadName("bld~" + std::string(reservation->step->drvPath.to_string()));
@@ -35,22 +35,20 @@ void State::builder(MachineReservation::ptr reservation)
activeSteps_.lock()->erase(activeStep);
});
std::string machine = reservation->machine->storeUri.render();
try {
auto destStore = getDestStore();
res = doBuildStep(destStore, reservation, activeStep);
// Might release the reservation.
res = doBuildStep(destStore, std::move(reservation), activeStep);
} catch (std::exception & e) {
printMsg(lvlError, "uncaught exception building %s on %s: %s",
localStore->printStorePath(reservation->step->drvPath),
reservation->machine->sshName,
localStore->printStorePath(activeStep->step->drvPath),
machine,
e.what());
}
}
/* Release the machine and wake up the dispatcher. */
assert(reservation.unique());
reservation = 0;
wakeDispatcher();
/* If there was a temporary failure, retry the step after an
exponentially increasing interval. */
Step::ptr step = wstep.lock();
@@ -72,11 +70,11 @@ void State::builder(MachineReservation::ptr reservation)
State::StepResult State::doBuildStep(nix::ref<Store> destStore,
MachineReservation::ptr reservation,
std::unique_ptr<MachineReservation> reservation,
std::shared_ptr<ActiveStep> activeStep)
{
auto & step(reservation->step);
auto & machine(reservation->machine);
auto step(reservation->step);
auto machine(reservation->machine);
{
auto step_(step->state.lock());
@@ -98,8 +96,13 @@ State::StepResult State::doBuildStep(nix::ref<Store> destStore,
it). */
BuildID buildId;
std::optional<StorePath> buildDrvPath;
unsigned int maxSilentTime, buildTimeout;
unsigned int repeats = step->isDeterministic ? 1 : 0;
// Other fields set below
nix::ServeProto::BuildOptions buildOptions {
.maxLogSize = maxLogSize,
.nrRepeats = step->isDeterministic ? 1u : 0u,
.enforceDeterminism = step->isDeterministic,
.keepFailed = false,
};
auto conn(dbPool.get());
@@ -134,18 +137,18 @@ State::StepResult State::doBuildStep(nix::ref<Store> destStore,
{
auto i = jobsetRepeats.find(std::make_pair(build2->projectName, build2->jobsetName));
if (i != jobsetRepeats.end())
repeats = std::max(repeats, i->second);
buildOptions.nrRepeats = std::max(buildOptions.nrRepeats, i->second);
}
}
if (!build) build = *dependents.begin();
buildId = build->id;
buildDrvPath = build->drvPath;
maxSilentTime = build->maxSilentTime;
buildTimeout = build->buildTimeout;
buildOptions.maxSilentTime = build->maxSilentTime;
buildOptions.buildTimeout = build->buildTimeout;
printInfo("performing step %s %d times on %s (needed by build %d and %d others)",
localStore->printStorePath(step->drvPath), repeats + 1, machine->sshName, buildId, (dependents.size() - 1));
localStore->printStorePath(step->drvPath), buildOptions.nrRepeats + 1, machine->storeUri.render(), buildId, (dependents.size() - 1));
}
if (!buildOneDone)
@@ -173,7 +176,7 @@ State::StepResult State::doBuildStep(nix::ref<Store> destStore,
unlink(result.logFile.c_str());
}
} catch (...) {
ignoreException();
ignoreExceptionInDestructor();
}
}
});
@@ -191,7 +194,7 @@ State::StepResult State::doBuildStep(nix::ref<Store> destStore,
{
auto mc = startDbUpdate();
pqxx::work txn(*conn);
stepNr = createBuildStep(txn, result.startTime, buildId, step, machine->sshName, bsBusy);
stepNr = createBuildStep(txn, result.startTime, buildId, step, machine->storeUri.render(), bsBusy);
txn.commit();
}
@@ -206,7 +209,7 @@ State::StepResult State::doBuildStep(nix::ref<Store> destStore,
try {
/* FIXME: referring builds may have conflicting timeouts. */
buildRemote(destStore, machine, step, maxSilentTime, buildTimeout, repeats, result, activeStep, updateStep, narMembers);
buildRemote(destStore, std::move(reservation), machine, step, buildOptions, result, activeStep, updateStep, narMembers);
} catch (Error & e) {
if (activeStep->state_.lock()->cancelled) {
printInfo("marking step %d of build %d as cancelled", stepNr, buildId);
@@ -221,7 +224,7 @@ State::StepResult State::doBuildStep(nix::ref<Store> destStore,
if (result.stepStatus == bsSuccess) {
updateStep(ssPostProcessing);
res = getBuildOutput(destStore, narMembers, *step->drv);
res = getBuildOutput(destStore, narMembers, destStore->queryDerivationOutputMap(step->drvPath, &*localStore));
}
}
@@ -248,7 +251,7 @@ State::StepResult State::doBuildStep(nix::ref<Store> destStore,
/* Finish the step in the database. */
if (stepNr) {
pqxx::work txn(*conn);
finishBuildStep(txn, result, buildId, stepNr, machine->sshName);
finishBuildStep(txn, result, buildId, stepNr, machine->storeUri.render());
txn.commit();
}
@@ -256,7 +259,7 @@ State::StepResult State::doBuildStep(nix::ref<Store> destStore,
issue). Retry a number of times. */
if (result.canRetry) {
printMsg(lvlError, "possibly transient failure building %s on %s: %s",
localStore->printStorePath(step->drvPath), machine->sshName, result.errorMsg);
localStore->printStorePath(step->drvPath), machine->storeUri.render(), result.errorMsg);
assert(stepNr);
bool retry;
{
@@ -275,9 +278,12 @@ State::StepResult State::doBuildStep(nix::ref<Store> destStore,
assert(stepNr);
for (auto & i : step->drv->outputsAndOptPaths(*localStore)) {
if (i.second.second)
addRoot(*i.second.second);
for (auto & [outputName, optOutputPath] : destStore->queryPartialDerivationOutputMap(step->drvPath, &*localStore)) {
if (!optOutputPath)
throw Error(
"Missing output %s for derivation %d which was supposed to have succeeded",
outputName, localStore->printStorePath(step->drvPath));
addRoot(*optOutputPath);
}
/* Register success in the database for all Build objects that
@@ -323,7 +329,7 @@ State::StepResult State::doBuildStep(nix::ref<Store> destStore,
pqxx::work txn(*conn);
for (auto & b : direct) {
printMsg(lvlInfo, format("marking build %1% as succeeded") % b->id);
printInfo("marking build %1% as succeeded", b->id);
markSucceededBuild(txn, b, res, buildId != b->id || result.isCached,
result.startTime, result.stopTime);
}
@@ -398,7 +404,7 @@ void State::failStep(
Step::ptr step,
BuildID buildId,
const RemoteResult & result,
Machine::ptr machine,
::Machine::ptr machine,
bool & stepFinished)
{
/* Register failure in the database for all Build objects that
@@ -444,21 +450,20 @@ void State::failStep(
build->finishedInDB)
continue;
createBuildStep(txn,
0, build->id, step, machine ? machine->sshName : "",
0, build->id, step, machine ? machine->storeUri.render() : "",
result.stepStatus, result.errorMsg, buildId == build->id ? 0 : buildId);
}
/* Mark all builds that depend on this derivation as failed. */
for (auto & build : indirect) {
if (build->finishedInDB) continue;
printMsg(lvlError, format("marking build %1% as failed") % build->id);
txn.exec_params0
("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $4, isCachedBuild = $5, notificationPendingSince = $4 where id = $1 and finished = 0",
build->id,
printError("marking build %1% as failed", build->id);
txn.exec("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $4, isCachedBuild = $5, notificationPendingSince = $4 where id = $1 and finished = 0",
pqxx::params{build->id,
(int) (build->drvPath != step->drvPath && result.buildStatus() == bsFailed ? bsDepFailed : result.buildStatus()),
result.startTime,
result.stopTime,
result.stepStatus == bsCachedFailure ? 1 : 0);
result.stepStatus == bsCachedFailure ? 1 : 0}).no_rows();
nrBuildsDone++;
}
@@ -467,7 +472,7 @@ void State::failStep(
if (result.stepStatus != bsCachedFailure && result.canCache)
for (auto & i : step->drv->outputsAndOptPaths(*localStore))
if (i.second.second)
txn.exec_params0("insert into FailedPaths values ($1)", localStore->printStorePath(*i.second.second));
txn.exec("insert into FailedPaths values ($1)", pqxx::params{localStore->printStorePath(*i.second.second)}).no_rows();
txn.commit();
}

View File

@@ -2,6 +2,7 @@
#include <cmath>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include "state.hh"
@@ -39,28 +40,34 @@ void State::dispatcher()
printMsg(lvlDebug, "dispatcher woken up");
nrDispatcherWakeups++;
auto now1 = std::chrono::steady_clock::now();
auto t_before_work = std::chrono::steady_clock::now();
auto sleepUntil = doDispatch();
auto now2 = std::chrono::steady_clock::now();
auto t_after_work = std::chrono::steady_clock::now();
dispatchTimeMs += std::chrono::duration_cast<std::chrono::milliseconds>(now2 - now1).count();
prom.dispatcher_time_spent_running.Increment(
std::chrono::duration_cast<std::chrono::microseconds>(t_after_work - t_before_work).count());
dispatchTimeMs += std::chrono::duration_cast<std::chrono::milliseconds>(t_after_work - t_before_work).count();
/* Sleep until we're woken up (either because a runnable build
is added, or because a build finishes). */
{
auto dispatcherWakeup_(dispatcherWakeup.lock());
if (!*dispatcherWakeup_) {
printMsg(lvlDebug, format("dispatcher sleeping for %1%s") %
debug("dispatcher sleeping for %1%s",
std::chrono::duration_cast<std::chrono::seconds>(sleepUntil - std::chrono::system_clock::now()).count());
dispatcherWakeup_.wait_until(dispatcherWakeupCV, sleepUntil);
}
*dispatcherWakeup_ = false;
}
auto t_after_sleep = std::chrono::steady_clock::now();
prom.dispatcher_time_spent_waiting.Increment(
std::chrono::duration_cast<std::chrono::microseconds>(t_after_sleep - t_after_work).count());
} catch (std::exception & e) {
printMsg(lvlError, format("dispatcher: %1%") % e.what());
printError("dispatcher: %s", e.what());
sleep(1);
}
@@ -80,17 +87,124 @@ system_time State::doDispatch()
jobset.second->pruneSteps();
auto s2 = jobset.second->shareUsed();
if (s1 != s2)
printMsg(lvlDebug, format("pruned scheduling window of %1%:%2% from %3% to %4%")
% jobset.first.first % jobset.first.second % s1 % s2);
debug("pruned scheduling window of %1%:%2% from %3% to %4%",
jobset.first.first, jobset.first.second, s1, s2);
}
}
system_time now = std::chrono::system_clock::now();
/* Start steps until we're out of steps or slots. */
auto sleepUntil = system_time::max();
bool keepGoing;
/* Sort the runnable steps by priority. Priority is establised
as follows (in order of precedence):
- The global priority of the builds that depend on the
step. This allows admins to bump a build to the front of
the queue.
- The lowest used scheduling share of the jobsets depending
on the step.
- The local priority of the build, as set via the build's
meta.schedulingPriority field. Note that this is not
quite correct: the local priority should only be used to
establish priority between builds in the same jobset, but
here it's used between steps in different jobsets if they
happen to have the same lowest used scheduling share. But
that's not very likely.
- The lowest ID of the builds depending on the step;
i.e. older builds take priority over new ones.
FIXME: O(n lg n); obviously, it would be better to keep a
runnable queue sorted by priority. */
struct StepInfo
{
Step::ptr step;
bool alreadyScheduled = false;
/* The lowest share used of any jobset depending on this
step. */
double lowestShareUsed = 1e9;
/* Info copied from step->state to ensure that the
comparator is a partial ordering (see MachineInfo). */
int highestGlobalPriority;
int highestLocalPriority;
size_t numRequiredSystemFeatures;
size_t numRevDeps;
BuildID lowestBuildID;
StepInfo(Step::ptr step, Step::State & step_) : step(step)
{
for (auto & jobset : step_.jobsets)
lowestShareUsed = std::min(lowestShareUsed, jobset->shareUsed());
highestGlobalPriority = step_.highestGlobalPriority;
highestLocalPriority = step_.highestLocalPriority;
numRequiredSystemFeatures = step->requiredSystemFeatures.size();
numRevDeps = step_.rdeps.size();
lowestBuildID = step_.lowestBuildID;
}
};
std::vector<StepInfo> runnableSorted;
struct RunnablePerType
{
unsigned int count{0};
std::chrono::seconds waitTime{0};
};
std::unordered_map<std::string, RunnablePerType> runnablePerType;
{
auto runnable_(runnable.lock());
runnableSorted.reserve(runnable_->size());
for (auto i = runnable_->begin(); i != runnable_->end(); ) {
auto step = i->lock();
/* Remove dead steps. */
if (!step) {
i = runnable_->erase(i);
continue;
}
++i;
auto & r = runnablePerType[step->systemType];
r.count++;
/* Skip previously failed steps that aren't ready
to be retried. */
auto step_(step->state.lock());
r.waitTime += std::chrono::duration_cast<std::chrono::seconds>(now - step_->runnableSince);
if (step_->tries > 0 && step_->after > now) {
if (step_->after < sleepUntil)
sleepUntil = step_->after;
continue;
}
runnableSorted.emplace_back(step, *step_);
}
}
sort(runnableSorted.begin(), runnableSorted.end(),
[](const StepInfo & a, const StepInfo & b)
{
return
a.highestGlobalPriority != b.highestGlobalPriority ? a.highestGlobalPriority > b.highestGlobalPriority :
a.lowestShareUsed != b.lowestShareUsed ? a.lowestShareUsed < b.lowestShareUsed :
a.highestLocalPriority != b.highestLocalPriority ? a.highestLocalPriority > b.highestLocalPriority :
a.numRequiredSystemFeatures != b.numRequiredSystemFeatures ? a.numRequiredSystemFeatures > b.numRequiredSystemFeatures :
a.numRevDeps != b.numRevDeps ? a.numRevDeps > b.numRevDeps :
a.lowestBuildID < b.lowestBuildID;
});
do {
system_time now = std::chrono::system_clock::now();
now = std::chrono::system_clock::now();
/* Copy the currentJobs field of each machine. This is
necessary to ensure that the sort comparator below is
@@ -98,7 +212,7 @@ system_time State::doDispatch()
filter out temporarily disabled machines. */
struct MachineInfo
{
Machine::ptr machine;
::Machine::ptr machine;
unsigned long currentJobs;
};
std::vector<MachineInfo> machinesSorted;
@@ -138,104 +252,6 @@ system_time State::doDispatch()
a.currentJobs > b.currentJobs;
});
/* Sort the runnable steps by priority. Priority is establised
as follows (in order of precedence):
- The global priority of the builds that depend on the
step. This allows admins to bump a build to the front of
the queue.
- The lowest used scheduling share of the jobsets depending
on the step.
- The local priority of the build, as set via the build's
meta.schedulingPriority field. Note that this is not
quite correct: the local priority should only be used to
establish priority between builds in the same jobset, but
here it's used between steps in different jobsets if they
happen to have the same lowest used scheduling share. But
that's not very likely.
- The lowest ID of the builds depending on the step;
i.e. older builds take priority over new ones.
FIXME: O(n lg n); obviously, it would be better to keep a
runnable queue sorted by priority. */
struct StepInfo
{
Step::ptr step;
/* The lowest share used of any jobset depending on this
step. */
double lowestShareUsed = 1e9;
/* Info copied from step->state to ensure that the
comparator is a partial ordering (see MachineInfo). */
int highestGlobalPriority;
int highestLocalPriority;
BuildID lowestBuildID;
StepInfo(Step::ptr step, Step::State & step_) : step(step)
{
for (auto & jobset : step_.jobsets)
lowestShareUsed = std::min(lowestShareUsed, jobset->shareUsed());
highestGlobalPriority = step_.highestGlobalPriority;
highestLocalPriority = step_.highestLocalPriority;
lowestBuildID = step_.lowestBuildID;
}
};
std::vector<StepInfo> runnableSorted;
struct RunnablePerType
{
unsigned int count{0};
std::chrono::seconds waitTime{0};
};
std::unordered_map<std::string, RunnablePerType> runnablePerType;
{
auto runnable_(runnable.lock());
runnableSorted.reserve(runnable_->size());
for (auto i = runnable_->begin(); i != runnable_->end(); ) {
auto step = i->lock();
/* Remove dead steps. */
if (!step) {
i = runnable_->erase(i);
continue;
}
++i;
auto & r = runnablePerType[step->systemType];
r.count++;
/* Skip previously failed steps that aren't ready
to be retried. */
auto step_(step->state.lock());
r.waitTime += std::chrono::duration_cast<std::chrono::seconds>(now - step_->runnableSince);
if (step_->tries > 0 && step_->after > now) {
if (step_->after < sleepUntil)
sleepUntil = step_->after;
continue;
}
runnableSorted.emplace_back(step, *step_);
}
}
sort(runnableSorted.begin(), runnableSorted.end(),
[](const StepInfo & a, const StepInfo & b)
{
return
a.highestGlobalPriority != b.highestGlobalPriority ? a.highestGlobalPriority > b.highestGlobalPriority :
a.lowestShareUsed != b.lowestShareUsed ? a.lowestShareUsed < b.lowestShareUsed :
a.highestLocalPriority != b.highestLocalPriority ? a.highestLocalPriority > b.highestLocalPriority :
a.lowestBuildID < b.lowestBuildID;
});
/* Find a machine with a free slot and find a step to run
on it. Once we find such a pair, we restart the outer
loop because the machine sorting will have changed. */
@@ -245,12 +261,14 @@ system_time State::doDispatch()
if (mi.machine->state->currentJobs >= mi.machine->maxJobs) continue;
for (auto & stepInfo : runnableSorted) {
if (stepInfo.alreadyScheduled) continue;
auto & step(stepInfo.step);
/* Can this machine do this step? */
if (!mi.machine->supportsStep(step)) {
debug("machine '%s' does not support step '%s' (system type '%s')",
mi.machine->sshName, localStore->printStorePath(step->drvPath), step->drv->platform);
mi.machine->storeUri.render(), localStore->printStorePath(step->drvPath), step->drv->platform);
continue;
}
@@ -271,10 +289,12 @@ system_time State::doDispatch()
r.count--;
}
stepInfo.alreadyScheduled = true;
/* Make a slot reservation and start a thread to
do the build. */
auto builderThread = std::thread(&State::builder, this,
std::make_shared<MachineReservation>(*this, step, mi.machine));
std::make_unique<MachineReservation>(*this, step, mi.machine));
builderThread.detach(); // FIXME?
keepGoing = true;
@@ -428,7 +448,7 @@ void Jobset::pruneSteps()
}
State::MachineReservation::MachineReservation(State & state, Step::ptr step, Machine::ptr machine)
State::MachineReservation::MachineReservation(State & state, Step::ptr step, ::Machine::ptr machine)
: state(state), step(step), machine(machine)
{
machine->state->currentJobs++;

View File

@@ -2,9 +2,9 @@
#include <memory>
#include "hash.hh"
#include "derivations.hh"
#include "store-api.hh"
#include <nix/util/hash.hh>
#include <nix/store/derivations.hh>
#include <nix/store/store-api.hh>
#include "nar-extractor.hh"
struct BuildProduct
@@ -36,10 +36,12 @@ struct BuildOutput
std::list<BuildProduct> products;
std::map<std::string, nix::StorePath> outputs;
std::map<std::string, BuildMetric> metrics;
};
BuildOutput getBuildOutput(
nix::ref<nix::Store> store,
NarMemberDatas & narMembers,
const nix::Derivation & drv);
const nix::OutputPathMap derivationOutputs);

View File

@@ -1,6 +1,7 @@
#include <iostream>
#include <thread>
#include <optional>
#include <type_traits>
#include <sys/types.h>
#include <sys/stat.h>
@@ -8,27 +9,21 @@
#include <prometheus/exposer.h>
#include <nlohmann/json.hpp>
#include <nix/util/signals.hh>
#include "state.hh"
#include "hydra-build-result.hh"
#include "store-api.hh"
#include "remote-store.hh"
#include <nix/store/store-open.hh>
#include <nix/store/remote-store.hh>
#include "globals.hh"
#include <nix/store/globals.hh>
#include "hydra-config.hh"
#include "json.hh"
#include "s3-binary-cache-store.hh"
#include "shared.hh"
#include <nix/store/s3-binary-cache-store.hh>
#include <nix/main/shared.hh>
using namespace nix;
namespace nix {
template<> void toJSON<std::atomic<long>>(std::ostream & str, const std::atomic<long> & n) { str << n; }
template<> void toJSON<std::atomic<uint64_t>>(std::ostream & str, const std::atomic<uint64_t> & n) { str << n; }
template<> void toJSON<double>(std::ostream & str, const double & n) { str << n; }
}
using nlohmann::json;
std::string getEnvOrDie(const std::string & key)
@@ -75,10 +70,31 @@ State::PromMetrics::PromMetrics()
.Register(*registry)
.Add({})
)
, queue_max_id(
prometheus::BuildGauge()
.Name("hydraqueuerunner_queue_max_build_id_info")
.Help("Maximum build record ID in the queue")
, dispatcher_time_spent_running(
prometheus::BuildCounter()
.Name("hydraqueuerunner_dispatcher_time_spent_running")
.Help("Time (in micros) spent running the dispatcher")
.Register(*registry)
.Add({})
)
, dispatcher_time_spent_waiting(
prometheus::BuildCounter()
.Name("hydraqueuerunner_dispatcher_time_spent_waiting")
.Help("Time (in micros) spent waiting for the dispatcher to obtain work")
.Register(*registry)
.Add({})
)
, queue_monitor_time_spent_running(
prometheus::BuildCounter()
.Name("hydraqueuerunner_queue_monitor_time_spent_running")
.Help("Time (in micros) spent running the queue monitor")
.Register(*registry)
.Add({})
)
, queue_monitor_time_spent_waiting(
prometheus::BuildCounter()
.Name("hydraqueuerunner_queue_monitor_time_spent_waiting")
.Help("Time (in micros) spent waiting for the queue monitor to obtain work")
.Register(*registry)
.Add({})
)
@@ -90,6 +106,7 @@ State::State(std::optional<std::string> metricsAddrOpt)
: config(std::make_unique<HydraConfig>())
, maxUnsupportedTime(config->getIntOption("max_unsupported_time", 0))
, dbPool(config->getIntOption("max_db_connections", 128))
, localWorkThrottler(config->getIntOption("max_local_worker_threads", std::min(maxSupportedLocalWorkers, std::max(4u, std::thread::hardware_concurrency()) - 2)))
, maxOutputSize(config->getIntOption("max_output_size", 2ULL << 30))
, maxLogSize(config->getIntOption("max_log_size", 64ULL << 20))
, uploadLogsToBinaryCache(config->getBoolOption("upload_logs_to_binary_cache", false))
@@ -140,50 +157,29 @@ void State::parseMachines(const std::string & contents)
oldMachines = *machines_;
}
for (auto line : tokenizeString<Strings>(contents, "\n")) {
line = trim(std::string(line, 0, line.find('#')));
auto tokens = tokenizeString<std::vector<std::string>>(line);
if (tokens.size() < 3) continue;
tokens.resize(8);
auto machine = std::make_shared<Machine>();
machine->sshName = tokens[0];
machine->systemTypes = tokenizeString<StringSet>(tokens[1], ",");
machine->sshKey = tokens[2] == "-" ? std::string("") : tokens[2];
if (tokens[3] != "")
machine->maxJobs = string2Int<decltype(machine->maxJobs)>(tokens[3]).value();
else
machine->maxJobs = 1;
machine->speedFactor = atof(tokens[4].c_str());
if (tokens[5] == "-") tokens[5] = "";
machine->supportedFeatures = tokenizeString<StringSet>(tokens[5], ",");
if (tokens[6] == "-") tokens[6] = "";
machine->mandatoryFeatures = tokenizeString<StringSet>(tokens[6], ",");
for (auto & f : machine->mandatoryFeatures)
machine->supportedFeatures.insert(f);
if (tokens[7] != "" && tokens[7] != "-")
machine->sshPublicHostKey = base64Decode(tokens[7]);
for (auto && machine_ : nix::Machine::parseConfig({}, contents)) {
auto machine = std::make_shared<::Machine>(std::move(machine_));
/* Re-use the State object of the previous machine with the
same name. */
auto i = oldMachines.find(machine->sshName);
auto i = oldMachines.find(machine->storeUri.variant);
if (i == oldMachines.end())
printMsg(lvlChatty, format("adding new machine %1%") % machine->sshName);
printMsg(lvlChatty, "adding new machine %1%", machine->storeUri.render());
else
printMsg(lvlChatty, format("updating machine %1%") % machine->sshName);
printMsg(lvlChatty, "updating machine %1%", machine->storeUri.render());
machine->state = i == oldMachines.end()
? std::make_shared<Machine::State>()
? std::make_shared<::Machine::State>()
: i->second->state;
newMachines[machine->sshName] = machine;
newMachines[machine->storeUri.variant] = machine;
}
for (auto & m : oldMachines)
if (newMachines.find(m.first) == newMachines.end()) {
if (m.second->enabled)
printMsg(lvlInfo, format("removing machine %1%") % m.first);
/* Add a disabled Machine object to make sure stats are
printInfo("removing machine %1%", m.second->storeUri.render());
/* Add a disabled ::Machine object to make sure stats are
maintained. */
auto machine = std::make_shared<Machine>(*(m.second));
auto machine = std::make_shared<::Machine>(*(m.second));
machine->enabled = false;
newMachines[m.first] = machine;
}
@@ -211,7 +207,7 @@ void State::monitorMachinesFile()
parseMachines("localhost " +
(settings.thisSystem == "x86_64-linux" ? "x86_64-linux,i686-linux" : settings.thisSystem.get())
+ " - " + std::to_string(settings.maxBuildJobs) + " 1 "
+ concatStringsSep(",", settings.systemFeatures.get()));
+ concatStringsSep(",", StoreConfig::getDefaultSystemFeatures()));
machinesReadyLock.unlock();
return;
}
@@ -280,17 +276,16 @@ void State::monitorMachinesFile()
void State::clearBusy(Connection & conn, time_t stopTime)
{
pqxx::work txn(conn);
txn.exec_params0
("update BuildSteps set busy = 0, status = $1, stopTime = $2 where busy != 0",
(int) bsAborted,
stopTime != 0 ? std::make_optional(stopTime) : std::nullopt);
txn.exec("update BuildSteps set busy = 0, status = $1, stopTime = $2 where busy != 0",
pqxx::params{(int) bsAborted,
stopTime != 0 ? std::make_optional(stopTime) : std::nullopt}).no_rows();
txn.commit();
}
unsigned int State::allocBuildStep(pqxx::work & txn, BuildID buildId)
{
auto res = txn.exec_params1("select max(stepnr) from BuildSteps where build = $1", buildId);
auto res = txn.exec("select max(stepnr) from BuildSteps where build = $1", buildId).one_row();
return res[0].is_null() ? 1 : res[0].as<int>() + 1;
}
@@ -301,9 +296,8 @@ unsigned int State::createBuildStep(pqxx::work & txn, time_t startTime, BuildID
restart:
auto stepNr = allocBuildStep(txn, buildId);
auto r = txn.exec_params
("insert into BuildSteps (build, stepnr, type, drvPath, busy, startTime, system, status, propagatedFrom, errorMsg, stopTime, machine) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) on conflict do nothing",
buildId,
auto r = txn.exec("insert into BuildSteps (build, stepnr, type, drvPath, busy, startTime, system, status, propagatedFrom, errorMsg, stopTime, machine) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) on conflict do nothing",
pqxx::params{buildId,
stepNr,
0, // == build
localStore->printStorePath(step->drvPath),
@@ -314,14 +308,16 @@ unsigned int State::createBuildStep(pqxx::work & txn, time_t startTime, BuildID
propagatedFrom != 0 ? std::make_optional(propagatedFrom) : std::nullopt, // internal::params
errorMsg != "" ? std::make_optional(errorMsg) : std::nullopt,
startTime != 0 && status != bsBusy ? std::make_optional(startTime) : std::nullopt,
machine);
machine});
if (r.affected_rows() == 0) goto restart;
for (auto & [name, output] : step->drv->outputs)
txn.exec_params0
("insert into BuildStepOutputs (build, stepnr, name, path) values ($1, $2, $3, $4)",
buildId, stepNr, name, localStore->printStorePath(*output.path(*localStore, step->drv->name, name)));
for (auto & [name, output] : getDestStore()->queryPartialDerivationOutputMap(step->drvPath, &*localStore))
txn.exec("insert into BuildStepOutputs (build, stepnr, name, path) values ($1, $2, $3, $4)",
pqxx::params{buildId, stepNr, name,
output
? std::optional { localStore->printStorePath(*output)}
: std::nullopt}).no_rows();
if (status == bsBusy)
txn.exec(fmt("notify step_started, '%d\t%d'", buildId, stepNr));
@@ -332,11 +328,10 @@ unsigned int State::createBuildStep(pqxx::work & txn, time_t startTime, BuildID
void State::updateBuildStep(pqxx::work & txn, BuildID buildId, unsigned int stepNr, StepState stepState)
{
if (txn.exec_params
("update BuildSteps set busy = $1 where build = $2 and stepnr = $3 and busy != 0 and status is null",
(int) stepState,
if (txn.exec("update BuildSteps set busy = $1 where build = $2 and stepnr = $3 and busy != 0 and status is null",
pqxx::params{(int) stepState,
buildId,
stepNr).affected_rows() != 1)
stepNr}).affected_rows() != 1)
throw Error("step %d of build %d is in an unexpected state", stepNr, buildId);
}
@@ -346,44 +341,52 @@ void State::finishBuildStep(pqxx::work & txn, const RemoteResult & result,
{
assert(result.startTime);
assert(result.stopTime);
txn.exec_params0
("update BuildSteps set busy = 0, status = $1, errorMsg = $4, startTime = $5, stopTime = $6, machine = $7, overhead = $8, timesBuilt = $9, isNonDeterministic = $10 where build = $2 and stepnr = $3",
(int) result.stepStatus, buildId, stepNr,
txn.exec("update BuildSteps set busy = 0, status = $1, errorMsg = $4, startTime = $5, stopTime = $6, machine = $7, overhead = $8, timesBuilt = $9, isNonDeterministic = $10 where build = $2 and stepnr = $3",
pqxx::params{(int) result.stepStatus, buildId, stepNr,
result.errorMsg != "" ? std::make_optional(result.errorMsg) : std::nullopt,
result.startTime, result.stopTime,
machine != "" ? std::make_optional(machine) : std::nullopt,
result.overhead != 0 ? std::make_optional(result.overhead) : std::nullopt,
result.timesBuilt > 0 ? std::make_optional(result.timesBuilt) : std::nullopt,
result.timesBuilt > 1 ? std::make_optional(result.isNonDeterministic) : std::nullopt);
result.timesBuilt > 1 ? std::make_optional(result.isNonDeterministic) : std::nullopt}).no_rows();
assert(result.logFile.find('\t') == std::string::npos);
txn.exec(fmt("notify step_finished, '%d\t%d\t%s'",
buildId, stepNr, result.logFile));
if (result.stepStatus == bsSuccess) {
// Update the corresponding `BuildStepOutputs` row to add the output path
auto res = txn.exec("select drvPath from BuildSteps where build = $1 and stepnr = $2", pqxx::params{buildId, stepNr}).one_row();
assert(res.size());
StorePath drvPath = localStore->parseStorePath(res[0].as<std::string>());
// If we've finished building, all the paths should be known
for (auto & [name, output] : getDestStore()->queryDerivationOutputMap(drvPath, &*localStore))
txn.exec("update BuildStepOutputs set path = $4 where build = $1 and stepnr = $2 and name = $3",
pqxx::params{buildId, stepNr, name, localStore->printStorePath(output)}).no_rows();
}
}
int State::createSubstitutionStep(pqxx::work & txn, time_t startTime, time_t stopTime,
Build::ptr build, const StorePath & drvPath, const std::string & outputName, const StorePath & storePath)
Build::ptr build, const StorePath & drvPath, const nix::Derivation drv, const std::string & outputName, const StorePath & storePath)
{
restart:
auto stepNr = allocBuildStep(txn, build->id);
auto r = txn.exec_params
("insert into BuildSteps (build, stepnr, type, drvPath, busy, status, startTime, stopTime) values ($1, $2, $3, $4, $5, $6, $7, $8) on conflict do nothing",
build->id,
auto r = txn.exec("insert into BuildSteps (build, stepnr, type, drvPath, busy, status, startTime, stopTime) values ($1, $2, $3, $4, $5, $6, $7, $8) on conflict do nothing",
pqxx::params{build->id,
stepNr,
1, // == substitution
(localStore->printStorePath(drvPath)),
0,
0,
startTime,
stopTime);
stopTime});
if (r.affected_rows() == 0) goto restart;
txn.exec_params0
("insert into BuildStepOutputs (build, stepnr, name, path) values ($1, $2, $3, $4)",
build->id, stepNr, outputName,
localStore->printStorePath(storePath));
txn.exec("insert into BuildStepOutputs (build, stepnr, name, path) values ($1, $2, $3, $4)",
pqxx::params{build->id, stepNr, outputName,
localStore->printStorePath(storePath)}).no_rows();
return stepNr;
}
@@ -450,49 +453,54 @@ void State::markSucceededBuild(pqxx::work & txn, Build::ptr build,
{
if (build->finishedInDB) return;
if (txn.exec_params("select 1 from Builds where id = $1 and finished = 0", build->id).empty()) return;
if (txn.exec("select 1 from Builds where id = $1 and finished = 0", pqxx::params{build->id}).empty()) return;
txn.exec_params0
("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $4, size = $5, closureSize = $6, releaseName = $7, isCachedBuild = $8, notificationPendingSince = $4 where id = $1",
build->id,
txn.exec("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $4, size = $5, closureSize = $6, releaseName = $7, isCachedBuild = $8, notificationPendingSince = $4 where id = $1",
pqxx::params{build->id,
(int) (res.failed ? bsFailedWithOutput : bsSuccess),
startTime,
stopTime,
res.size,
res.closureSize,
res.releaseName != "" ? std::make_optional(res.releaseName) : std::nullopt,
isCachedBuild ? 1 : 0);
isCachedBuild ? 1 : 0}).no_rows();
txn.exec_params0("delete from BuildProducts where build = $1", build->id);
for (auto & [outputName, outputPath] : res.outputs) {
txn.exec("update BuildOutputs set path = $3 where build = $1 and name = $2",
pqxx::params{build->id,
outputName,
localStore->printStorePath(outputPath)}
).no_rows();
}
txn.exec("delete from BuildProducts where build = $1", pqxx::params{build->id}).no_rows();
unsigned int productNr = 1;
for (auto & product : res.products) {
txn.exec_params0
("insert into BuildProducts (build, productnr, type, subtype, fileSize, sha256hash, path, name, defaultPath) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
build->id,
txn.exec("insert into BuildProducts (build, productnr, type, subtype, fileSize, sha256hash, path, name, defaultPath) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
pqxx::params{build->id,
productNr++,
product.type,
product.subtype,
product.fileSize ? std::make_optional(*product.fileSize) : std::nullopt,
product.sha256hash ? std::make_optional(product.sha256hash->to_string(Base16, false)) : std::nullopt,
product.sha256hash ? std::make_optional(product.sha256hash->to_string(HashFormat::Base16, false)) : std::nullopt,
product.path,
product.name,
product.defaultPath);
product.defaultPath}).no_rows();
}
txn.exec_params0("delete from BuildMetrics where build = $1", build->id);
txn.exec("delete from BuildMetrics where build = $1", pqxx::params{build->id}).no_rows();
for (auto & metric : res.metrics) {
txn.exec_params0
("insert into BuildMetrics (build, name, unit, value, project, jobset, job, timestamp) values ($1, $2, $3, $4, $5, $6, $7, $8)",
build->id,
txn.exec("insert into BuildMetrics (build, name, unit, value, project, jobset, job, timestamp) values ($1, $2, $3, $4, $5, $6, $7, $8)",
pqxx::params{build->id,
metric.second.name,
metric.second.unit != "" ? std::make_optional(metric.second.unit) : std::nullopt,
metric.second.value,
build->projectName,
build->jobsetName,
build->jobName,
build->timestamp);
build->timestamp}).no_rows();
}
nrBuildsDone++;
@@ -504,7 +512,7 @@ bool State::checkCachedFailure(Step::ptr step, Connection & conn)
pqxx::work txn(conn);
for (auto & i : step->drv->outputsAndOptPaths(*localStore))
if (i.second.second)
if (!txn.exec_params("select 1 from FailedPaths where path = $1", localStore->printStorePath(*i.second.second)).empty())
if (!txn.exec("select 1 from FailedPaths where path = $1", pqxx::params{localStore->printStorePath(*i.second.second)}).empty())
return true;
return false;
}
@@ -542,190 +550,182 @@ std::shared_ptr<PathLocks> State::acquireGlobalLock()
void State::dumpStatus(Connection & conn)
{
std::ostringstream out;
time_t now = time(0);
json statusJson = {
{"status", "up"},
{"time", time(0)},
{"uptime", now - startedAt},
{"pid", getpid()},
{"nrQueuedBuilds", builds.lock()->size()},
{"nrActiveSteps", activeSteps_.lock()->size()},
{"nrStepsBuilding", nrStepsBuilding.load()},
{"nrStepsCopyingTo", nrStepsCopyingTo.load()},
{"nrStepsWaitingForDownloadSlot", nrStepsWaitingForDownloadSlot.load()},
{"nrStepsCopyingFrom", nrStepsCopyingFrom.load()},
{"nrStepsWaiting", nrStepsWaiting.load()},
{"nrUnsupportedSteps", nrUnsupportedSteps.load()},
{"bytesSent", bytesSent.load()},
{"bytesReceived", bytesReceived.load()},
{"nrBuildsRead", nrBuildsRead.load()},
{"buildReadTimeMs", buildReadTimeMs.load()},
{"buildReadTimeAvgMs", nrBuildsRead == 0 ? 0.0 : (float) buildReadTimeMs / nrBuildsRead},
{"nrBuildsDone", nrBuildsDone.load()},
{"nrStepsStarted", nrStepsStarted.load()},
{"nrStepsDone", nrStepsDone.load()},
{"nrRetries", nrRetries.load()},
{"maxNrRetries", maxNrRetries.load()},
{"nrQueueWakeups", nrQueueWakeups.load()},
{"nrDispatcherWakeups", nrDispatcherWakeups.load()},
{"dispatchTimeMs", dispatchTimeMs.load()},
{"dispatchTimeAvgMs", nrDispatcherWakeups == 0 ? 0.0 : (float) dispatchTimeMs / nrDispatcherWakeups},
{"nrDbConnections", dbPool.count()},
{"nrActiveDbUpdates", nrActiveDbUpdates.load()},
};
{
JSONObject root(out);
time_t now = time(0);
root.attr("status", "up");
root.attr("time", time(0));
root.attr("uptime", now - startedAt);
root.attr("pid", getpid());
{
auto builds_(builds.lock());
root.attr("nrQueuedBuilds", builds_->size());
}
{
auto steps_(steps.lock());
for (auto i = steps_->begin(); i != steps_->end(); )
if (i->second.lock()) ++i; else i = steps_->erase(i);
root.attr("nrUnfinishedSteps", steps_->size());
statusJson["nrUnfinishedSteps"] = steps_->size();
}
{
auto runnable_(runnable.lock());
for (auto i = runnable_->begin(); i != runnable_->end(); )
if (i->lock()) ++i; else i = runnable_->erase(i);
root.attr("nrRunnableSteps", runnable_->size());
statusJson["nrRunnableSteps"] = runnable_->size();
}
root.attr("nrActiveSteps", activeSteps_.lock()->size());
root.attr("nrStepsBuilding", nrStepsBuilding);
root.attr("nrStepsCopyingTo", nrStepsCopyingTo);
root.attr("nrStepsCopyingFrom", nrStepsCopyingFrom);
root.attr("nrStepsWaiting", nrStepsWaiting);
root.attr("nrUnsupportedSteps", nrUnsupportedSteps);
root.attr("bytesSent", bytesSent);
root.attr("bytesReceived", bytesReceived);
root.attr("nrBuildsRead", nrBuildsRead);
root.attr("buildReadTimeMs", buildReadTimeMs);
root.attr("buildReadTimeAvgMs", nrBuildsRead == 0 ? 0.0 : (float) buildReadTimeMs / nrBuildsRead);
root.attr("nrBuildsDone", nrBuildsDone);
root.attr("nrStepsStarted", nrStepsStarted);
root.attr("nrStepsDone", nrStepsDone);
root.attr("nrRetries", nrRetries);
root.attr("maxNrRetries", maxNrRetries);
if (nrStepsDone) {
root.attr("totalStepTime", totalStepTime);
root.attr("totalStepBuildTime", totalStepBuildTime);
root.attr("avgStepTime", (float) totalStepTime / nrStepsDone);
root.attr("avgStepBuildTime", (float) totalStepBuildTime / nrStepsDone);
statusJson["totalStepTime"] = totalStepTime.load();
statusJson["totalStepBuildTime"] = totalStepBuildTime.load();
statusJson["avgStepTime"] = (float) totalStepTime / nrStepsDone;
statusJson["avgStepBuildTime"] = (float) totalStepBuildTime / nrStepsDone;
}
root.attr("nrQueueWakeups", nrQueueWakeups);
root.attr("nrDispatcherWakeups", nrDispatcherWakeups);
root.attr("dispatchTimeMs", dispatchTimeMs);
root.attr("dispatchTimeAvgMs", nrDispatcherWakeups == 0 ? 0.0 : (float) dispatchTimeMs / nrDispatcherWakeups);
root.attr("nrDbConnections", dbPool.count());
root.attr("nrActiveDbUpdates", nrActiveDbUpdates);
{
auto nested = root.object("machines");
auto machines_json = json::object();
auto machines_(machines.lock());
for (auto & i : *machines_) {
auto & m(i.second);
auto & s(m->state);
auto nested2 = nested.object(m->sshName);
nested2.attr("enabled", m->enabled);
{
auto list = nested2.list("systemTypes");
for (auto & s : m->systemTypes)
list.elem(s);
}
{
auto list = nested2.list("supportedFeatures");
for (auto & s : m->supportedFeatures)
list.elem(s);
}
{
auto list = nested2.list("mandatoryFeatures");
for (auto & s : m->mandatoryFeatures)
list.elem(s);
}
nested2.attr("currentJobs", s->currentJobs);
if (s->currentJobs == 0)
nested2.attr("idleSince", s->idleSince);
nested2.attr("nrStepsDone", s->nrStepsDone);
if (m->state->nrStepsDone) {
nested2.attr("totalStepTime", s->totalStepTime);
nested2.attr("totalStepBuildTime", s->totalStepBuildTime);
nested2.attr("avgStepTime", (float) s->totalStepTime / s->nrStepsDone);
nested2.attr("avgStepBuildTime", (float) s->totalStepBuildTime / s->nrStepsDone);
}
auto info(m->state->connectInfo.lock());
nested2.attr("disabledUntil", std::chrono::system_clock::to_time_t(info->disabledUntil));
nested2.attr("lastFailure", std::chrono::system_clock::to_time_t(info->lastFailure));
nested2.attr("consecutiveFailures", info->consecutiveFailures);
json machine = {
{"enabled", m->enabled},
{"systemTypes", m->systemTypes},
{"supportedFeatures", m->supportedFeatures},
{"mandatoryFeatures", m->mandatoryFeatures},
{"nrStepsDone", s->nrStepsDone.load()},
{"currentJobs", s->currentJobs.load()},
{"disabledUntil", std::chrono::system_clock::to_time_t(info->disabledUntil)},
{"lastFailure", std::chrono::system_clock::to_time_t(info->lastFailure)},
{"consecutiveFailures", info->consecutiveFailures},
};
if (s->currentJobs == 0)
machine["idleSince"] = s->idleSince.load();
if (m->state->nrStepsDone) {
machine["totalStepTime"] = s->totalStepTime.load();
machine["totalStepBuildTime"] = s->totalStepBuildTime.load();
machine["avgStepTime"] = (float) s->totalStepTime / s->nrStepsDone;
machine["avgStepBuildTime"] = (float) s->totalStepBuildTime / s->nrStepsDone;
}
machines_json[m->storeUri.render()] = machine;
}
statusJson["machines"] = machines_json;
}
{
auto nested = root.object("jobsets");
auto jobsets_json = json::object();
auto jobsets_(jobsets.lock());
for (auto & jobset : *jobsets_) {
auto nested2 = nested.object(jobset.first.first + ":" + jobset.first.second);
nested2.attr("shareUsed", jobset.second->shareUsed());
nested2.attr("seconds", jobset.second->getSeconds());
jobsets_json[jobset.first.first + ":" + jobset.first.second] = {
{"shareUsed", jobset.second->shareUsed()},
{"seconds", jobset.second->getSeconds()},
};
}
statusJson["jobsets"] = jobsets_json;
}
{
auto nested = root.object("machineTypes");
auto machineTypesJson = json::object();
auto machineTypes_(machineTypes.lock());
for (auto & i : *machineTypes_) {
auto nested2 = nested.object(i.first);
nested2.attr("runnable", i.second.runnable);
nested2.attr("running", i.second.running);
auto machineTypeJson = machineTypesJson[i.first] = {
{"runnable", i.second.runnable},
{"running", i.second.running},
};
if (i.second.runnable > 0)
nested2.attr("waitTime", i.second.waitTime.count() +
i.second.runnable * (time(0) - lastDispatcherCheck));
machineTypeJson["waitTime"] = i.second.waitTime.count() +
i.second.runnable * (time(0) - lastDispatcherCheck);
if (i.second.running == 0)
nested2.attr("lastActive", std::chrono::system_clock::to_time_t(i.second.lastActive));
machineTypeJson["lastActive"] = std::chrono::system_clock::to_time_t(i.second.lastActive);
}
statusJson["machineTypes"] = machineTypesJson;
}
auto store = getDestStore();
auto nested = root.object("store");
auto & stats = store->getStats();
nested.attr("narInfoRead", stats.narInfoRead);
nested.attr("narInfoReadAverted", stats.narInfoReadAverted);
nested.attr("narInfoMissing", stats.narInfoMissing);
nested.attr("narInfoWrite", stats.narInfoWrite);
nested.attr("narInfoCacheSize", stats.pathInfoCacheSize);
nested.attr("narRead", stats.narRead);
nested.attr("narReadBytes", stats.narReadBytes);
nested.attr("narReadCompressedBytes", stats.narReadCompressedBytes);
nested.attr("narWrite", stats.narWrite);
nested.attr("narWriteAverted", stats.narWriteAverted);
nested.attr("narWriteBytes", stats.narWriteBytes);
nested.attr("narWriteCompressedBytes", stats.narWriteCompressedBytes);
nested.attr("narWriteCompressionTimeMs", stats.narWriteCompressionTimeMs);
nested.attr("narCompressionSavings",
stats.narWriteBytes
? 1.0 - (double) stats.narWriteCompressedBytes / stats.narWriteBytes
: 0.0);
nested.attr("narCompressionSpeed", // MiB/s
statusJson["store"] = {
{"narInfoRead", stats.narInfoRead.load()},
{"narInfoReadAverted", stats.narInfoReadAverted.load()},
{"narInfoMissing", stats.narInfoMissing.load()},
{"narInfoWrite", stats.narInfoWrite.load()},
{"narInfoCacheSize", stats.pathInfoCacheSize.load()},
{"narRead", stats.narRead.load()},
{"narReadBytes", stats.narReadBytes.load()},
{"narReadCompressedBytes", stats.narReadCompressedBytes.load()},
{"narWrite", stats.narWrite.load()},
{"narWriteAverted", stats.narWriteAverted.load()},
{"narWriteBytes", stats.narWriteBytes.load()},
{"narWriteCompressedBytes", stats.narWriteCompressedBytes.load()},
{"narWriteCompressionTimeMs", stats.narWriteCompressionTimeMs.load()},
{"narCompressionSavings",
stats.narWriteBytes
? 1.0 - (double) stats.narWriteCompressedBytes / stats.narWriteBytes
: 0.0},
{"narCompressionSpeed", // MiB/s
stats.narWriteCompressionTimeMs
? (double) stats.narWriteBytes / stats.narWriteCompressionTimeMs * 1000.0 / (1024.0 * 1024.0)
: 0.0);
: 0.0},
};
#if NIX_WITH_S3_SUPPORT
auto s3Store = dynamic_cast<S3BinaryCacheStore *>(&*store);
if (s3Store) {
auto nested2 = nested.object("s3");
auto & s3Stats = s3Store->getS3Stats();
nested2.attr("put", s3Stats.put);
nested2.attr("putBytes", s3Stats.putBytes);
nested2.attr("putTimeMs", s3Stats.putTimeMs);
nested2.attr("putSpeed",
s3Stats.putTimeMs
? (double) s3Stats.putBytes / s3Stats.putTimeMs * 1000.0 / (1024.0 * 1024.0)
: 0.0);
nested2.attr("get", s3Stats.get);
nested2.attr("getBytes", s3Stats.getBytes);
nested2.attr("getTimeMs", s3Stats.getTimeMs);
nested2.attr("getSpeed",
s3Stats.getTimeMs
? (double) s3Stats.getBytes / s3Stats.getTimeMs * 1000.0 / (1024.0 * 1024.0)
: 0.0);
nested2.attr("head", s3Stats.head);
nested2.attr("costDollarApprox",
(s3Stats.get + s3Stats.head) / 10000.0 * 0.004
+ s3Stats.put / 1000.0 * 0.005 +
+ s3Stats.getBytes / (1024.0 * 1024.0 * 1024.0) * 0.09);
auto jsonS3 = statusJson["s3"] = {
{"put", s3Stats.put.load()},
{"putBytes", s3Stats.putBytes.load()},
{"putTimeMs", s3Stats.putTimeMs.load()},
{"putSpeed",
s3Stats.putTimeMs
? (double) s3Stats.putBytes / s3Stats.putTimeMs * 1000.0 / (1024.0 * 1024.0)
: 0.0},
{"get", s3Stats.get.load()},
{"getBytes", s3Stats.getBytes.load()},
{"getTimeMs", s3Stats.getTimeMs.load()},
{"getSpeed",
s3Stats.getTimeMs
? (double) s3Stats.getBytes / s3Stats.getTimeMs * 1000.0 / (1024.0 * 1024.0)
: 0.0},
{"head", s3Stats.head.load()},
{"costDollarApprox",
(s3Stats.get + s3Stats.head) / 10000.0 * 0.004
+ s3Stats.put / 1000.0 * 0.005 +
+ s3Stats.getBytes / (1024.0 * 1024.0 * 1024.0) * 0.09},
};
}
#endif
}
{
auto mc = startDbUpdate();
pqxx::work txn(conn);
// FIXME: use PostgreSQL 9.5 upsert.
txn.exec("delete from SystemStatus where what = 'queue-runner'");
txn.exec_params0("insert into SystemStatus values ('queue-runner', $1)", out.str());
txn.exec("delete from SystemStatus where what = 'queue-runner'").no_rows();
txn.exec("insert into SystemStatus values ('queue-runner', $1)", pqxx::params{statusJson.dump()}).no_rows();
txn.exec("notify status_dumped");
txn.commit();
}
@@ -790,7 +790,7 @@ void State::unlock()
{
pqxx::work txn(*conn);
txn.exec("delete from SystemStatus where what = 'queue-runner'");
txn.exec("delete from SystemStatus where what = 'queue-runner'").no_rows();
txn.commit();
}
}
@@ -820,7 +820,7 @@ void State::run(BuildID buildOne)
<< metricsAddr << "/metrics (port " << exposerPort << ")"
<< std::endl;
Store::Params localParams;
Store::Config::Params localParams;
localParams["max-connections"] = "16";
localParams["max-connection-age"] = "600";
localStore = openStore(getEnv("NIX_REMOTE").value_or(""), localParams);
@@ -868,11 +868,10 @@ void State::run(BuildID buildOne)
pqxx::work txn(*conn);
for (auto & step : steps) {
printMsg(lvlError, "cleaning orphaned step %d of build %d", step.second, step.first);
txn.exec_params0
("update BuildSteps set busy = 0, status = $1 where build = $2 and stepnr = $3 and busy != 0",
(int) bsAborted,
txn.exec("update BuildSteps set busy = 0, status = $1 where build = $2 and stepnr = $3 and busy != 0",
pqxx::params{(int) bsAborted,
step.first,
step.second);
step.second}).no_rows();
}
txn.commit();
} catch (std::exception & e) {
@@ -902,10 +901,17 @@ void State::run(BuildID buildOne)
while (true) {
try {
auto conn(dbPool.get());
receiver dumpStatus_(*conn, "dump_status");
while (true) {
conn->await_notification();
dumpStatus(*conn);
try {
receiver dumpStatus_(*conn, "dump_status");
while (true) {
conn->await_notification();
dumpStatus(*conn);
}
} catch (pqxx::broken_connection & connEx) {
printMsg(lvlError, "main thread: %s", connEx.what());
printMsg(lvlError, "main thread: Reconnecting in 10s");
conn.markBad();
sleep(10);
}
} catch (std::exception & e) {
printMsg(lvlError, "main thread: %s", e.what());
@@ -950,7 +956,6 @@ int main(int argc, char * * argv)
});
settings.verboseBuild = true;
settings.lockCPU = false;
State state{metricsAddrOpt};
if (status)

View File

@@ -0,0 +1,24 @@
srcs = files(
'builder.cc',
'build-remote.cc',
'build-result.cc',
'dispatcher.cc',
'hydra-queue-runner.cc',
'nar-extractor.cc',
'queue-monitor.cc',
)
hydra_queue_runner = executable('hydra-queue-runner',
'hydra-queue-runner.cc',
srcs,
dependencies: [
libhydra_dep,
nix_util_dep,
nix_store_dep,
nix_main_dep,
pqxx_dep,
prom_cpp_core_dep,
prom_cpp_pull_dep,
],
install: true,
)

View File

@@ -1,12 +1,51 @@
#include "nar-extractor.hh"
#include "archive.hh"
#include <nix/util/archive.hh>
#include <unordered_set>
using namespace nix;
struct Extractor : ParseSink
struct NarMemberConstructor : CreateRegularFileSink
{
NarMemberData & curMember;
HashSink hashSink = HashSink { HashAlgorithm::SHA256 };
std::optional<uint64_t> expectedSize;
NarMemberConstructor(NarMemberData & curMember)
: curMember(curMember)
{ }
void isExecutable() override
{
}
void preallocateContents(uint64_t size) override
{
expectedSize = size;
}
void operator () (std::string_view data) override
{
assert(expectedSize);
*curMember.fileSize += data.size();
hashSink(data);
if (curMember.contents) {
curMember.contents->append(data);
}
assert(curMember.fileSize <= expectedSize);
if (curMember.fileSize == expectedSize) {
auto [hash, len] = hashSink.finish();
assert(curMember.fileSize == len);
curMember.sha256 = hash;
}
}
};
struct Extractor : FileSystemObjectSink
{
std::unordered_set<Path> filesToKeep {
"/nix-support/hydra-build-products",
@@ -15,58 +54,40 @@ struct Extractor : ParseSink
};
NarMemberDatas & members;
NarMemberData * curMember = nullptr;
Path prefix;
std::filesystem::path prefix;
Path toKey(const CanonPath & path)
{
std::filesystem::path p = prefix;
// Conditional to avoid trailing slash
if (!path.isRoot()) p /= path.rel();
return p;
}
Extractor(NarMemberDatas & members, const Path & prefix)
: members(members), prefix(prefix)
{ }
void createDirectory(const Path & path) override
void createDirectory(const CanonPath & path) override
{
members.insert_or_assign(prefix + path, NarMemberData { .type = FSAccessor::Type::tDirectory });
members.insert_or_assign(toKey(path), NarMemberData { .type = SourceAccessor::Type::tDirectory });
}
void createRegularFile(const Path & path) override
void createRegularFile(const CanonPath & path, std::function<void(CreateRegularFileSink &)> func) override
{
curMember = &members.insert_or_assign(prefix + path, NarMemberData {
.type = FSAccessor::Type::tRegular,
.fileSize = 0,
.contents = filesToKeep.count(path) ? std::optional("") : std::nullopt,
}).first->second;
NarMemberConstructor nmc {
members.insert_or_assign(toKey(path), NarMemberData {
.type = SourceAccessor::Type::tRegular,
.fileSize = 0,
.contents = filesToKeep.count(path.abs()) ? std::optional("") : std::nullopt,
}).first->second,
};
func(nmc);
}
std::optional<uint64_t> expectedSize;
std::unique_ptr<HashSink> hashSink;
void preallocateContents(uint64_t size) override
void createSymlink(const CanonPath & path, const std::string & target) override
{
expectedSize = size;
hashSink = std::make_unique<HashSink>(htSHA256);
}
void receiveContents(std::string_view data) override
{
assert(expectedSize);
assert(curMember);
assert(hashSink);
*curMember->fileSize += data.size();
(*hashSink)(data);
if (curMember->contents) {
curMember->contents->append(data);
}
assert(curMember->fileSize <= expectedSize);
if (curMember->fileSize == expectedSize) {
auto [hash, len] = hashSink->finish();
assert(curMember->fileSize == len);
curMember->sha256 = hash;
hashSink.reset();
}
}
void createSymlink(const Path & path, const std::string & target) override
{
members.insert_or_assign(prefix + path, NarMemberData { .type = FSAccessor::Type::tSymlink });
members.insert_or_assign(toKey(path), NarMemberData { .type = SourceAccessor::Type::tSymlink });
}
};

View File

@@ -1,13 +1,13 @@
#pragma once
#include "fs-accessor.hh"
#include "types.hh"
#include "serialise.hh"
#include "hash.hh"
#include <nix/util/source-accessor.hh>
#include <nix/util/types.hh>
#include <nix/util/serialise.hh>
#include <nix/util/hash.hh>
struct NarMemberData
{
nix::FSAccessor::Type type;
nix::SourceAccessor::Type type;
std::optional<uint64_t> fileSize;
std::optional<std::string> contents;
std::optional<nix::Hash> sha256;

View File

@@ -1,8 +1,11 @@
#include "state.hh"
#include "hydra-build-result.hh"
#include "globals.hh"
#include <nix/store/globals.hh>
#include <nix/store/parsed-derivations.hh>
#include <nix/util/thread-pool.hh>
#include <cstring>
#include <signal.h>
using namespace nix;
@@ -10,63 +13,74 @@ using namespace nix;
void State::queueMonitor()
{
while (true) {
auto conn(dbPool.get());
try {
queueMonitorLoop();
queueMonitorLoop(*conn);
} catch (pqxx::broken_connection & e) {
printMsg(lvlError, "queue monitor: %s", e.what());
printMsg(lvlError, "queue monitor: Reconnecting in 10s");
conn.markBad();
sleep(10);
} catch (std::exception & e) {
printMsg(lvlError, format("queue monitor: %1%") % e.what());
printError("queue monitor: %s", e.what());
sleep(10); // probably a DB problem, so don't retry right away
}
}
}
void State::queueMonitorLoop()
void State::queueMonitorLoop(Connection & conn)
{
auto conn(dbPool.get());
receiver buildsAdded(*conn, "builds_added");
receiver buildsRestarted(*conn, "builds_restarted");
receiver buildsCancelled(*conn, "builds_cancelled");
receiver buildsDeleted(*conn, "builds_deleted");
receiver buildsBumped(*conn, "builds_bumped");
receiver jobsetSharesChanged(*conn, "jobset_shares_changed");
receiver buildsAdded(conn, "builds_added");
receiver buildsRestarted(conn, "builds_restarted");
receiver buildsCancelled(conn, "builds_cancelled");
receiver buildsDeleted(conn, "builds_deleted");
receiver buildsBumped(conn, "builds_bumped");
receiver jobsetSharesChanged(conn, "jobset_shares_changed");
auto destStore = getDestStore();
unsigned int lastBuildId = 0;
bool quit = false;
while (!quit) {
auto t_before_work = std::chrono::steady_clock::now();
localStore->clearPathInfoCache();
bool done = getQueuedBuilds(*conn, destStore, lastBuildId);
bool done = getQueuedBuilds(conn, destStore);
if (buildOne && buildOneDone) quit = true;
auto t_after_work = std::chrono::steady_clock::now();
prom.queue_monitor_time_spent_running.Increment(
std::chrono::duration_cast<std::chrono::microseconds>(t_after_work - t_before_work).count());
/* Sleep until we get notification from the database about an
event. */
if (done && !quit) {
conn->await_notification();
conn.await_notification();
nrQueueWakeups++;
} else
conn->get_notifs();
conn.get_notifs();
if (auto lowestId = buildsAdded.get()) {
lastBuildId = std::min(lastBuildId, static_cast<unsigned>(std::stoul(*lowestId) - 1));
printMsg(lvlTalkative, "got notification: new builds added to the queue");
}
if (buildsRestarted.get()) {
printMsg(lvlTalkative, "got notification: builds restarted");
lastBuildId = 0; // check all builds
}
if (buildsCancelled.get() || buildsDeleted.get() || buildsBumped.get()) {
printMsg(lvlTalkative, "got notification: builds cancelled or bumped");
processQueueChange(*conn);
processQueueChange(conn);
}
if (jobsetSharesChanged.get()) {
printMsg(lvlTalkative, "got notification: jobset shares changed");
processJobsetSharesChange(*conn);
processJobsetSharesChange(conn);
}
auto t_after_sleep = std::chrono::steady_clock::now();
prom.queue_monitor_time_spent_waiting.Increment(
std::chrono::duration_cast<std::chrono::microseconds>(t_after_sleep - t_after_work).count());
}
exit(0);
@@ -80,39 +94,31 @@ struct PreviousFailure : public std::exception {
bool State::getQueuedBuilds(Connection & conn,
ref<Store> destStore, unsigned int & lastBuildId)
ref<Store> destStore)
{
prom.queue_checks_started.Increment();
printInfo("checking the queue for builds > %d...", lastBuildId);
printInfo("checking the queue for builds...");
/* Grab the queued builds from the database, but don't process
them yet (since we don't want a long-running transaction). */
std::vector<BuildID> newIDs;
std::map<BuildID, Build::ptr> newBuildsByID;
std::unordered_map<BuildID, Build::ptr> newBuildsByID;
std::multimap<StorePath, BuildID> newBuildsByPath;
unsigned int newLastBuildId = lastBuildId;
{
pqxx::work txn(conn);
auto res = txn.exec_params
("select builds.id, builds.jobset_id, jobsets.project as project, "
auto res = txn.exec("select builds.id, builds.jobset_id, jobsets.project as project, "
"jobsets.name as jobset, job, drvPath, maxsilent, timeout, timestamp, "
"globalPriority, priority from Builds "
"inner join jobsets on builds.jobset_id = jobsets.id "
"where builds.id > $1 and finished = 0 order by globalPriority desc, builds.id",
lastBuildId);
"where finished = 0 order by globalPriority desc, random()");
for (auto const & row : res) {
auto builds_(builds.lock());
BuildID id = row["id"].as<BuildID>();
if (buildOne && id != buildOne) continue;
if (id > newLastBuildId) {
newLastBuildId = id;
prom.queue_max_id.Set(id);
}
if (builds_->count(id)) continue;
auto build = std::make_shared<Build>(
@@ -142,21 +148,20 @@ bool State::getQueuedBuilds(Connection & conn,
createBuild = [&](Build::ptr build) {
prom.queue_build_loads.Increment();
printMsg(lvlTalkative, format("loading build %1% (%2%)") % build->id % build->fullJobName());
printMsg(lvlTalkative, "loading build %1% (%2%)", build->id, build->fullJobName());
nrAdded++;
newBuildsByID.erase(build->id);
if (!localStore->isValidPath(build->drvPath)) {
/* Derivation has been GC'ed prematurely. */
printMsg(lvlError, format("aborting GC'ed build %1%") % build->id);
printError("aborting GC'ed build %1%", build->id);
if (!build->finishedInDB) {
auto mc = startDbUpdate();
pqxx::work txn(conn);
txn.exec_params0
("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $3 where id = $1 and finished = 0",
build->id,
txn.exec("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $3 where id = $1 and finished = 0",
pqxx::params{build->id,
(int) bsAborted,
time(0));
time(0)}).no_rows();
txn.commit();
build->finishedInDB = true;
nrBuildsDone++;
@@ -186,32 +191,33 @@ bool State::getQueuedBuilds(Connection & conn,
derivation path, then by output path. */
BuildID propagatedFrom = 0;
auto res = txn.exec_params1
("select max(build) from BuildSteps where drvPath = $1 and startTime != 0 and stopTime != 0 and status = 1",
localStore->printStorePath(ex.step->drvPath));
auto res = txn.exec("select max(build) from BuildSteps where drvPath = $1 and startTime != 0 and stopTime != 0 and status = 1",
pqxx::params{localStore->printStorePath(ex.step->drvPath)}).one_row();
if (!res[0].is_null()) propagatedFrom = res[0].as<BuildID>();
if (!propagatedFrom) {
for (auto & i : ex.step->drv->outputsAndOptPaths(*localStore)) {
if (i.second.second) {
auto res = txn.exec_params
("select max(s.build) from BuildSteps s join BuildStepOutputs o on s.build = o.build where path = $1 and startTime != 0 and stopTime != 0 and status = 1",
localStore->printStorePath(*i.second.second));
if (!res[0][0].is_null()) {
propagatedFrom = res[0][0].as<BuildID>();
break;
}
for (auto & [outputName, optOutputPath] : destStore->queryPartialDerivationOutputMap(ex.step->drvPath, &*localStore)) {
constexpr std::string_view common = "select max(s.build) from BuildSteps s join BuildStepOutputs o on s.build = o.build where startTime != 0 and stopTime != 0 and status = 1";
auto res = optOutputPath
? txn.exec(
std::string { common } + " and path = $1",
pqxx::params{localStore->printStorePath(*optOutputPath)})
: txn.exec(
std::string { common } + " and drvPath = $1 and name = $2",
pqxx::params{localStore->printStorePath(ex.step->drvPath), outputName});
if (!res[0][0].is_null()) {
propagatedFrom = res[0][0].as<BuildID>();
break;
}
}
}
createBuildStep(txn, 0, build->id, ex.step, "", bsCachedFailure, "", propagatedFrom);
txn.exec_params
("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $3, isCachedBuild = 1, notificationPendingSince = $3 "
txn.exec("update Builds set finished = 1, buildStatus = $2, startTime = $3, stopTime = $3, isCachedBuild = 1, notificationPendingSince = $3 "
"where id = $1 and finished = 0",
build->id,
pqxx::params{build->id,
(int) (ex.step->drvPath == build->drvPath ? bsFailed : bsDepFailed),
time(0));
time(0)}).no_rows();
notifyBuildFinished(txn, build->id, {});
txn.commit();
build->finishedInDB = true;
@@ -236,12 +242,10 @@ bool State::getQueuedBuilds(Connection & conn,
/* If we didn't get a step, it means the step's outputs are
all valid. So we mark this as a finished, cached build. */
if (!step) {
auto drv = localStore->readDerivation(build->drvPath);
BuildOutput res = getBuildOutputCached(conn, destStore, drv);
BuildOutput res = getBuildOutputCached(conn, destStore, build->drvPath);
for (auto & i : drv.outputsAndOptPaths(*localStore))
if (i.second.second)
addRoot(*i.second.second);
for (auto & i : destStore->queryDerivationOutputMap(build->drvPath, &*localStore))
addRoot(i.second);
{
auto mc = startDbUpdate();
@@ -292,7 +296,7 @@ bool State::getQueuedBuilds(Connection & conn,
try {
createBuild(build);
} catch (Error & e) {
e.addTrace({}, hintfmt("while loading build %d: ", build->id));
e.addTrace({}, HintFmt("while loading build %d: ", build->id));
throw;
}
@@ -302,7 +306,7 @@ bool State::getQueuedBuilds(Connection & conn,
/* Add the new runnable build steps to runnable and wake up
the builder threads. */
printMsg(lvlChatty, format("got %1% new runnable steps from %2% new builds") % newRunnable.size() % nrAdded);
printMsg(lvlChatty, "got %1% new runnable steps from %2% new builds", newRunnable.size(), nrAdded);
for (auto & r : newRunnable)
makeRunnable(r);
@@ -312,15 +316,13 @@ bool State::getQueuedBuilds(Connection & conn,
/* Stop after a certain time to allow priority bumps to be
processed. */
if (std::chrono::system_clock::now() > start + std::chrono::seconds(600)) {
if (std::chrono::system_clock::now() > start + std::chrono::seconds(60)) {
prom.queue_checks_early_exits.Increment();
break;
}
}
}
prom.queue_checks_finished.Increment();
lastBuildId = newBuildsByID.empty() ? newLastBuildId : newBuildsByID.begin()->first - 1;
return newBuildsByID.empty();
}
@@ -358,13 +360,13 @@ void State::processQueueChange(Connection & conn)
for (auto i = builds_->begin(); i != builds_->end(); ) {
auto b = currentIds.find(i->first);
if (b == currentIds.end()) {
printMsg(lvlInfo, format("discarding cancelled build %1%") % i->first);
printInfo("discarding cancelled build %1%", i->first);
i = builds_->erase(i);
// FIXME: ideally we would interrupt active build steps here.
continue;
}
if (i->second->globalPriority < b->second) {
printMsg(lvlInfo, format("priority of build %1% increased") % i->first);
printInfo("priority of build %1% increased", i->first);
i->second->globalPriority = b->second;
i->second->propagatePriorities();
}
@@ -399,6 +401,34 @@ void State::processQueueChange(Connection & conn)
}
std::map<DrvOutput, std::optional<StorePath>> State::getMissingRemotePaths(
ref<Store> destStore,
const std::map<DrvOutput, std::optional<StorePath>> & paths)
{
Sync<std::map<DrvOutput, std::optional<StorePath>>> missing_;
ThreadPool tp;
for (auto & [output, maybeOutputPath] : paths) {
if (!maybeOutputPath) {
auto missing(missing_.lock());
missing->insert({output, maybeOutputPath});
} else {
tp.enqueue([&] {
if (!destStore->isValidPath(*maybeOutputPath)) {
auto missing(missing_.lock());
missing->insert({output, maybeOutputPath});
}
});
}
}
tp.process();
auto missing(missing_.lock());
return *missing;
}
Step::ptr State::createStep(ref<Store> destStore,
Connection & conn, Build::ptr build, const StorePath & drvPath,
Build::ptr referringBuild, Step::ptr referringStep, std::set<StorePath> & finishedDrvs,
@@ -457,17 +487,24 @@ Step::ptr State::createStep(ref<Store> destStore,
it's not runnable yet, and other threads won't make it
runnable while step->created == false. */
step->drv = std::make_unique<Derivation>(localStore->readDerivation(drvPath));
step->parsedDrv = std::make_unique<ParsedDerivation>(drvPath, *step->drv);
{
try {
step->drvOptions = std::make_unique<DerivationOptions>(
DerivationOptions::fromStructuredAttrs(
step->drv->env,
step->drv->structuredAttrs ? &*step->drv->structuredAttrs : nullptr));
} catch (Error & e) {
e.addTrace({}, "while parsing derivation '%s'", localStore->printStorePath(drvPath));
throw;
}
}
step->preferLocalBuild = step->parsedDrv->willBuildLocally(*localStore);
step->preferLocalBuild = step->drvOptions->willBuildLocally(*localStore, *step->drv);
step->isDeterministic = getOr(step->drv->env, "isDetermistic", "0") == "1";
step->systemType = step->drv->platform;
{
auto i = step->drv->env.find("requiredSystemFeatures");
StringSet features;
if (i != step->drv->env.end())
features = step->requiredSystemFeatures = tokenizeString<std::set<std::string>>(i->second);
StringSet features = step->requiredSystemFeatures = step->drvOptions->getRequiredSystemFeatures(*step->drv);
if (step->preferLocalBuild)
features.insert("local");
if (!features.empty()) {
@@ -481,26 +518,40 @@ Step::ptr State::createStep(ref<Store> destStore,
throw PreviousFailure{step};
/* Are all outputs valid? */
bool valid = true;
DerivationOutputs missing;
for (auto & i : step->drv->outputs)
if (!destStore->isValidPath(*i.second.path(*localStore, step->drv->name, i.first))) {
valid = false;
missing.insert_or_assign(i.first, i.second);
}
auto outputHashes = staticOutputHashes(*localStore, *(step->drv));
std::map<DrvOutput, std::optional<StorePath>> paths;
for (auto & [outputName, maybeOutputPath] : destStore->queryPartialDerivationOutputMap(drvPath, &*localStore)) {
auto outputHash = outputHashes.at(outputName);
paths.insert({{outputHash, outputName}, maybeOutputPath});
}
auto missing = getMissingRemotePaths(destStore, paths);
bool valid = missing.empty();
/* Try to copy the missing paths from the local store or from
substitutes. */
if (!missing.empty()) {
size_t avail = 0;
for (auto & i : missing) {
auto path = i.second.path(*localStore, step->drv->name, i.first);
if (/* localStore != destStore && */ localStore->isValidPath(*path))
for (auto & [i, pathOpt] : missing) {
// If we don't know the output path from the destination
// store, see if the local store can tell us.
if (/* localStore != destStore && */ !pathOpt && experimentalFeatureSettings.isEnabled(Xp::CaDerivations))
if (auto maybeRealisation = localStore->queryRealisation(i))
pathOpt = maybeRealisation->outPath;
if (!pathOpt) {
// No hope of getting the store object if we don't know
// the path.
continue;
}
auto & path = *pathOpt;
if (/* localStore != destStore && */ localStore->isValidPath(path))
avail++;
else if (useSubstitutes) {
SubstitutablePathInfos infos;
localStore->querySubstitutablePathInfos({{*path, {}}}, infos);
localStore->querySubstitutablePathInfos({{path, {}}}, infos);
if (infos.size() == 1)
avail++;
}
@@ -508,26 +559,29 @@ Step::ptr State::createStep(ref<Store> destStore,
if (missing.size() == avail) {
valid = true;
for (auto & i : missing) {
auto path = i.second.path(*localStore, step->drv->name, i.first);
for (auto & [i, pathOpt] : missing) {
// If we found everything, then we should know the path
// to every missing store object now.
assert(pathOpt);
auto & path = *pathOpt;
try {
time_t startTime = time(0);
if (localStore->isValidPath(*path))
if (localStore->isValidPath(path))
printInfo("copying output %1% of %2% from local store",
localStore->printStorePath(*path),
localStore->printStorePath(path),
localStore->printStorePath(drvPath));
else {
printInfo("substituting output %1% of %2%",
localStore->printStorePath(*path),
localStore->printStorePath(path),
localStore->printStorePath(drvPath));
localStore->ensurePath(*path);
localStore->ensurePath(path);
// FIXME: should copy directly from substituter to destStore.
}
copyClosure(*localStore, *destStore,
StorePathSet { *path },
StorePathSet { path },
NoRepair, CheckSigs, NoSubstitute);
time_t stopTime = time(0);
@@ -535,13 +589,13 @@ Step::ptr State::createStep(ref<Store> destStore,
{
auto mc = startDbUpdate();
pqxx::work txn(conn);
createSubstitutionStep(txn, startTime, stopTime, build, drvPath, "out", *path);
createSubstitutionStep(txn, startTime, stopTime, build, drvPath, *(step->drv), "out", path);
txn.commit();
}
} catch (Error & e) {
printError("while copying/substituting output %s of %s: %s",
localStore->printStorePath(*path),
localStore->printStorePath(path),
localStore->printStorePath(drvPath),
e.what());
valid = false;
@@ -561,7 +615,7 @@ Step::ptr State::createStep(ref<Store> destStore,
printMsg(lvlDebug, "creating build step %1%", localStore->printStorePath(drvPath));
/* Create steps for the dependencies. */
for (auto & i : step->drv->inputDrvs) {
for (auto & i : step->drv->inputDrvs.map) {
auto dep = createStep(destStore, conn, build, i.first, 0, step, finishedDrvs, newSteps, newRunnable);
if (dep) {
auto step_(step->state.lock());
@@ -596,10 +650,8 @@ Jobset::ptr State::createJobset(pqxx::work & txn,
if (i != jobsets_->end()) return i->second;
}
auto res = txn.exec_params1
("select schedulingShares from Jobsets where id = $1",
jobsetID);
if (res.empty()) throw Error("missing jobset - can't happen");
auto res = txn.exec("select schedulingShares from Jobsets where id = $1",
pqxx::params{jobsetID}).one_row();
auto shares = res["schedulingShares"].as<unsigned int>();
@@ -607,11 +659,10 @@ Jobset::ptr State::createJobset(pqxx::work & txn,
jobset->setShares(shares);
/* Load the build steps from the last 24 hours. */
auto res2 = txn.exec_params
("select s.startTime, s.stopTime from BuildSteps s join Builds b on build = id "
auto res2 = txn.exec("select s.startTime, s.stopTime from BuildSteps s join Builds b on build = id "
"where s.startTime is not null and s.stopTime > $1 and jobset_id = $2",
time(0) - Jobset::schedulingWindow * 10,
jobsetID);
pqxx::params{time(0) - Jobset::schedulingWindow * 10,
jobsetID});
for (auto const & row : res2) {
time_t startTime = row["startTime"].as<time_t>();
time_t stopTime = row["stopTime"].as<time_t>();
@@ -640,21 +691,22 @@ void State::processJobsetSharesChange(Connection & conn)
}
BuildOutput State::getBuildOutputCached(Connection & conn, nix::ref<nix::Store> destStore, const nix::Derivation & drv)
BuildOutput State::getBuildOutputCached(Connection & conn, nix::ref<nix::Store> destStore, const nix::StorePath & drvPath)
{
auto derivationOutputs = destStore->queryDerivationOutputMap(drvPath, &*localStore);
{
pqxx::work txn(conn);
for (auto & [name, output] : drv.outputsAndOptPaths(*localStore)) {
auto r = txn.exec_params
("select id, buildStatus, releaseName, closureSize, size from Builds b "
for (auto & [name, output] : derivationOutputs) {
auto r = txn.exec("select id, buildStatus, releaseName, closureSize, size from Builds b "
"join BuildOutputs o on b.id = o.build "
"where finished = 1 and (buildStatus = 0 or buildStatus = 6) and path = $1",
localStore->printStorePath(*output.second));
pqxx::params{localStore->printStorePath(output)});
if (r.empty()) continue;
BuildID id = r[0][0].as<BuildID>();
printMsg(lvlInfo, format("reusing build %d") % id);
printInfo("reusing build %d", id);
BuildOutput res;
res.failed = r[0][1].as<int>() == bsFailedWithOutput;
@@ -662,9 +714,8 @@ BuildOutput State::getBuildOutputCached(Connection & conn, nix::ref<nix::Store>
res.closureSize = r[0][3].is_null() ? 0 : r[0][3].as<uint64_t>();
res.size = r[0][4].is_null() ? 0 : r[0][4].as<uint64_t>();
auto products = txn.exec_params
("select type, subtype, fileSize, sha256hash, path, name, defaultPath from BuildProducts where build = $1 order by productnr",
id);
auto products = txn.exec("select type, subtype, fileSize, sha256hash, path, name, defaultPath from BuildProducts where build = $1 order by productnr",
pqxx::params{id});
for (auto row : products) {
BuildProduct product;
@@ -677,7 +728,7 @@ BuildOutput State::getBuildOutputCached(Connection & conn, nix::ref<nix::Store>
product.fileSize = row[2].as<off_t>();
}
if (!row[3].is_null())
product.sha256hash = Hash::parseAny(row[3].as<std::string>(), htSHA256);
product.sha256hash = Hash::parseAny(row[3].as<std::string>(), HashAlgorithm::SHA256);
if (!row[4].is_null())
product.path = row[4].as<std::string>();
product.name = row[5].as<std::string>();
@@ -686,9 +737,8 @@ BuildOutput State::getBuildOutputCached(Connection & conn, nix::ref<nix::Store>
res.products.emplace_back(product);
}
auto metrics = txn.exec_params
("select name, unit, value from BuildMetrics where build = $1",
id);
auto metrics = txn.exec("select name, unit, value from BuildMetrics where build = $1",
pqxx::params{id});
for (auto row : metrics) {
BuildMetric metric;
@@ -704,5 +754,5 @@ BuildOutput State::getBuildOutputCached(Connection & conn, nix::ref<nix::Store>
}
NarMemberDatas narMembers;
return getBuildOutput(destStore, narMembers, drv);
return getBuildOutput(destStore, narMembers, derivationOutputs);
}

View File

@@ -7,6 +7,7 @@
#include <memory>
#include <queue>
#include <regex>
#include <semaphore>
#include <prometheus/counter.h>
#include <prometheus/gauge.h>
@@ -14,13 +15,19 @@
#include "db.hh"
#include "parsed-derivations.hh"
#include "pathlocks.hh"
#include "pool.hh"
#include "build-result.hh"
#include "store-api.hh"
#include "sync.hh"
#include <nix/store/derivations.hh>
#include <nix/store/derivation-options.hh>
#include <nix/store/pathlocks.hh>
#include <nix/util/pool.hh>
#include <nix/store/build-result.hh>
#include <nix/store/store-api.hh>
#include <nix/util/sync.hh>
#include "nar-extractor.hh"
#include <nix/store/serve-protocol.hh>
#include <nix/store/serve-protocol-impl.hh>
#include <nix/store/serve-protocol-connection.hh>
#include <nix/store/machines.hh>
#include <nix/store/globals.hh>
typedef unsigned int BuildID;
@@ -54,6 +61,7 @@ typedef enum {
ssConnecting = 10,
ssSendingInputs = 20,
ssBuilding = 30,
ssWaitingForLocalSlot = 35,
ssReceivingOutputs = 40,
ssPostProcessing = 50,
} StepState;
@@ -78,6 +86,8 @@ struct RemoteResult
{
return stepStatus == bsCachedFailure ? bsFailed : stepStatus;
}
void updateWithBuildResult(const nix::BuildResult &);
};
@@ -162,8 +172,8 @@ struct Step
nix::StorePath drvPath;
std::unique_ptr<nix::Derivation> drv;
std::unique_ptr<nix::ParsedDerivation> parsedDrv;
std::set<std::string> requiredSystemFeatures;
std::unique_ptr<nix::DerivationOptions> drvOptions;
nix::StringSet requiredSystemFeatures;
bool preferLocalBuild;
bool isDeterministic;
std::string systemType; // concatenation of drv.platform and requiredSystemFeatures
@@ -231,18 +241,10 @@ void getDependents(Step::ptr step, std::set<Build::ptr> & builds, std::set<Step:
void visitDependencies(std::function<void(Step::ptr)> visitor, Step::ptr step);
struct Machine
struct Machine : nix::Machine
{
typedef std::shared_ptr<Machine> ptr;
bool enabled{true};
std::string sshName, sshKey;
std::set<std::string> systemTypes, supportedFeatures, mandatoryFeatures;
unsigned int maxJobs = 1;
float speedFactor = 1.0;
std::string sshPublicHostKey;
struct State {
typedef std::shared_ptr<State> ptr;
counter currentJobs{0};
@@ -292,11 +294,13 @@ struct Machine
return true;
}
bool isLocalhost()
{
std::regex r("^(ssh://|ssh-ng://)?localhost$");
return std::regex_search(sshName, r);
}
bool isLocalhost() const;
// A connection to a machine
struct Connection : nix::ServeProto::BasicClientConnection {
// Backpointer to the machine
ptr machine;
};
};
@@ -350,9 +354,13 @@ private:
/* The build machines. */
std::mutex machinesReadyLock;
typedef std::map<std::string, Machine::ptr> Machines;
typedef std::map<nix::StoreReference::Variant, Machine::ptr> Machines;
nix::Sync<Machines> machines; // FIXME: use atomic_shared_ptr
/* Throttler for CPU-bound local work. */
static constexpr unsigned int maxSupportedLocalWorkers = 1024;
std::counting_semaphore<maxSupportedLocalWorkers> localWorkThrottler;
/* Various stats. */
time_t startedAt;
counter nrBuildsRead{0};
@@ -362,6 +370,7 @@ private:
counter nrStepsDone{0};
counter nrStepsBuilding{0};
counter nrStepsCopyingTo{0};
counter nrStepsWaitingForDownloadSlot{0};
counter nrStepsCopyingFrom{0};
counter nrStepsWaiting{0};
counter nrUnsupportedSteps{0};
@@ -392,7 +401,6 @@ private:
struct MachineReservation
{
typedef std::shared_ptr<MachineReservation> ptr;
State & state;
Step::ptr step;
Machine::ptr machine;
@@ -430,7 +438,7 @@ private:
/* How often the build steps of a jobset should be repeated in
order to detect non-determinism. */
std::map<std::pair<std::string, std::string>, unsigned int> jobsetRepeats;
std::map<std::pair<std::string, std::string>, size_t> jobsetRepeats;
bool uploadLogsToBinaryCache;
@@ -450,7 +458,12 @@ private:
prometheus::Counter& queue_steps_created;
prometheus::Counter& queue_checks_early_exits;
prometheus::Counter& queue_checks_finished;
prometheus::Gauge& queue_max_id;
prometheus::Counter& dispatcher_time_spent_running;
prometheus::Counter& dispatcher_time_spent_waiting;
prometheus::Counter& queue_monitor_time_spent_running;
prometheus::Counter& queue_monitor_time_spent_waiting;
PromMetrics();
};
@@ -485,23 +498,28 @@ private:
const std::string & machine);
int createSubstitutionStep(pqxx::work & txn, time_t startTime, time_t stopTime,
Build::ptr build, const nix::StorePath & drvPath, const std::string & outputName, const nix::StorePath & storePath);
Build::ptr build, const nix::StorePath & drvPath, const nix::Derivation drv, const std::string & outputName, const nix::StorePath & storePath);
void updateBuild(pqxx::work & txn, Build::ptr build, BuildStatus status);
void queueMonitor();
void queueMonitorLoop();
void queueMonitorLoop(Connection & conn);
/* Check the queue for new builds. */
bool getQueuedBuilds(Connection & conn,
nix::ref<nix::Store> destStore, unsigned int & lastBuildId);
bool getQueuedBuilds(Connection & conn, nix::ref<nix::Store> destStore);
/* Handle cancellation, deletion and priority bumps. */
void processQueueChange(Connection & conn);
BuildOutput getBuildOutputCached(Connection & conn, nix::ref<nix::Store> destStore,
const nix::Derivation & drv);
const nix::StorePath & drvPath);
/* Returns paths missing from the remote store. Paths are processed in
* parallel to work around the possible latency of remote stores. */
std::map<nix::DrvOutput, std::optional<nix::StorePath>> getMissingRemotePaths(
nix::ref<nix::Store> destStore,
const std::map<nix::DrvOutput, std::optional<nix::StorePath>> & paths);
Step::ptr createStep(nix::ref<nix::Store> store,
Connection & conn, Build::ptr build, const nix::StorePath & drvPath,
@@ -532,19 +550,19 @@ private:
void abortUnsupported();
void builder(MachineReservation::ptr reservation);
void builder(std::unique_ptr<MachineReservation> reservation);
/* Perform the given build step. Return true if the step is to be
retried. */
enum StepResult { sDone, sRetry, sMaybeCancelled };
StepResult doBuildStep(nix::ref<nix::Store> destStore,
MachineReservation::ptr reservation,
std::unique_ptr<MachineReservation> reservation,
std::shared_ptr<ActiveStep> activeStep);
void buildRemote(nix::ref<nix::Store> destStore,
std::unique_ptr<MachineReservation> reservation,
Machine::ptr machine, Step::ptr step,
unsigned int maxSilentTime, unsigned int buildTimeout,
unsigned int repeats,
const nix::ServeProto::BuildOptions & buildOptions,
RemoteResult & result, std::shared_ptr<ActiveStep> activeStep,
std::function<void(StepState)> updateStep,
NarMemberDatas & narMembers);

View File

@@ -4,7 +4,6 @@ use strict;
use warnings;
use base 'Hydra::Base::Controller::REST';
use List::SomeUtils qw(any);
use Nix::Store;
use Hydra::Helper::Nix;
use Hydra::Helper::CatalystUtils;
@@ -30,7 +29,7 @@ sub getChannelData {
my $outputs = {};
foreach my $output (@outputs) {
my $outPath = $output->get_column("outpath");
next if $checkValidity && !isValidPath($outPath);
next if $checkValidity && !$MACHINE_LOCAL_STORE->isValidPath($outPath);
$outputs->{$output->get_column("outname")} = $outPath;
push @storePaths, $outPath;
# Put the system type in the manifest (for top-level

View File

@@ -95,6 +95,7 @@ sub get_legacy_ldap_config {
"hydra_bump-to-front" => [ "bump-to-front" ],
"hydra_cancel-build" => [ "cancel-build" ],
"hydra_create-projects" => [ "create-projects" ],
"hydra_eval-jobset" => [ "eval-jobset" ],
"hydra_restart-jobs" => [ "restart-jobs" ],
},
};
@@ -159,6 +160,7 @@ sub valid_roles {
"bump-to-front",
"cancel-build",
"create-projects",
"eval-jobset",
"restart-jobs",
];
}

View File

@@ -12,6 +12,9 @@ use DateTime;
use Digest::SHA qw(sha256_hex);
use Text::Diff;
use IPC::Run qw(run);
use Digest::SHA qw(hmac_sha256_hex);
use String::Compare::ConstantTime qw(equals);
use IPC::Run3;
sub api : Chained('/') PathPart('api') CaptureArgs(0) {
@@ -216,8 +219,13 @@ sub scmdiff : Path('/api/scmdiff') Args(0) {
} elsif ($type eq "git") {
my $clonePath = getSCMCacheDir . "/git/" . sha256_hex($uri);
die if ! -d $clonePath;
$diff .= `(cd $clonePath; git log $rev1..$rev2)`;
$diff .= `(cd $clonePath; git diff $rev1..$rev2)`;
my ($stdout1, $stderr1);
run3(['git', '-C', $clonePath, 'log', "$rev1..$rev2"], \undef, \$stdout1, \$stderr1);
$diff .= $stdout1 if $? == 0;
my ($stdout2, $stderr2);
run3(['git', '-C', $clonePath, 'diff', "$rev1..$rev2"], \undef, \$stdout2, \$stderr2);
$diff .= $stdout2 if $? == 0;
}
$c->stash->{'plain'} = { data => (scalar $diff) || " " };
@@ -239,6 +247,8 @@ sub triggerJobset {
sub push : Chained('api') PathPart('push') Args(0) {
my ($self, $c) = @_;
requirePost($c);
$c->{stash}->{json}->{jobsetsTriggered} = [];
my $force = exists $c->request->query_params->{force};
@@ -246,19 +256,24 @@ sub push : Chained('api') PathPart('push') Args(0) {
foreach my $s (@jobsets) {
my ($p, $j) = parseJobsetName($s);
my $jobset = $c->model('DB::Jobsets')->find($p, $j);
requireEvalJobsetPrivileges($c, $jobset->project);
next unless defined $jobset && ($force || ($jobset->project->enabled && $jobset->enabled));
triggerJobset($self, $c, $jobset, $force);
}
my @repos = split /,/, ($c->request->query_params->{repos} // "");
foreach my $r (@repos) {
triggerJobset($self, $c, $_, $force) foreach $c->model('DB::Jobsets')->search(
my @jobsets = $c->model('DB::Jobsets')->search(
{ 'project.enabled' => 1, 'me.enabled' => 1 },
{
join => 'project',
where => \ [ 'exists (select 1 from JobsetInputAlts where project = me.project and jobset = me.name and value = ?)', [ 'value', $r ] ],
order_by => 'me.id DESC'
});
foreach my $jobset (@jobsets) {
requireEvalJobsetPrivileges($c, $jobset->project);
triggerJobset($self, $c, $jobset, $force)
}
}
$self->status_ok(
@@ -267,13 +282,84 @@ sub push : Chained('api') PathPart('push') Args(0) {
);
}
sub verifyWebhookSignature {
my ($c, $platform, $header_name, $signature_prefix) = @_;
# Get secrets from config
my $webhook_config = $c->config->{webhooks} // {};
my $platform_config = $webhook_config->{$platform} // {};
my $secrets = $platform_config->{secret};
# Normalize to array
$secrets = [] unless defined $secrets;
$secrets = [$secrets] unless ref($secrets) eq 'ARRAY';
# Trim whitespace from secrets
my @secrets = grep { defined && length } map { s/^\s+|\s+$//gr } @$secrets;
if (@secrets) {
my $signature = $c->request->header($header_name);
if (!$signature) {
$c->log->warn("Webhook authentication failed for $platform: Missing signature from IP " . $c->request->address);
$c->response->status(401);
$c->stash->{json} = { error => "Missing webhook signature" };
$c->forward('View::JSON');
return 0;
}
# Get the raw body content from the buffered PSGI input
# For JSON requests, Catalyst will have already read and buffered the body
my $input = $c->request->env->{'psgi.input'};
$input->seek(0, 0);
local $/;
my $payload = <$input>;
$input->seek(0, 0); # Reset for any other consumers
unless (defined $payload && length $payload) {
$c->log->warn("Webhook authentication failed for $platform: Empty request body from IP " . $c->request->address);
$c->response->status(400);
$c->stash->{json} = { error => "Empty request body" };
$c->forward('View::JSON');
return 0;
}
my $valid = 0;
for my $secret (@secrets) {
my $expected = $signature_prefix . hmac_sha256_hex($payload, $secret);
if (equals($signature, $expected)) {
$valid = 1;
last;
}
}
if (!$valid) {
$c->log->warn("Webhook authentication failed for $platform: Invalid signature from IP " . $c->request->address);
$c->response->status(401);
$c->stash->{json} = { error => "Invalid webhook signature" };
$c->forward('View::JSON');
return 0;
}
return 1;
} else {
$c->log->warn("Webhook authentication failed for $platform: Unable to validate signature from IP " . $c->request->address . " because no secrets are configured");
$c->response->status(401);
$c->stash->{json} = { error => "Invalid webhook signature" };
$c->forward('View::JSON');
return 0;
}
}
sub push_github : Chained('api') PathPart('push-github') Args(0) {
my ($self, $c) = @_;
$c->{stash}->{json}->{jobsetsTriggered} = [];
return unless verifyWebhookSignature($c, 'github', 'X-Hub-Signature-256', 'sha256=');
my $in = $c->request->{data};
my $owner = $in->{repository}->{owner}->{name} or die;
my $owner = ($in->{repository}->{owner}->{name} // $in->{repository}->{owner}->{login}) or die;
my $repo = $in->{repository}->{name} or die;
print STDERR "got push from GitHub repository $owner/$repo\n";
@@ -285,6 +371,26 @@ sub push_github : Chained('api') PathPart('push-github') Args(0) {
$c->response->body("");
}
sub push_gitea : Chained('api') PathPart('push-gitea') Args(0) {
my ($self, $c) = @_;
$c->{stash}->{json}->{jobsetsTriggered} = [];
# Note: Gitea doesn't use sha256= prefix
return unless verifyWebhookSignature($c, 'gitea', 'X-Gitea-Signature', '');
my $in = $c->request->{data};
my $url = $in->{repository}->{clone_url} or die;
$url =~ s/.git$//;
print STDERR "got push from Gitea repository $url\n";
triggerJobset($self, $c, $_, 0) foreach $c->model('DB::Jobsets')->search(
{ 'project.enabled' => 1, 'me.enabled' => 1 },
{ join => 'project'
, where => \ [ 'me.flake like ? or exists (select 1 from JobsetInputAlts where project = me.project and jobset = me.name and value like ?)', [ 'flake', "%$url%"], [ 'value', "%$url%" ] ]
});
$c->response->body("");
}
1;

View File

@@ -10,11 +10,12 @@ use File::Basename;
use File::LibMagic;
use File::stat;
use Data::Dump qw(dump);
use Nix::Store;
use Nix::Config;
use List::SomeUtils qw(all);
use Encode;
use JSON::PP;
use IPC::Run qw(run);
use IPC::Run3;
use WWW::Form::UrlEncoded::PP qw();
use feature 'state';
@@ -78,14 +79,16 @@ sub build_GET {
$c->stash->{template} = 'build.tt';
$c->stash->{isLocalStore} = isLocalStore();
# XXX: If the derivation is content-addressed then this will always return
# false because `$_->path` will be empty
$c->stash->{available} =
$c->stash->{isLocalStore}
? all { isValidPath($_->path) } $build->buildoutputs->all
? all { $_->path && $MACHINE_LOCAL_STORE->isValidPath($_->path) } $build->buildoutputs->all
: 1;
$c->stash->{drvAvailable} = isValidPath $build->drvpath;
$c->stash->{drvAvailable} = $MACHINE_LOCAL_STORE->isValidPath($build->drvpath);
if ($build->finished && $build->iscachedbuild) {
my $path = ($build->buildoutputs)[0]->path or die;
my $path = ($build->buildoutputs)[0]->path or undef;
my $cachedBuildStep = findBuildStepByOutPath($self, $c, $path);
if (defined $cachedBuildStep) {
$c->stash->{cachedBuild} = $cachedBuildStep->build;
@@ -139,7 +142,7 @@ sub view_nixlog : Chained('buildChain') PathPart('nixlog') {
$c->stash->{step} = $step;
my $drvPath = $step->drvpath;
my $log_uri = $c->uri_for($c->controller('Root')->action_for("log"), [basename($drvPath)]);
my $log_uri = $c->uri_for($c->controller('Root')->action_for("log"), [WWW::Form::UrlEncoded::PP::url_encode(basename($drvPath))]);
showLog($c, $mode, $log_uri);
}
@@ -148,7 +151,7 @@ sub view_log : Chained('buildChain') PathPart('log') {
my ($self, $c, $mode) = @_;
my $drvPath = $c->stash->{build}->drvpath;
my $log_uri = $c->uri_for($c->controller('Root')->action_for("log"), [basename($drvPath)]);
my $log_uri = $c->uri_for($c->controller('Root')->action_for("log"), [WWW::Form::UrlEncoded::PP::url_encode(basename($drvPath))]);
showLog($c, $mode, $log_uri);
}
@@ -209,7 +212,7 @@ sub checkPath {
sub serveFile {
my ($c, $path) = @_;
my $res = run(cmd => ["nix", "--experimental-features", "nix-command",
my $res = runCommand(cmd => ["nix", "--experimental-features", "nix-command",
"ls-store", "--store", getStoreUri(), "--json", "$path"]);
if ($res->{status}) {
@@ -233,14 +236,25 @@ sub serveFile {
}
elsif ($ls->{type} eq "regular") {
# Have the hosted data considered its own origin to avoid being a giant
# XSS hole.
$c->response->header('Content-Security-Policy' => 'sandbox allow-scripts');
$c->stash->{'plain'} = { data => grab(cmd => ["nix", "--experimental-features", "nix-command",
$c->stash->{'plain'} = { data => readIntoSocket(cmd => ["nix", "--experimental-features", "nix-command",
"store", "cat", "--store", getStoreUri(), "$path"]) };
# Detect MIME type.
state $magic = File::LibMagic->new(follow_symlinks => 1);
my $info = $magic->info_from_filename($path);
my $type = $info->{mime_with_encoding};
my $type = "text/plain";
if ($path =~ /.*\.(\S{1,})$/xms) {
my $ext = $1;
my $mimeTypes = MIME::Types->new(only_complete => 1);
my $t = $mimeTypes->mimeTypeOf($ext);
$type = ref $t ? $t->type : $t if $t;
} else {
state $magic = File::LibMagic->new(follow_symlinks => 1);
my $info = $magic->info_from_filename($path);
$type = $info->{mime_with_encoding};
}
$c->response->content_type($type);
$c->forward('Hydra::View::Plain');
}
@@ -298,7 +312,7 @@ sub output : Chained('buildChain') PathPart Args(1) {
error($c, "This build is not finished yet.") unless $build->finished;
my $output = $build->buildoutputs->find({name => $outputName});
notFound($c, "This build has no output named $outputName") unless defined $output;
gone($c, "Output is no longer available.") unless isValidPath $output->path;
gone($c, "Output is no longer available.") unless $MACHINE_LOCAL_STORE->isValidPath($output->path);
$c->response->header('Content-Disposition', "attachment; filename=\"build-${\$build->id}-${\$outputName}.nar.bz2\"");
$c->stash->{current_view} = 'NixNAR';
@@ -336,19 +350,21 @@ sub contents : Chained('buildChain') PathPart Args(1) {
notFound($c, "Product $path has disappeared.") unless -e $path;
# Sanitize $path to prevent shell injection attacks.
$path =~ /^\/[\/[A-Za-z0-9_\-\.=+:]+$/ or die "Filename contains illegal characters.\n";
# FIXME: don't use shell invocations below.
# FIXME: use nix store cat
my $res;
if ($product->type eq "nix-build" && -d $path) {
# FIXME: use nix ls-store -R --json
$res = `cd '$path' && find . -print0 | xargs -0 ls -ld --`;
error($c, "`ls -lR' error: $?") if $? != 0;
# We need to use a pipe between find and xargs, so we'll use IPC::Run
my $error;
# Run find with absolute path and post-process to get relative paths
my $success = run(['find', $path, '-print0'], '|', ['xargs', '-0', 'ls', '-ld', '--'], \$res, \$error);
error($c, "`find $path -print0 | xargs -0 ls -ld --' error: $error") unless $success;
# Strip the base path to show relative paths
my $escaped_path = quotemeta($path);
$res =~ s/^(.*\s)$escaped_path(\/|$)/$1.$2/mg;
#my $baseuri = $c->uri_for('/build', $c->stash->{build}->id, 'download', $product->productnr);
#$baseuri .= "/".$product->name if $product->name;
@@ -356,34 +372,59 @@ sub contents : Chained('buildChain') PathPart Args(1) {
}
elsif ($path =~ /\.rpm$/) {
$res = `rpm --query --info --package '$path'`;
error($c, "RPM error: $?") if $? != 0;
my ($stdout1, $stderr1);
run3(['rpm', '--query', '--info', '--package', $path], \undef, \$stdout1, \$stderr1);
error($c, "RPM error: $stderr1") if $? != 0;
$res = $stdout1;
$res .= "===\n";
$res .= `rpm --query --list --verbose --package '$path'`;
error($c, "RPM error: $?") if $? != 0;
my ($stdout2, $stderr2);
run3(['rpm', '--query', '--list', '--verbose', '--package', $path], \undef, \$stdout2, \$stderr2);
error($c, "RPM error: $stderr2") if $? != 0;
$res .= $stdout2;
}
elsif ($path =~ /\.deb$/) {
$res = `dpkg-deb --info '$path'`;
error($c, "`dpkg-deb' error: $?") if $? != 0;
my ($stdout1, $stderr1);
run3(['dpkg-deb', '--info', $path], \undef, \$stdout1, \$stderr1);
error($c, "`dpkg-deb' error: $stderr1") if $? != 0;
$res = $stdout1;
$res .= "===\n";
$res .= `dpkg-deb --contents '$path'`;
error($c, "`dpkg-deb' error: $?") if $? != 0;
my ($stdout2, $stderr2);
run3(['dpkg-deb', '--contents', $path], \undef, \$stdout2, \$stderr2);
error($c, "`dpkg-deb' error: $stderr2") if $? != 0;
$res .= $stdout2;
}
elsif ($path =~ /\.(tar(\.gz|\.bz2|\.xz|\.lzma)?|tgz)$/ ) {
$res = `tar tvfa '$path'`;
error($c, "`tar' error: $?") if $? != 0;
my ($stdout, $stderr);
run3(['tar', 'tvfa', $path], \undef, \$stdout, \$stderr);
error($c, "`tar' error: $stderr") if $? != 0;
$res = $stdout;
}
elsif ($path =~ /\.(zip|jar)$/ ) {
$res = `unzip -v '$path'`;
error($c, "`unzip' error: $?") if $? != 0;
my ($stdout, $stderr);
run3(['unzip', '-v', $path], \undef, \$stdout, \$stderr);
error($c, "`unzip' error: $stderr") if $? != 0;
$res = $stdout;
}
elsif ($path =~ /\.iso$/ ) {
$res = `isoinfo -d -i '$path' && isoinfo -l -R -i '$path'`;
error($c, "`isoinfo' error: $?") if $? != 0;
# Run first isoinfo command
my ($stdout1, $stderr1);
run3(['isoinfo', '-d', '-i', $path], \undef, \$stdout1, \$stderr1);
error($c, "`isoinfo' error: $stderr1") if $? != 0;
$res = $stdout1;
# Run second isoinfo command
my ($stdout2, $stderr2);
run3(['isoinfo', '-l', '-R', '-i', $path], \undef, \$stdout2, \$stderr2);
error($c, "`isoinfo' error: $stderr2") if $? != 0;
$res .= $stdout2;
}
else {
@@ -415,7 +456,7 @@ sub getDependencyGraph {
};
$$done{$path} = $node;
my @refs;
foreach my $ref (queryReferences($path)) {
foreach my $ref ($MACHINE_LOCAL_STORE->queryReferences($path)) {
next if $ref eq $path;
next unless $runtime || $ref =~ /\.drv$/;
getDependencyGraph($self, $c, $runtime, $done, $ref);
@@ -423,7 +464,7 @@ sub getDependencyGraph {
}
# Show in reverse topological order to flatten the graph.
# Should probably do a proper BFS.
my @sorted = reverse topoSortPaths(@refs);
my @sorted = reverse $MACHINE_LOCAL_STORE->topoSortPaths(@refs);
$node->{refs} = [map { $$done{$_} } @sorted];
}
@@ -436,7 +477,7 @@ sub build_deps : Chained('buildChain') PathPart('build-deps') {
my $build = $c->stash->{build};
my $drvPath = $build->drvpath;
error($c, "Derivation no longer available.") unless isValidPath $drvPath;
error($c, "Derivation no longer available.") unless $MACHINE_LOCAL_STORE->isValidPath($drvPath);
$c->stash->{buildTimeGraph} = getDependencyGraph($self, $c, 0, {}, $drvPath);
@@ -451,7 +492,7 @@ sub runtime_deps : Chained('buildChain') PathPart('runtime-deps') {
requireLocalStore($c);
error($c, "Build outputs no longer available.") unless all { isValidPath($_) } @outPaths;
error($c, "Build outputs no longer available.") unless all { $MACHINE_LOCAL_STORE->isValidPath($_) } @outPaths;
my $done = {};
$c->stash->{runtimeGraph} = [ map { getDependencyGraph($self, $c, 1, $done, $_) } @outPaths ];
@@ -471,7 +512,7 @@ sub nix : Chained('buildChain') PathPart('nix') CaptureArgs(0) {
if (isLocalStore) {
foreach my $out ($build->buildoutputs) {
notFound($c, "Path " . $out->path . " is no longer available.")
unless isValidPath($out->path);
unless $MACHINE_LOCAL_STORE->isValidPath($out->path);
}
}

View File

@@ -364,6 +364,21 @@ sub evals_GET {
);
}
sub errors :Chained('jobsetChain') :PathPart('errors') :Args(0) :ActionClass('REST') { }
sub errors_GET {
my ($self, $c) = @_;
$c->stash->{template} = 'eval-error.tt';
my $jobsetName = $c->stash->{params}->{name};
$c->stash->{jobset} = $c->stash->{project}->jobsets->find(
{ name => $jobsetName },
{ '+columns' => { 'errormsg' => 'errormsg' } }
);
$self->status_ok($c, entity => $c->stash->{jobset});
}
# Redirect to the latest finished evaluation of this jobset.
sub latest_eval : Chained('jobsetChain') PathPart('latest-eval') {

View File

@@ -76,7 +76,9 @@ sub view_GET {
$c->stash->{removed} = $diff->{removed};
$c->stash->{unfinished} = $diff->{unfinished};
$c->stash->{aborted} = $diff->{aborted};
$c->stash->{failed} = $diff->{failed};
$c->stash->{totalAborted} = $diff->{totalAborted};
$c->stash->{totalFailed} = $diff->{totalFailed};
$c->stash->{totalQueued} = $diff->{totalQueued};
$c->stash->{full} = ($c->req->params->{full} || "0") eq "1";
@@ -86,6 +88,17 @@ sub view_GET {
);
}
sub errors :Chained('evalChain') :PathPart('errors') :Args(0) :ActionClass('REST') { }
sub errors_GET {
my ($self, $c) = @_;
$c->stash->{template} = 'eval-error.tt';
$c->stash->{eval} = $c->model('DB::JobsetEvals')->find($c->stash->{eval}->id, { prefetch => 'evaluationerror' });
$self->status_ok($c, entity => $c->stash->{eval});
}
sub create_jobset : Chained('evalChain') PathPart('create-jobset') Args(0) {
my ($self, $c) = @_;

View File

@@ -9,15 +9,21 @@ use Hydra::Helper::CatalystUtils;
use Hydra::View::TT;
use Nix::Store;
use Nix::Config;
use Number::Bytes::Human qw(format_bytes);
use Encode;
use File::Basename;
use JSON::MaybeXS;
use HTML::Entities;
use IPC::Run3;
use List::Util qw[min max];
use List::SomeUtils qw{any};
use Net::Prometheus;
use Types::Standard qw/StrMatch/;
use WWW::Form::UrlEncoded::PP qw();
use constant NARINFO_REGEX => qr{^([a-z0-9]{32})\.narinfo$};
# e.g.: https://hydra.example.com/realisations/sha256:a62128132508a3a32eef651d6467695944763602f226ac630543e947d9feb140!out.doi
use constant REALISATIONS_REGEX => qr{^(sha256:[a-z0-9]{64}![a-z]+)\.doi$};
# Put this controller at top-level.
__PACKAGE__->config->{namespace} = '';
@@ -32,6 +38,7 @@ sub noLoginNeeded {
return $whitelisted ||
$c->request->path eq "api/push-github" ||
$c->request->path eq "api/push-gitea" ||
$c->request->path eq "google-login" ||
$c->request->path eq "github-redirect" ||
$c->request->path eq "github-login" ||
@@ -47,11 +54,13 @@ sub begin :Private {
$c->stash->{curUri} = $c->request->uri;
$c->stash->{version} = $ENV{"HYDRA_RELEASE"} || "<devel>";
$c->stash->{nixVersion} = $ENV{"NIX_RELEASE"} || "<devel>";
$c->stash->{nixEvalJobsVersion} = $ENV{"NIX_EVAL_JOBS_RELEASE"} || "<devel>";
$c->stash->{curTime} = time;
$c->stash->{logo} = defined $c->config->{hydra_logo} ? "/logo" : "";
$c->stash->{tracker} = defined $c->config->{tracker} ? $c->config->{tracker} : "";
$c->stash->{flashMsg} = $c->flash->{flashMsg};
$c->stash->{successMsg} = $c->flash->{successMsg};
$c->stash->{localStore} = isLocalStore;
$c->stash->{isPrivateHydra} = $c->config->{private} // "0" ne "0";
@@ -77,7 +86,7 @@ sub begin :Private {
$_->supportedInputTypes($c->stash->{inputTypes}) foreach @{$c->hydra_plugins};
# XSRF protection: require POST requests to have the same origin.
if ($c->req->method eq "POST" && $c->req->path ne "api/push-github") {
if ($c->req->method eq "POST" && $c->req->path ne "api/push-github" && $c->req->path ne "api/push-gitea") {
my $referer = $c->req->header('Referer');
$referer //= $c->req->header('Origin');
my $base = $c->req->base;
@@ -157,7 +166,7 @@ sub status_GET {
{ "buildsteps.busy" => { '!=', 0 } },
{ order_by => ["globalpriority DESC", "id"],
join => "buildsteps",
columns => [@buildListColumns]
columns => [@buildListColumns, 'buildsteps.drvpath', 'buildsteps.type']
})]
);
}
@@ -169,8 +178,14 @@ sub queue_runner_status_GET {
my ($self, $c) = @_;
#my $status = from_json($c->model('DB::SystemStatus')->find('queue-runner')->status);
my $status = decode_json(`hydra-queue-runner --status`);
if ($?) { $status->{status} = "unknown"; }
my ($stdout, $stderr);
run3(['hydra-queue-runner', '--status'], \undef, \$stdout, \$stderr);
my $status;
if ($? != 0) {
$status = { status => "unknown" };
} else {
$status = decode_json($stdout);
}
my $json = JSON->new->pretty()->canonical();
$c->stash->{template} = 'queue-runner-status.tt';
@@ -183,8 +198,10 @@ sub machines :Local Args(0) {
my ($self, $c) = @_;
my $machines = getMachines;
# Add entry for localhost.
$machines->{''} //= {};
# Add entry for localhost. The implicit addition is not needed with queue runner v2
if (not $c->config->{'queue_runner_endpoint'}) {
$machines->{''} //= {};
}
delete $machines->{'localhost'};
my $status = $c->model('DB::SystemStatus')->find("queue-runner");
@@ -192,9 +209,11 @@ sub machines :Local Args(0) {
my $ms = decode_json($status->status)->{"machines"};
foreach my $name (keys %{$ms}) {
$name = "" if $name eq "localhost";
$machines->{$name} //= {disabled => 1};
$machines->{$name}->{nrStepsDone} = $ms->{$name}->{nrStepsDone};
$machines->{$name}->{avgStepBuildTime} = $ms->{$name}->{avgStepBuildTime} // 0;
my $outName = $name;
$outName = "" if $name eq "ssh://localhost";
$machines->{$outName} //= {disabled => 1};
$machines->{$outName}->{nrStepsDone} = $ms->{$name}->{nrStepsDone};
$machines->{$outName}->{avgStepBuildTime} = $ms->{$name}->{avgStepBuildTime} // 0;
}
}
@@ -207,6 +226,19 @@ sub machines :Local Args(0) {
"where busy != 0 order by machine, stepnr",
{ Slice => {} });
$c->stash->{template} = 'machine-status.tt';
$c->stash->{human_bytes} = sub {
my ($bytes) = @_;
return format_bytes($bytes, si => 1);
};
$c->stash->{pretty_load} = sub {
my ($load) = @_;
return sprintf('%.2f', $load);
};
$c->stash->{pretty_percent} = sub {
my ($percent) = @_;
my $ret = sprintf('%.2f', $percent);
return ('&nbsp;' x (6 - length($ret))) . encode_entities($ret);
};
$self->status_ok($c, entity => $c->stash->{machines});
}
@@ -326,7 +358,7 @@ sub nar :Local :Args(1) {
else {
$path = $Nix::Config::storeDir . "/$path";
gone($c, "Path " . $path . " is no longer available.") unless isValidPath($path);
gone($c, "Path " . $path . " is no longer available.") unless $MACHINE_LOCAL_STORE->isValidPath($path);
$c->stash->{current_view} = 'NixNAR';
$c->stash->{storePath} = $path;
@@ -355,6 +387,33 @@ sub nix_cache_info :Path('nix-cache-info') :Args(0) {
}
sub realisations :Path('realisations') :Args(StrMatch[REALISATIONS_REGEX]) {
my ($self, $c, $realisation) = @_;
if (!isLocalStore) {
notFound($c, "There is no binary cache here.");
}
else {
my ($rawDrvOutput) = $realisation =~ REALISATIONS_REGEX;
my $rawRealisation = $MACHINE_LOCAL_STORE->queryRawRealisation($rawDrvOutput);
if (!$rawRealisation) {
$c->response->status(404);
$c->response->content_type('text/plain');
$c->stash->{plain}->{data} = "does not exist\n";
$c->forward('Hydra::View::Plain');
setCacheHeaders($c, 60 * 60);
return;
}
$c->response->content_type('text/plain');
$c->stash->{plain}->{data} = $rawRealisation;
$c->forward('Hydra::View::Plain');
}
}
sub narinfo :Path :Args(StrMatch[NARINFO_REGEX]) {
my ($self, $c, $narinfo) = @_;
@@ -366,7 +425,7 @@ sub narinfo :Path :Args(StrMatch[NARINFO_REGEX]) {
my ($hash) = $narinfo =~ NARINFO_REGEX;
die("Hash length was not 32") if length($hash) != 32;
my $path = queryPathFromHashPart($hash);
my $path = $MACHINE_LOCAL_STORE->queryPathFromHashPart($hash);
if (!$path) {
$c->response->status(404);
@@ -524,7 +583,7 @@ sub log :Local :Args(1) {
my $logPrefix = $c->config->{log_prefix};
if (defined $logPrefix) {
$c->res->redirect($logPrefix . "log/" . basename($drvPath));
$c->res->redirect($logPrefix . "log/" . WWW::Form::UrlEncoded::PP::url_encode(basename($drvPath)));
} else {
notFound($c, "The build log of $drvPath is not available.");
}

View File

@@ -463,7 +463,7 @@ sub my_jobs_tab :Chained('dashboard_base') :PathPart('my-jobs-tab') :Args(0) {
, "jobset.enabled" => 1
},
{ order_by => ["project", "jobset", "job"]
, join => ["project", "jobset"]
, join => {"jobset" => "project"}
})];
}

View File

@@ -32,12 +32,26 @@ sub buildDiff {
removed => [],
unfinished => [],
aborted => [],
failed => [],
# These summary counters cut across the categories to determine whether
# actions such as "Restart all failed" or "Bump queue" are available.
totalAborted => 0,
totalFailed => 0,
totalQueued => 0,
};
my $n = 0;
foreach my $build (@{$builds}) {
my $aborted = $build->finished != 0 && ($build->buildstatus == 3 || $build->buildstatus == 4);
my $aborted = $build->finished != 0 && (
# aborted
$build->buildstatus == 3
# cancelled
|| $build->buildstatus == 4
# timeout
|| $build->buildstatus == 7
# log limit exceeded
|| $build->buildstatus == 10
);
my $d;
my $found = 0;
while ($n < scalar(@{$builds2})) {
@@ -71,12 +85,19 @@ sub buildDiff {
} else {
push @{$ret->{new}}, $build if !$found;
}
if (defined $build->buildstatus && $build->buildstatus != 0) {
push @{$ret->{failed}}, $build;
if ($build->finished != 0 && $build->buildstatus != 0) {
if ($aborted) {
++$ret->{totalAborted};
} else {
++$ret->{totalFailed};
}
} elsif ($build->finished == 0) {
++$ret->{totalQueued};
}
}
return $ret;
}
1;
1;

View File

@@ -15,6 +15,7 @@ our @EXPORT = qw(
forceLogin requireUser requireProjectOwner requireRestartPrivileges requireAdmin requirePost isAdmin isProjectOwner
requireBumpPrivileges
requireCancelBuildPrivileges
requireEvalJobsetPrivileges
trim
getLatestFinishedEval getFirstEval
paramToList
@@ -186,6 +187,27 @@ sub isProjectOwner {
defined $c->model('DB::ProjectMembers')->find({ project => $project, userName => $c->user->username }));
}
sub hasEvalJobsetRole {
my ($c) = @_;
return $c->user_exists && $c->check_user_roles("eval-jobset");
}
sub mayEvalJobset {
my ($c, $project) = @_;
return
$c->user_exists &&
(isAdmin($c) ||
hasEvalJobsetRole($c) ||
isProjectOwner($c, $project));
}
sub requireEvalJobsetPrivileges {
my ($c, $project) = @_;
requireUser($c);
accessDenied($c, "Only the project members, administrators, and accounts with eval-jobset privileges can perform this operation.")
unless mayEvalJobset($c, $project);
}
sub hasCancelBuildRole {
my ($c) = @_;
return $c->user_exists && $c->check_user_roles('cancel-build');
@@ -272,7 +294,7 @@ sub requireAdmin {
sub requirePost {
my ($c) = @_;
error($c, "Request must be POSTed.") if $c->request->method ne "POST";
error($c, "Request must be POSTed.", 405) if $c->request->method ne "POST";
}

View File

@@ -12,10 +12,14 @@ use Nix::Store;
use Encode;
use Sys::Hostname::Long;
use IPC::Run;
use IPC::Run3;
use LWP::UserAgent;
use JSON::MaybeXS;
use UUID4::Tiny qw(is_uuid4_string);
our @ISA = qw(Exporter);
our @EXPORT = qw(
addToStore
cancelBuilds
constructRunCommandLogPath
findLog
@@ -36,12 +40,16 @@ our @EXPORT = qw(
jobsetOverview
jobsetOverview_
pathIsInsidePrefix
readIntoSocket
readNixFile
registerRoot
restartBuilds
run
runCommand
$MACHINE_LOCAL_STORE
);
our $MACHINE_LOCAL_STORE = Nix::Store->new();
sub getHydraHome {
my $dir = $ENV{"HYDRA_HOME"} or die "The HYDRA_HOME directory does not exist!\n";
@@ -171,6 +179,9 @@ sub getDrvLogPath {
for ($fn . $bucketed, $fn . $bucketed . ".bz2") {
return $_ if -f $_;
}
for ($fn . $bucketed, $fn . $bucketed . ".zst") {
return $_ if -f $_;
}
return undef;
}
@@ -187,6 +198,10 @@ sub findLog {
return undef if scalar @outPaths == 0;
# Filter out any NULLs. Content-addressed derivations
# that haven't built yet or failed to build may have a NULL outPath.
@outPaths = grep {defined} @outPaths;
my @steps = $c->model('DB::BuildSteps')->search(
{ path => { -in => [@outPaths] } },
{ select => ["drvpath"]
@@ -286,8 +301,7 @@ sub getEvals {
my @evals = $evals_result_set->search(
{ hasnewbuilds => 1 },
{ order_by => "$me.id DESC", rows => $rows, offset => $offset
, prefetch => { evaluationerror => [ ] } });
{ order_by => "$me.id DESC", rows => $rows, offset => $offset });
my @res = ();
my $cache = {};
@@ -330,37 +344,68 @@ sub getEvals {
sub getMachines {
my %machines = ();
my $config = getHydraConfig();
my @machinesFiles = split /:/, ($ENV{"NIX_REMOTE_SYSTEMS"} || "/etc/nix/machines");
if ($config->{'queue_runner_endpoint'}) {
my $ua = LWP::UserAgent->new();
my $resp = $ua->get($config->{'queue_runner_endpoint'} . "/status/machines");
if (not $resp->is_success) {
print STDERR "Unable to ask queue runner for machines\n";
return \%machines;
}
for my $machinesFile (@machinesFiles) {
next unless -e $machinesFile;
open(my $conf, "<", $machinesFile) or die;
while (my $line = <$conf>) {
chomp($line);
$line =~ s/\#.*$//g;
next if $line =~ /^\s*$/;
my @tokens = split /\s+/, $line;
my $data = decode_json($resp->decoded_content) or return \%machines;
my $machinesData = $data->{machines};
if (!defined($tokens[5]) || $tokens[5] eq "-") {
$tokens[5] = "";
}
my @supportedFeatures = split(/,/, $tokens[5] || "");
if (!defined($tokens[6]) || $tokens[6] eq "-") {
$tokens[6] = "";
}
my @mandatoryFeatures = split(/,/, $tokens[6] || "");
$machines{$tokens[0]} =
{ systemTypes => [ split(/,/, $tokens[1]) ]
, sshKeys => $tokens[2]
, maxJobs => int($tokens[3])
, speedFactor => 1.0 * (defined $tokens[4] ? int($tokens[4]) : 1)
, supportedFeatures => [ @supportedFeatures, @mandatoryFeatures ]
, mandatoryFeatures => [ @mandatoryFeatures ]
foreach my $machineName (keys %$machinesData) {
my $machine = %$machinesData{$machineName};
$machines{$machineName} =
{ systemTypes => $machine->{systems}
, maxJobs => $machine->{maxJobs}
, speedFactor => $machine->{speedFactor}
, supportedFeatures => [ @{$machine->{supportedFeatures}}, @{$machine->{mandatoryFeatures}} ]
, mandatoryFeatures => [ @{$machine->{mandatoryFeatures}} ]
# New fields for the machine status
, primarySystemType => $machine->{systems}[0]
, hasCapacity => $machine->{hasCapacity}
, hasDynamicCapacity => $machine->{hasDynamicCapacity}
, hasStaticCapacity => $machine->{hasStaticCapacity}
, score => $machine->{score}
, stats => $machine->{stats}
, memTotal => $machine->{totalMem}
};
}
close $conf;
} else {
my @machinesFiles = split /:/, ($ENV{"NIX_REMOTE_SYSTEMS"} || "/etc/nix/machines");
for my $machinesFile (@machinesFiles) {
next unless -e $machinesFile;
open(my $conf, "<", $machinesFile) or die;
while (my $line = <$conf>) {
chomp($line);
$line =~ s/\#.*$//g;
next if $line =~ /^\s*$/;
my @tokens = split /\s+/, $line;
if (!defined($tokens[5]) || $tokens[5] eq "-") {
$tokens[5] = "";
}
my @supportedFeatures = split(/,/, $tokens[5] || "");
if (!defined($tokens[6]) || $tokens[6] eq "-") {
$tokens[6] = "";
}
my @mandatoryFeatures = split(/,/, $tokens[6] || "");
$machines{$tokens[0]} =
{ systemTypes => [ split(/,/, $tokens[1]) ]
, maxJobs => int($tokens[3])
, speedFactor => 1.0 * (defined $tokens[4] ? int($tokens[4]) : 1)
, supportedFeatures => [ @supportedFeatures, @mandatoryFeatures ]
, mandatoryFeatures => [ @mandatoryFeatures ]
};
}
close $conf;
}
}
return \%machines;
@@ -407,11 +452,21 @@ sub pathIsInsidePrefix {
return $cur;
}
sub readIntoSocket{
my (%args) = @_;
my $sock;
eval {
open($sock, "-|", @{$args{cmd}}) or die q(failed to open socket from command:\n $x);
};
return $sock;
}
sub run {
sub runCommand {
my (%args) = @_;
my $res = { stdout => "", stderr => "" };
my $stdin = "";
@@ -451,7 +506,7 @@ sub run {
sub grab {
my (%args) = @_;
my $res = run(%args, grabStderr => 0);
my $res = runCommand(%args, grabStderr => 0);
if ($res->{status}) {
my $msgloc = "(in an indeterminate location)";
if (defined $args{dir}) {
@@ -494,7 +549,7 @@ sub restartBuilds {
$builds = $builds->search({ finished => 1 });
foreach my $build ($builds->search({}, { columns => ["drvpath"] })) {
next if !isValidPath($build->drvpath);
next if !$MACHINE_LOCAL_STORE->isValidPath($build->drvpath);
registerRoot $build->drvpath;
}
@@ -561,4 +616,14 @@ sub constructRunCommandLogPath {
return "$hydra_path/runcommand-logs/$bucket/$uuid";
}
sub addToStore {
my ($path) = @_;
my ($stdout, $stderr);
run3(['nix-store', '--add', $path], \undef, \$stdout, \$stderr);
die "cannot add path $path to the Nix store: $stderr\n" if $? != 0;
return trim($stdout);
}
1;

View File

@@ -7,7 +7,6 @@ use Digest::SHA qw(sha256_hex);
use File::Path;
use Hydra::Helper::Exec;
use Hydra::Helper::Nix;
use Nix::Store;
sub supportedInputTypes {
my ($self, $inputTypes) = @_;
@@ -38,9 +37,9 @@ sub fetchInput {
(my $cachedInput) = $self->{db}->resultset('CachedBazaarInputs')->search(
{uri => $uri, revision => $revision});
addTempRoot($cachedInput->storepath) if defined $cachedInput;
$MACHINE_LOCAL_STORE->addTempRoot($cachedInput->storepath) if defined $cachedInput;
if (defined $cachedInput && isValidPath($cachedInput->storepath)) {
if (defined $cachedInput && $MACHINE_LOCAL_STORE->isValidPath($cachedInput->storepath)) {
$storePath = $cachedInput->storepath;
$sha256 = $cachedInput->sha256hash;
} else {
@@ -58,7 +57,7 @@ sub fetchInput {
($sha256, $storePath) = split ' ', $stdout;
# FIXME: time window between nix-prefetch-bzr and addTempRoot.
addTempRoot($storePath);
$MACHINE_LOCAL_STORE->addTempRoot($storePath);
$self->{db}->txn_do(sub {
$self->{db}->resultset('CachedBazaarInputs')->create(

View File

@@ -7,6 +7,7 @@ use HTTP::Request;
use LWP::UserAgent;
use JSON::MaybeXS;
use Hydra::Helper::CatalystUtils;
use Hydra::Helper::Nix;
use File::Temp;
use POSIX qw(strftime);
@@ -43,14 +44,11 @@ sub fetchInput {
my $ua = LWP::UserAgent->new();
_iterate("https://api.bitbucket.com/2.0/repositories/$owner/$repo/pullrequests?state=OPEN", $auth, \%pulls, $ua);
my $tempdir = File::Temp->newdir("bitbucket-pulls" . "XXXXX", TMPDIR => 1);
my $filename = "$tempdir/bitbucket-pulls.json";
my $filename = "$tempdir/bitbucket-pulls-sorted.json";
open(my $fh, ">", $filename) or die "Cannot open $filename for writing: $!";
print $fh encode_json \%pulls;
print $fh JSON::MaybeXS->new(canonical => 1, pretty => 1)->encode(\%pulls);
close $fh;
system("jq -S . < $filename > $tempdir/bitbucket-pulls-sorted.json");
my $storePath = trim(`nix-store --add "$tempdir/bitbucket-pulls-sorted.json"`
or die "cannot copy path $filename to the Nix store.\n");
chomp $storePath;
my $storePath = addToStore($filename);
my $timestamp = time;
return { storePath => $storePath, revision => strftime "%Y%m%d%H%M%S", gmtime($timestamp) };
}

View File

@@ -9,11 +9,24 @@ use Hydra::Helper::CatalystUtils;
sub stepFinished {
my ($self, $step, $logPath) = @_;
my $doCompress = $self->{config}->{'compress_build_logs'} // "1";
my $doCompress = $self->{config}->{'compress_build_logs'} // '1';
my $silent = $self->{config}->{'compress_build_logs_silent'} // '0';
my $compression = $self->{config}->{'compress_build_logs_compression'} // 'bzip2';
if ($doCompress eq "1" && -e $logPath) {
print STDERR "compressing $logPath...\n";
system("bzip2", "--force", $logPath);
if (not -e $logPath or $doCompress ne "1") {
return;
}
if ($silent ne '1') {
print STDERR "compressing '$logPath' with $compression...\n";
}
if ($compression eq 'bzip2') {
system('bzip2', '--force', $logPath);
} elsif ($compression eq 'zstd') {
system('zstd', '--rm', '--quiet', '-T0', $logPath);
} else {
print STDERR "unknown compression type '$compression'\n";
}
}

View File

@@ -7,7 +7,7 @@ use Digest::SHA qw(sha256_hex);
use File::Path;
use Hydra::Helper::Exec;
use Hydra::Helper::Nix;
use Nix::Store;
use IPC::Run3;
sub supportedInputTypes {
my ($self, $inputTypes) = @_;
@@ -32,7 +32,7 @@ sub fetchInput {
my $stdout = ""; my $stderr = ""; my $res;
if (! -d $clonePath) {
# Clone the repository.
$res = run(timeout => 600,
$res = runCommand(timeout => 600,
cmd => ["darcs", "get", "--lazy", $uri, $clonePath],
dir => $ENV{"TMPDIR"});
die "Error getting darcs repo at `$uri':\n$stderr" if $res->{status};
@@ -58,7 +58,7 @@ sub fetchInput {
{uri => $uri, revision => $revision},
{rows => 1});
if (defined $cachedInput && isValidPath($cachedInput->storepath)) {
if (defined $cachedInput && $MACHINE_LOCAL_STORE->isValidPath($cachedInput->storepath)) {
$storePath = $cachedInput->storepath;
$sha256 = $cachedInput->sha256hash;
$revision = $cachedInput->revision;
@@ -71,12 +71,15 @@ sub fetchInput {
(system "darcs", "get", "--lazy", $clonePath, "$tmpDir/export", "--quiet",
"--to-match", "hash $revision") == 0
or die "darcs export failed";
$revCount = `darcs changes --count --repodir $tmpDir/export`; chomp $revCount;
die "darcs changes --count failed" if $? != 0;
my ($stdout, $stderr);
run3(['darcs', 'changes', '--count', '--repodir', "$tmpDir/export"], \undef, \$stdout, \$stderr);
die "darcs changes --count failed: $stderr\n" if $? != 0;
$revCount = $stdout;
chomp $revCount;
system "rm", "-rf", "$tmpDir/export/_darcs";
$storePath = addToStore("$tmpDir/export", 1, "sha256");
$sha256 = queryPathHash($storePath);
$storePath = $MACHINE_LOCAL_STORE->addToStore("$tmpDir/export", 1, "sha256");
$sha256 = $MACHINE_LOCAL_STORE->queryPathHash($storePath);
$sha256 =~ s/sha256://;
$self->{db}->txn_do(sub {

View File

@@ -137,8 +137,8 @@ sub fetchInput {
my $res;
if (! -d $clonePath) {
# Clone everything and fetch the branch.
$res = run(cmd => ["git", "init", $clonePath]);
$res = run(cmd => ["git", "remote", "add", "origin", "--", $uri], dir => $clonePath) unless $res->{status};
$res = runCommand(cmd => ["git", "init", $clonePath]);
$res = runCommand(cmd => ["git", "remote", "add", "origin", "--", $uri], dir => $clonePath) unless $res->{status};
die "error creating git repo in `$clonePath':\n$res->{stderr}" if $res->{status};
}
@@ -146,9 +146,9 @@ sub fetchInput {
# the remote branch for whatever the repository state is. This command mirrors
# only one branch of the remote repository.
my $localBranch = _isHash($branch) ? "_hydra_tmp" : $branch;
$res = run(cmd => ["git", "fetch", "-fu", "origin", "+$branch:$localBranch"], dir => $clonePath,
$res = runCommand(cmd => ["git", "fetch", "-fu", "origin", "+$branch:$localBranch"], dir => $clonePath,
timeout => $cfg->{timeout});
$res = run(cmd => ["git", "fetch", "-fu", "origin"], dir => $clonePath, timeout => $cfg->{timeout}) if $res->{status};
$res = runCommand(cmd => ["git", "fetch", "-fu", "origin"], dir => $clonePath, timeout => $cfg->{timeout}) if $res->{status};
die "error fetching latest change from git repo at `$uri':\n$res->{stderr}" if $res->{status};
# If deepClone is defined, then we look at the content of the repository
@@ -156,16 +156,16 @@ sub fetchInput {
if (defined $deepClone) {
# Is the target branch a topgit branch?
$res = run(cmd => ["git", "ls-tree", "-r", "$branch", ".topgit"], dir => $clonePath);
$res = runCommand(cmd => ["git", "ls-tree", "-r", "$branch", ".topgit"], dir => $clonePath);
if ($res->{stdout} ne "") {
# Checkout the branch to look at its content.
$res = run(cmd => ["git", "checkout", "--force", "$branch"], dir => $clonePath);
$res = runCommand(cmd => ["git", "checkout", "--force", "$branch"], dir => $clonePath);
die "error checking out Git branch '$branch' at `$uri':\n$res->{stderr}" if $res->{status};
# This is a TopGit branch. Fetch all the topic branches so
# that builders can run "tg patch" and similar.
$res = run(cmd => ["tg", "remote", "--populate", "origin"], dir => $clonePath, timeout => $cfg->{timeout});
$res = runCommand(cmd => ["tg", "remote", "--populate", "origin"], dir => $clonePath, timeout => $cfg->{timeout});
print STDERR "warning: `tg remote --populate origin' failed:\n$res->{stderr}" if $res->{status};
}
}
@@ -186,9 +186,9 @@ sub fetchInput {
{uri => $uri, branch => $branch, revision => $revision, isdeepclone => defined($deepClone) ? 1 : 0},
{rows => 1});
addTempRoot($cachedInput->storepath) if defined $cachedInput;
$MACHINE_LOCAL_STORE->addTempRoot($cachedInput->storepath) if defined $cachedInput;
if (defined $cachedInput && isValidPath($cachedInput->storepath)) {
if (defined $cachedInput && $MACHINE_LOCAL_STORE->isValidPath($cachedInput->storepath)) {
$storePath = $cachedInput->storepath;
$sha256 = $cachedInput->sha256hash;
$revision = $cachedInput->revision;
@@ -217,7 +217,7 @@ sub fetchInput {
($sha256, $storePath) = split ' ', grab(cmd => ["nix-prefetch-git", $clonePath, $revision], chomp => 1);
# FIXME: time window between nix-prefetch-git and addTempRoot.
addTempRoot($storePath);
$MACHINE_LOCAL_STORE->addTempRoot($storePath);
$self->{db}->txn_do(sub {
$self->{db}->resultset('CachedGitInputs')->update_or_create(

View File

@@ -88,10 +88,6 @@ sub buildQueued {
common(@_, [], 0);
}
sub buildStarted {
common(@_, [], 1);
}
sub buildFinished {
common(@_, 2);
}

View File

@@ -7,6 +7,7 @@ use HTTP::Request;
use LWP::UserAgent;
use JSON::MaybeXS;
use Hydra::Helper::CatalystUtils;
use Hydra::Helper::Nix;
use File::Temp;
use POSIX qw(strftime);
@@ -58,9 +59,7 @@ sub fetchInput {
print $fh JSON->new->utf8->canonical->encode(\%pulls);
close $fh;
my $storePath = trim(`nix-store --add "$filename"`
or die "cannot copy path $filename to the Nix store.\n");
chomp $storePath;
my $storePath = addToStore($filename);
my $timestamp = time;
return { storePath => $storePath, revision => strftime "%Y%m%d%H%M%S", gmtime($timestamp) };
}

View File

@@ -7,6 +7,7 @@ use HTTP::Request;
use LWP::UserAgent;
use JSON::MaybeXS;
use Hydra::Helper::CatalystUtils;
use Hydra::Helper::Nix;
use File::Temp;
use POSIX qw(strftime);
@@ -17,9 +18,8 @@ tags) from GitHub following a certain naming scheme
=head1 DESCRIPTION
This plugin reads the list of branches or tags using GitHub'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.
This plugin reads the list of branches or tags using GitHub's REST API. This
list is stored in the nix-store and used as an input to declarative jobsets.
=head1 CONFIGURATION
@@ -33,7 +33,7 @@ The declarative project C<spec.json> file must contains an input such as
"pulls": {
"type": "github_refs",
"value": "[owner] [repo] heads|tags - [prefix]",
"value": "[owner] [repo] [type] - [prefix]",
"emailresponsible": false
}
@@ -41,12 +41,11 @@ In the above snippet, 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<heads|tags> denotes that one of these two is allowed, that is, the third
position should hold either the C<heads> or the C<tags> keyword. In case of the former, the plugin
will fetch all branches, while in case of the latter, it will fetch the tags.
C<[type]> is the type of ref to list. Typical values are "heads", "tags", and
"pull". "." will include all types.
C<prefix> denotes the prefix the reference name must start with, in order to be
included.
included. "." will include all references.
For example, C<"value": "nixos hydra heads - release/"> refers to
L<https://github.com/nixos/hydra> repository, and will fetch all branches that
@@ -101,8 +100,6 @@ sub fetchInput {
return undef if $input_type ne "github_refs";
my ($owner, $repo, $type, $fut, $prefix) = split ' ', $value;
die "type field is neither 'heads' nor 'tags', but '$type'"
unless $type eq 'heads' or $type eq 'tags';
my $auth = $self->{config}->{github_authorization}->{$owner};
my $githubEndpoint = $self->{config}->{github_endpoint} // "https://api.github.com";
@@ -110,14 +107,11 @@ sub fetchInput {
my $ua = LWP::UserAgent->new();
_iterate("$githubEndpoint/repos/$owner/$repo/git/matching-refs/$type/$prefix?per_page=100", $auth, \%refs, $ua);
my $tempdir = File::Temp->newdir("github-refs" . "XXXXX", TMPDIR => 1);
my $filename = "$tempdir/github-refs.json";
my $filename = "$tempdir/github-refs-sorted.json";
open(my $fh, ">", $filename) or die "Cannot open $filename for writing: $!";
print $fh encode_json \%refs;
print $fh JSON::MaybeXS->new(canonical => 1, pretty => 1)->encode(\%refs);
close $fh;
system("jq -S . < $filename > $tempdir/github-refs-sorted.json");
my $storePath = trim(qx{nix-store --add "$tempdir/github-refs-sorted.json"}
or die "cannot copy path $filename to the Nix store.\n");
chomp $storePath;
my $storePath = addToStore($filename);
my $timestamp = time;
return { storePath => $storePath, revision => strftime "%Y%m%d%H%M%S", gmtime($timestamp) };
}

View File

@@ -21,6 +21,7 @@ use HTTP::Request;
use LWP::UserAgent;
use JSON::MaybeXS;
use Hydra::Helper::CatalystUtils;
use Hydra::Helper::Nix;
use File::Temp;
use POSIX qw(strftime);
@@ -81,14 +82,11 @@ sub fetchInput {
_iterate($url, $baseUrl, \%pulls, $ua, $target_repo_url);
my $tempdir = File::Temp->newdir("gitlab-pulls" . "XXXXX", TMPDIR => 1);
my $filename = "$tempdir/gitlab-pulls.json";
my $filename = "$tempdir/gitlab-pulls-sorted.json";
open(my $fh, ">", $filename) or die "Cannot open $filename for writing: $!";
print $fh encode_json \%pulls;
print $fh JSON::MaybeXS->new(canonical => 1, pretty => 1, utf8 => 1)->encode(\%pulls);
close $fh;
system("jq -S . < $filename > $tempdir/gitlab-pulls-sorted.json");
my $storePath = trim(`nix-store --add "$tempdir/gitlab-pulls-sorted.json"`
or die "cannot copy path $filename to the Nix store.\n");
chomp $storePath;
my $storePath = addToStore($filename);
my $timestamp = time;
return { storePath => $storePath, revision => strftime "%Y%m%d%H%M%S", gmtime($timestamp) };
}

View File

@@ -7,7 +7,6 @@ use Digest::SHA qw(sha256_hex);
use File::Path;
use Hydra::Helper::Nix;
use Hydra::Helper::Exec;
use Nix::Store;
use Fcntl qw(:flock);
sub supportedInputTypes {
@@ -68,9 +67,9 @@ sub fetchInput {
(my $cachedInput) = $self->{db}->resultset('CachedHgInputs')->search(
{uri => $uri, branch => $branch, revision => $revision});
addTempRoot($cachedInput->storepath) if defined $cachedInput;
$MACHINE_LOCAL_STORE->addTempRoot($cachedInput->storepath) if defined $cachedInput;
if (defined $cachedInput && isValidPath($cachedInput->storepath)) {
if (defined $cachedInput && $MACHINE_LOCAL_STORE->isValidPath($cachedInput->storepath)) {
$storePath = $cachedInput->storepath;
$sha256 = $cachedInput->sha256hash;
} else {
@@ -85,7 +84,7 @@ sub fetchInput {
($sha256, $storePath) = split ' ', $stdout;
# FIXME: time window between nix-prefetch-hg and addTempRoot.
addTempRoot($storePath);
$MACHINE_LOCAL_STORE->addTempRoot($storePath);
$self->{db}->txn_do(sub {
$self->{db}->resultset('CachedHgInputs')->update_or_create(

View File

@@ -5,7 +5,7 @@ use warnings;
use parent 'Hydra::Plugin';
use POSIX qw(strftime);
use Hydra::Helper::Nix;
use Nix::Store;
use IPC::Run3;
sub supportedInputTypes {
my ($self, $inputTypes) = @_;
@@ -30,7 +30,7 @@ sub fetchInput {
{srcpath => $uri, lastseen => {">", $timestamp - $timeout}},
{rows => 1, order_by => "lastseen DESC"});
if (defined $cachedInput && isValidPath($cachedInput->storepath)) {
if (defined $cachedInput && $MACHINE_LOCAL_STORE->isValidPath($cachedInput->storepath)) {
$storePath = $cachedInput->storepath;
$sha256 = $cachedInput->sha256hash;
$timestamp = $cachedInput->timestamp;
@@ -38,15 +38,20 @@ sub fetchInput {
print STDERR "copying input ", $name, " from $uri\n";
if ( $uri =~ /^\// ) {
$storePath = `nix-store --add "$uri"`
or die "cannot copy path $uri to the Nix store.\n";
$storePath = addToStore($uri);
} else {
$storePath = `PRINT_PATH=1 nix-prefetch-url "$uri" | tail -n 1`
or die "cannot fetch $uri to the Nix store.\n";
# Run nix-prefetch-url with PRINT_PATH=1
my ($stdout, $stderr);
local $ENV{PRINT_PATH} = 1;
run3(['nix-prefetch-url', $uri], \undef, \$stdout, \$stderr);
die "cannot fetch $uri to the Nix store: $stderr\n" if $? != 0;
# Get the last line (which is the store path)
my @output_lines = split /\n/, $stdout;
$storePath = $output_lines[-1] if @output_lines;
}
chomp $storePath;
$sha256 = (queryPathInfo($storePath, 0))[1] or die;
$sha256 = ($MACHINE_LOCAL_STORE->queryPathInfo($storePath, 0))[1] or die;
($cachedInput) = $self->{db}->resultset('CachedPathInputs')->search(
{srcpath => $uri, sha256hash => $sha256});

View File

@@ -7,6 +7,8 @@ use File::Temp;
use File::Basename;
use Fcntl;
use IO::File;
use IPC::Run qw(run);
use IPC::Run3;
use Net::Amazon::S3;
use Net::Amazon::S3::Client;
use Digest::SHA;
@@ -14,6 +16,7 @@ use Nix::Config;
use Nix::Store;
use Hydra::Model::DB;
use Hydra::Helper::CatalystUtils;
use Hydra::Helper::Nix;
sub isEnabled {
my ($self) = @_;
@@ -26,11 +29,11 @@ my %compressors = ();
$compressors{"none"} = "";
if (defined($Nix::Config::bzip2)) {
$compressors{"bzip2"} = "| $Nix::Config::bzip2",
$compressors{"bzip2"} = "$Nix::Config::bzip2",
}
if (defined($Nix::Config::xz)) {
$compressors{"xz"} = "| $Nix::Config::xz",
$compressors{"xz"} = "$Nix::Config::xz",
}
my $lockfile = Hydra::Model::DB::getHydraPath . "/.hydra-s3backup.lock";
@@ -92,7 +95,7 @@ sub buildFinished {
my $hash = substr basename($path), 0, 32;
my ($deriver, $narHash, $time, $narSize, $refs) = queryPathInfo($path, 0);
my $system;
if (defined $deriver and isValidPath($deriver)) {
if (defined $deriver and $MACHINE_LOCAL_STORE->isValidPath($deriver)) {
$system = derivationFromPath($deriver)->{platform};
}
foreach my $reference (@{$refs}) {
@@ -110,7 +113,16 @@ sub buildFinished {
}
next unless @incomplete_buckets;
my $compressor = $compressors{$compression_type};
system("$Nix::Config::binDir/nix-store --dump $path $compressor > $tempdir/nar") == 0 or die;
if ($compressor eq "") {
# No compression - use IPC::Run3 to redirect stdout to file
run3(["$Nix::Config::binDir/nix-store", "--dump", $path],
\undef, "$tempdir/nar", \undef) or die "nix-store --dump failed: $!";
} else {
# With compression - use IPC::Run to pipe nix-store output to compressor
my $dump_cmd = ["$Nix::Config::binDir/nix-store", "--dump", $path];
my $compress_cmd = [$compressor];
run($dump_cmd, '|', $compress_cmd, '>', "$tempdir/nar") or die "Pipeline failed: $?";
}
my $digest = Digest::SHA->new(256);
$digest->addfile("$tempdir/nar");
my $file_hash = $digest->hexdigest;

View File

@@ -7,7 +7,6 @@ use Digest::SHA qw(sha256_hex);
use Hydra::Helper::Exec;
use Hydra::Helper::Nix;
use IPC::Run;
use Nix::Store;
sub supportedInputTypes {
my ($self, $inputTypes) = @_;
@@ -45,9 +44,9 @@ sub fetchInput {
(my $cachedInput) = $self->{db}->resultset('CachedSubversionInputs')->search(
{uri => $uri, revision => $revision});
addTempRoot($cachedInput->storepath) if defined $cachedInput;
$MACHINE_LOCAL_STORE->addTempRoot($cachedInput->storepath) if defined $cachedInput;
if (defined $cachedInput && isValidPath($cachedInput->storepath)) {
if (defined $cachedInput && $MACHINE_LOCAL_STORE->isValidPath($cachedInput->storepath)) {
$storePath = $cachedInput->storepath;
$sha256 = $cachedInput->sha256hash;
} else {
@@ -62,16 +61,16 @@ sub fetchInput {
die "error checking out Subversion repo at `$uri':\n$stderr" if $res;
if ($type eq "svn-checkout") {
$storePath = addToStore($wcPath, 1, "sha256");
$storePath = $MACHINE_LOCAL_STORE->addToStore($wcPath, 1, "sha256");
} else {
# Hm, if the Nix Perl bindings supported filters in
# addToStore(), then we wouldn't need to make a copy here.
my $tmpDir = File::Temp->newdir("hydra-svn-export.XXXXXX", CLEANUP => 1, TMPDIR => 1) or die;
(system "svn", "export", $wcPath, "$tmpDir/source", "--quiet") == 0 or die "svn export failed";
$storePath = addToStore("$tmpDir/source", 1, "sha256");
$storePath = $MACHINE_LOCAL_STORE->addToStore("$tmpDir/source", 1, "sha256");
}
$sha256 = queryPathHash($storePath); $sha256 =~ s/sha256://;
$sha256 = $MACHINE_LOCAL_STORE->queryPathHash($storePath); $sha256 =~ s/sha256://;
$self->{db}->txn_do(sub {
$self->{db}->resultset('CachedSubversionInputs')->update_or_create(

View File

@@ -49,7 +49,7 @@ __PACKAGE__->table("buildoutputs");
=head2 path
data_type: 'text'
is_nullable: 0
is_nullable: 1
=cut
@@ -59,7 +59,7 @@ __PACKAGE__->add_columns(
"name",
{ data_type => "text", is_nullable => 0 },
"path",
{ data_type => "text", is_nullable => 0 },
{ data_type => "text", is_nullable => 1 },
);
=head1 PRIMARY KEY
@@ -94,8 +94,8 @@ __PACKAGE__->belongs_to(
);
# Created by DBIx::Class::Schema::Loader v0.07049 @ 2021-08-26 12:02:36
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:gU+kZ6A0ISKpaXGRGve8mg
# Created by DBIx::Class::Schema::Loader v0.07049 @ 2022-06-30 12:02:32
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Jsabm3YTcI7YvCuNdKP5Ng
my %hint = (
columns => [

View File

@@ -55,7 +55,7 @@ __PACKAGE__->table("buildstepoutputs");
=head2 path
data_type: 'text'
is_nullable: 0
is_nullable: 1
=cut
@@ -67,7 +67,7 @@ __PACKAGE__->add_columns(
"name",
{ data_type => "text", is_nullable => 0 },
"path",
{ data_type => "text", is_nullable => 0 },
{ data_type => "text", is_nullable => 1 },
);
=head1 PRIMARY KEY
@@ -119,8 +119,8 @@ __PACKAGE__->belongs_to(
);
# Created by DBIx::Class::Schema::Loader v0.07049 @ 2021-08-26 12:02:36
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:gxp8rOjpRVen4YbIjomHTw
# Created by DBIx::Class::Schema::Loader v0.07049 @ 2022-06-30 12:02:32
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Bad70CRTt7zb2GGuRoQ++Q
# You can replace this text with custom code or comments, and it will be preserved on regeneration

View File

@@ -105,4 +105,6 @@ __PACKAGE__->add_column(
"+id" => { retrieve_on_insert => 1 }
);
__PACKAGE__->mk_group_accessors('column' => 'has_error');
1;

View File

@@ -386,6 +386,8 @@ __PACKAGE__->add_column(
"+id" => { retrieve_on_insert => 1 }
);
__PACKAGE__->mk_group_accessors('column' => 'has_error');
sub supportsDynamicRunCommand {
my ($self) = @_;

View File

@@ -216,7 +216,7 @@ sub json_hint {
sub _authenticator() {
my $authenticator = Crypt::Passphrase->new(
encoder => 'Argon2',
encoder => { module => 'Argon2', output_size => 16 },
validators => [
(sub {
my ($password, $hash) = @_;

View File

@@ -0,0 +1,30 @@
package Hydra::Schema::ResultSet::EvaluationErrors;
use strict;
use utf8;
use warnings;
use parent 'DBIx::Class::ResultSet';
use Storable qw(dclone);
__PACKAGE__->load_components('Helper::ResultSet::RemoveColumns');
# Exclude expensive error message values unless explicitly requested, and
# replace them with a summary field describing their presence/absence.
sub search_rs {
my ( $class, $query, $attrs ) = @_;
if ($attrs) {
$attrs = dclone($attrs);
}
unless (exists $attrs->{'select'} || exists $attrs->{'columns'}) {
$attrs->{'+columns'}->{'has_error'} = "errormsg != ''";
}
unless (exists $attrs->{'+columns'}->{'errormsg'}) {
push @{ $attrs->{'remove_columns'} }, 'errormsg';
}
return $class->next::method($query, $attrs);
}

View File

@@ -0,0 +1,30 @@
package Hydra::Schema::ResultSet::Jobsets;
use strict;
use utf8;
use warnings;
use parent 'DBIx::Class::ResultSet';
use Storable qw(dclone);
__PACKAGE__->load_components('Helper::ResultSet::RemoveColumns');
# Exclude expensive error message values unless explicitly requested, and
# replace them with a summary field describing their presence/absence.
sub search_rs {
my ( $class, $query, $attrs ) = @_;
if ($attrs) {
$attrs = dclone($attrs);
}
unless (exists $attrs->{'select'} || exists $attrs->{'columns'}) {
$attrs->{'+columns'}->{'has_error'} = "errormsg != ''";
}
unless (exists $attrs->{'+columns'}->{'errormsg'}) {
push @{ $attrs->{'remove_columns'} }, 'errormsg';
}
return $class->next::method($query, $attrs);
}

View File

@@ -8,6 +8,7 @@ use MIME::Base64;
use Nix::Manifest;
use Nix::Store;
use Nix::Utils;
use Hydra::Helper::Nix;
use base qw/Catalyst::View/;
sub process {
@@ -17,7 +18,7 @@ sub process {
$c->response->content_type('text/x-nix-narinfo'); # !!! check MIME type
my ($deriver, $narHash, $time, $narSize, $refs) = queryPathInfo($storePath, 1);
my ($deriver, $narHash, $time, $narSize, $refs) = $MACHINE_LOCAL_STORE->queryPathInfo($storePath, 1);
my $info;
$info .= "StorePath: $storePath\n";
@@ -28,8 +29,8 @@ sub process {
$info .= "References: " . join(" ", map { basename $_ } @{$refs}) . "\n";
if (defined $deriver) {
$info .= "Deriver: " . basename $deriver . "\n";
if (isValidPath($deriver)) {
my $drv = derivationFromPath($deriver);
if ($MACHINE_LOCAL_STORE->isValidPath($deriver)) {
my $drv = $MACHINE_LOCAL_STORE->derivationFromPath($deriver);
$info .= "System: $drv->{platform}\n";
}
}

View File

@@ -16,7 +16,10 @@ sub process {
my $tail = int($c->stash->{tail} // "0");
if ($logPath =~ /\.bz2$/) {
if ($logPath =~ /\.zst$/) {
my $doTail = $tail ? "| tail -n '$tail'" : "";
open($fh, "-|", "zstd -dc < '$logPath' $doTail") or die;
} elsif ($logPath =~ /\.bz2$/) {
my $doTail = $tail ? "| tail -n '$tail'" : "";
open($fh, "-|", "bzip2 -dc < '$logPath' $doTail") or die;
} else {

View File

@@ -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};

View File

@@ -1,22 +0,0 @@
PERL_MODULES = \
$(wildcard *.pm) \
$(wildcard Hydra/*.pm) \
$(wildcard Hydra/Helper/*.pm) \
$(wildcard Hydra/Model/*.pm) \
$(wildcard Hydra/View/*.pm) \
$(wildcard Hydra/Schema/*.pm) \
$(wildcard Hydra/Schema/Result/*.pm) \
$(wildcard Hydra/Schema/ResultSet/*.pm) \
$(wildcard Hydra/Controller/*.pm) \
$(wildcard Hydra/Base/*.pm) \
$(wildcard Hydra/Base/Controller/*.pm) \
$(wildcard Hydra/Script/*.pm) \
$(wildcard Hydra/Component/*.pm) \
$(wildcard Hydra/Event/*.pm) \
$(wildcard Hydra/Plugin/*.pm)
EXTRA_DIST = \
$(PERL_MODULES)
hydradir = $(libexecdir)/hydra/lib
nobase_hydra_DATA = $(PERL_MODULES)

View File

@@ -0,0 +1,103 @@
package Perl::Critic::Policy::Hydra::ProhibitShellInvokingSystemCalls;
use strict;
use warnings;
use constant;
use Perl::Critic::Utils qw{ :severities :classification :ppi };
use base 'Perl::Critic::Policy';
our $VERSION = '1.000';
use constant DESC => q{Shell-invoking system calls are prohibited};
use constant EXPL => q{Use list form system() or IPC::Run3 for better security. String form invokes shell and is vulnerable to injection};
sub supported_parameters { return () }
sub default_severity { return $SEVERITY_HIGHEST }
sub default_themes { return qw( hydra security ) }
sub applies_to { return 'PPI::Token::Word' }
sub violates {
my ( $self, $elem, undef ) = @_;
# Only check system() and exec() calls
return () unless $elem->content() =~ /^(system|exec)$/;
return () unless is_function_call($elem);
# Skip method calls (->system or ->exec)
my $prev = $elem->sprevious_sibling();
return () if $prev && $prev->isa('PPI::Token::Operator') && $prev->content() eq '->';
# Get first argument after function name, skipping whitespace
my $args = $elem->snext_sibling();
return () unless $args;
$args = $args->snext_sibling() while $args && $args->isa('PPI::Token::Whitespace');
# For parenthesized calls, look inside
my $search_elem = $args;
if ($args && $args->isa('PPI::Structure::List')) {
$search_elem = $args->schild(0);
return () unless $search_elem;
}
# Check if it's list form (has comma)
my $current = $search_elem;
if ($current && $current->isa('PPI::Statement')) {
# Look through statement children
for my $child ($current->schildren()) {
return () if $child->isa('PPI::Token::Operator') && $child->content() eq ',';
}
} else {
# Look through siblings for non-parenthesized calls
while ($current) {
return () if $current->isa('PPI::Token::Operator') && $current->content() eq ',';
last if $current->isa('PPI::Token::Structure') && $current->content() eq ';';
$current = $current->snext_sibling();
}
}
# Check if first arg is array variable
my $first = $search_elem->isa('PPI::Statement') ?
$search_elem->schild(0) : $search_elem;
return () if $first && $first->isa('PPI::Token::Symbol') && $first->content() =~ /^[@]/;
# Check if it's a safe single-word command
if ($first && $first->isa('PPI::Token::Quote')) {
my $content = $first->string();
return () if $content =~ /^[a-zA-Z0-9_\-\.\/]+$/;
}
return $self->violation( DESC, EXPL, $elem );
}
1;
__END__
=pod
=head1 NAME
Perl::Critic::Policy::Hydra::ProhibitShellInvokingSystemCalls - Prohibit shell-invoking system() and exec() calls
=head1 DESCRIPTION
This policy prohibits the use of C<system()> and C<exec()> functions when called with a single string argument,
which invokes the shell and is vulnerable to injection attacks.
The list form (e.g., C<system('ls', '-la')>) is allowed as it executes directly without shell interpretation.
For better error handling and output capture, consider using C<IPC::Run3>.
=head1 CONFIGURATION
This Policy is not configurable except for the standard options.
=head1 AUTHOR
Hydra Development Team
=head1 COPYRIGHT
Copyright (c) 2025 Hydra Development Team. All rights reserved.
=cut

View File

@@ -2,7 +2,8 @@
#include <pqxx/pqxx>
#include "util.hh"
#include <nix/util/environment-variables.hh>
#include <nix/util/util.hh>
struct Connection : pqxx::connection
@@ -26,19 +27,20 @@ struct Connection : pqxx::connection
};
class receiver : public pqxx::notification_receiver
class receiver
{
std::optional<std::string> status;
pqxx::connection & conn;
public:
receiver(pqxx::connection_base & c, const std::string & channel)
: pqxx::notification_receiver(c, channel) { }
void operator() (const std::string & payload, int pid) override
: conn(static_cast<pqxx::connection &>(c))
{
status = payload;
};
conn.listen(channel, [this](pqxx::notification n) {
status = std::string(n.payload);
});
}
std::optional<std::string> get() {
auto s = status;

View File

@@ -2,7 +2,8 @@
#include <map>
#include "util.hh"
#include <nix/util/file-system.hh>
#include <nix/util/util.hh>
struct HydraConfig
{

5
src/libhydra/meson.build Normal file
View File

@@ -0,0 +1,5 @@
libhydra_inc = include_directories('.')
libhydra_dep = declare_dependency(
include_directories: [libhydra_inc],
)

77
src/meson.build Normal file
View File

@@ -0,0 +1,77 @@
# Native code
subdir('libhydra')
subdir('hydra-evaluator')
subdir('hydra-queue-runner')
hydra_libexecdir = get_option('libexecdir') / 'hydra'
# Data and interpreted
foreach dir : ['lib', 'root']
install_subdir(dir,
install_dir: hydra_libexecdir,
)
endforeach
subdir('sql')
subdir('ttf')
# Static files for website
hydra_libexecdir_static = hydra_libexecdir / 'root' / 'static'
## Bootstrap
bootstrap_name = 'bootstrap-4.3.1-dist'
bootstrap = custom_target(
'extract-bootstrap',
input: 'root' / (bootstrap_name + '.zip'),
output: bootstrap_name,
command: ['unzip', '-u', '-d', '@OUTDIR@', '@INPUT@'],
)
custom_target(
'name-bootstrap',
input: bootstrap,
output: 'bootstrap',
command: ['cp', '-r', '@INPUT@' , '@OUTPUT@'],
install: true,
install_dir: hydra_libexecdir_static,
)
## Flot
custom_target(
'extract-flot',
input: 'root' / 'flot-0.8.3.zip',
output: 'flot',
command: ['unzip', '-u', '-d', '@OUTDIR@', '@INPUT@'],
install: true,
install_dir: hydra_libexecdir_static / 'js',
)
## Fontawesome
fontawesome_name = 'fontawesome-free-5.10.2-web'
fontawesome = custom_target(
'extract-fontawesome',
input: 'root' / (fontawesome_name + '.zip'),
output: fontawesome_name,
command: ['unzip', '-u', '-d', '@OUTDIR@', '@INPUT@'],
)
custom_target(
'name-fontawesome',
input: fontawesome,
output: 'fontawesome',
command: ['cp', '-r', '@INPUT@' , '@OUTPUT@'],
install: true,
install_dir: hydra_libexecdir_static,
)
# Scripts
install_subdir('script',
install_dir: get_option('bindir'),
exclude_files: [
'hydra-dev-server',
],
install_mode: 'rwxr-xr-x',
strip_directory: true,
)

View File

@@ -1,39 +0,0 @@
TEMPLATES = $(wildcard *.tt)
STATIC = \
$(wildcard static/images/*) \
$(wildcard static/css/*) \
static/js/bootbox.min.js \
static/js/popper.min.js \
static/js/common.js \
static/js/jquery/jquery-3.4.1.min.js \
static/js/jquery/jquery-ui-1.10.4.min.js
FLOT = flot-0.8.3.zip
BOOTSTRAP = bootstrap-4.3.1-dist.zip
FONTAWESOME = fontawesome-free-5.10.2-web.zip
ZIPS = $(FLOT) $(BOOTSTRAP) $(FONTAWESOME)
EXTRA_DIST = $(TEMPLATES) $(STATIC) $(ZIPS)
hydradir = $(libexecdir)/hydra/root
nobase_hydra_DATA = $(EXTRA_DIST)
all:
mkdir -p $(srcdir)/static/js
unzip -u -d $(srcdir)/static $(BOOTSTRAP)
rm -rf $(srcdir)/static/bootstrap
mv $(srcdir)/static/$(basename $(BOOTSTRAP)) $(srcdir)/static/bootstrap
unzip -u -d $(srcdir)/static/js $(FLOT)
unzip -u -d $(srcdir)/static $(FONTAWESOME)
rm -rf $(srcdir)/static/fontawesome
mv $(srcdir)/static/$(basename $(FONTAWESOME)) $(srcdir)/static/fontawesome
install-data-local: $(ZIPS)
mkdir -p $(hydradir)/static/js
cp -prvd $(srcdir)/static/js/* $(hydradir)/static/js
mkdir -p $(hydradir)/static/bootstrap
cp -prvd $(srcdir)/static/bootstrap/* $(hydradir)/static/bootstrap
mkdir -p $(hydradir)/static/fontawesome/{css,webfonts}
cp -prvd $(srcdir)/static/fontawesome/css/* $(hydradir)/static/fontawesome/css
cp -prvd $(srcdir)/static/fontawesome/webfonts/* $(hydradir)/static/fontawesome/webfonts

View File

@@ -11,7 +11,7 @@ titleHTML="Latest builds" _
"") %]
[% PROCESS common.tt %]
<p>Showing builds [% (page - 1) * resultsPerPage + 1 %] - [% (page - 1) * resultsPerPage + builds.size %] out of [% total %] in order of descending finish time.</p>
<p>Showing builds [% (page - 1) * resultsPerPage + 1 %] - [% (page - 1) * resultsPerPage + builds.size %] out of [% HTML.escape(total) %] in order of descending finish time.</p>
[% INCLUDE renderBuildList hideProjectName=project hideJobsetName=jobset hideJobName=job %]
[% INCLUDE renderPager %]

View File

@@ -33,7 +33,7 @@
<div id="hydra-signin" class="modal hide fade" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<form>
<form id="signin-form">
<div class="modal-body">
<div class="form-group">
<label for="username" class="col-form-label">User name</label>
@@ -45,7 +45,7 @@
</div>
</div>
<div class="modal-footer">
<button id="do-signin" type="button" class="btn btn-primary">Sign in</button>
<button type="submit" class="btn btn-primary">Sign in</button>
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
</div>
</form>
@@ -57,10 +57,11 @@
function finishSignOut() { }
$("#do-signin").click(function() {
$("#signin-form").submit(function(e) {
e.preventDefault();
requestJSON({
url: "[% c.uri_for('/login') %]",
data: $(this).parents("form").serialize(),
data: $(this).serialize(),
type: 'POST',
success: function(data) {
window.location.reload();
@@ -82,7 +83,7 @@
function onGoogleSignIn(googleUser) {
requestJSON({
url: "[% c.uri_for('/google-login') %]",
data: "id_token=" + googleUser.getAuthResponse().id_token,
data: "id_token=" + googleUser.credential,
type: 'POST',
success: function(data) {
window.location.reload();
@@ -91,9 +92,6 @@
return false;
};
$("#google-signin").click(function() {
$(".g-signin2:first-child > div").click();
});
</script>
[% END %]

View File

@@ -37,7 +37,7 @@ END;
seen.${step.drvpath} = 1;
log = c.uri_for('/build' build.id 'nixlog' step.stepnr); %]
<tr>
<td>[% step.stepnr %]</td>
<td>[% HTML.escape(step.stepnr) %]</td>
<td>
[% IF step.type == 0 %]
Build of <tt>[% INCLUDE renderOutputs outputs=step.buildstepoutputs %]</tt>
@@ -61,21 +61,7 @@ END;
<td>[% IF step.busy != 0 || ((step.machine || step.starttime) && (step.status == 0 || step.status == 1 || step.status == 3 || step.status == 4 || step.status == 7)); INCLUDE renderMachineName machine=step.machine; ELSE; "<em>n/a</em>"; END %]</td>
<td class="step-status">
[% IF step.busy != 0 %]
[% IF step.busy == 1 %]
<strong>Preparing</strong>
[% ELSIF step.busy == 10 %]
<strong>Connecting</strong>
[% ELSIF step.busy == 20 %]
<strong>Sending inputs</strong>
[% ELSIF step.busy == 30 %]
<strong>Building</strong>
[% ELSIF step.busy == 40 %]
<strong>Receiving outputs</strong>
[% ELSIF step.busy == 50 %]
<strong>Post-processing</strong>
[% ELSE %]
<strong>Unknown state</strong>
[% END %]
[% INCLUDE renderBusyStatus %]
[% ELSIF step.status == 0 %]
[% IF step.isnondeterministic %]
<span class="warn">Succeeded with non-determistic result</span>
@@ -100,7 +86,7 @@ END;
[% ELSIF step.status == 11 %]
<span class="error">Output limit exceeded</span>
[% ELSIF step.status == 12 %]
<span class="error">Non-determinism detected</span> [% IF step.timesbuilt %] after [% step.timesbuilt %] times[% END %]
<span class="error">Non-determinism detected</span> [% IF step.timesbuilt %] after [% HTML.escape(step.timesbuilt) %] times[% END %]
[% ELSIF step.errormsg %]
<span class="error">Failed</span>: <em>[% HTML.escape(step.errormsg) %]</em>
[% ELSE %]
@@ -126,16 +112,16 @@ END;
[% IF c.user_exists %]
[% IF available %]
[% IF build.keep %]
<a class="dropdown-item" href="[% c.uri_for('/build' build.id 'keep' 0) %]">Unkeep</a>
<a class="dropdown-item" [% HTML.attributes(href => c.uri_for('/build' build.id 'keep' 0)) %]>Unkeep</a>
[% ELSE %]
<a class="dropdown-item" href="[% c.uri_for('/build' build.id 'keep' 1) %]">Keep</a>
<a class="dropdown-item" [% HTML.attributes(href => c.uri_for('/build' build.id 'keep' 1)) %]>Keep</a>
[% END %]
[% END %]
[% IF build.finished %]
<a class="dropdown-item" href="[% c.uri_for('/build' build.id 'restart') %]">Restart</a>
<a class="dropdown-item" [% HTML.attributes(href => c.uri_for('/build' build.id 'restart')) %]>Restart</a>
[% ELSE %]
<a class="dropdown-item" href="[% c.uri_for('/build' build.id 'cancel') %]">Cancel</a>
<a class="dropdown-item" href="[% c.uri_for('/build' build.id 'bump') %]">Bump up</a>
<a class="dropdown-item" [% HTML.attributes(href => c.uri_for('/build' build.id 'cancel')) %]>Cancel</a>
<a class="dropdown-item" [% HTML.attributes(href => c.uri_for('/build' build.id 'bump')) %]>Bump up</a>
[% END %]
[% END %]
</div>
@@ -146,7 +132,7 @@ END;
<li class="nav-item"><a class="nav-link" href="#tabs-details" data-toggle="tab">Details</a></li>
<li class="nav-item"><a class="nav-link" href="#tabs-buildinputs" data-toggle="tab">Inputs</a></li>
[% IF steps.size() > 0 %]<li class="nav-item"><a class="nav-link" href="#tabs-buildsteps" data-toggle="tab">Build Steps</a></li>[% END %]
[% IF build.dependents %]<li class="nav-item"><a class="nav-link" href="#tabs-usedby" data-toggle="tab">Used By</a></li>[% END%]
[% IF build.dependents %]<li class="nav-item"><a class="nav-link" href="#tabs-usedby" data-toggle="tab">Used By</a></li>[% END %]
[% IF drvAvailable %]<li class="nav-item"><a class="nav-link" href="#tabs-build-deps" data-toggle="tab">Build Dependencies</a></li>[% END %]
[% IF localStore && available %]<li class="nav-item"><a class="nav-link" href="#tabs-runtime-deps" data-toggle="tab">Runtime Dependencies</a></li>[% END %]
[% IF runcommandlogProblem || runcommandlogs.size() > 0 %]<li class="nav-item"><a class="nav-link" href="#tabs-runcommandlogs" data-toggle="tab">RunCommand Logs[% IF runcommandlogProblem %] <span class="badge badge-warning">Disabled</span>[% END %]</a></li>[% END %]
@@ -165,7 +151,7 @@ END;
<table class="info-table">
<tr>
<th>Build ID:</th>
<td>[% build.id %]</td>
<td>[% HTML.escape(build.id) %]</td>
</tr>
<tr>
<th>Status:</th>
@@ -182,9 +168,9 @@ END;
END;
%];
[%+ IF nrFinished == nrConstituents && nrFailedConstituents == 0 %]
all [% nrConstituents %] constituent builds succeeded
all [% HTML.escape(nrConstituents) %] constituent builds succeeded
[% ELSE %]
[% nrFailedConstituents %] out of [% nrConstituents %] constituent builds failed
[% HTML.escape(nrFailedConstituents) %] out of [% HTML.escape(nrConstituents) %] constituent builds failed
[% IF nrFinished < nrConstituents %]
([% nrConstituents - nrFinished %] still pending)
[% END %]
@@ -194,25 +180,25 @@ END;
</tr>
<tr>
<th>System:</th>
<td><tt>[% build.system %]</tt></td>
<td><tt>[% build.system | html %]</tt></td>
</tr>
[% IF build.releasename %]
<tr>
<th>Release name:</th>
<td><tt>[% HTML.escape(build.releasename) %]</tt></td>
<td><tt>[% build.releasename | html %]</tt></td>
</tr>
[% ELSE %]
<tr>
<th>Nix name:</th>
<td><tt>[% build.nixname %]</tt></td>
<td><tt>[% build.nixname | html %]</tt></td>
</tr>
[% END %]
[% IF eval %]
<tr>
<th>Part of:</th>
<td>
<a href="[% c.uri_for(c.controller('JobsetEval').action_for('view'), [eval.id]) %]">evaluation [% eval.id %]</a>
[% IF nrEvals > 1 +%] (and <a href="[% c.uri_for('/build' build.id 'evals') %]">[% nrEvals - 1 %] others</a>)[% END %]
<a [% HTML.attributes(href => c.uri_for(c.controller('JobsetEval').action_for('view'), [eval.id])) %]>evaluation [% HTML.escape(eval.id) %]</a>
[% IF nrEvals > 1 +%] (and <a [% HTML.attributes(href => c.uri_for('/build' build.id 'evals')) %]>[% nrEvals - 1 %] others</a>)[% END %]
</td>
</tr>
[% END %]
@@ -240,9 +226,9 @@ END;
<th>Logfile:</th>
<td>
[% actualLog = cachedBuildStep ? c.uri_for('/build' cachedBuild.id 'nixlog' cachedBuildStep.stepnr) : c.uri_for('/build' build.id 'log') %]
<a class="btn btn-secondary btn-sm" href="[%actualLog%]">pretty</a>
<a class="btn btn-secondary btn-sm" href="[%actualLog%]/raw">raw</a>
<a class="btn btn-secondary btn-sm" href="[%actualLog%]/tail">tail</a>
<a class="btn btn-secondary btn-sm" [% HTML.attributes(href => actualLog) %]>pretty</a>
<a class="btn btn-secondary btn-sm" [% HTML.attributes(href => actualLog _ "/raw") %]>raw</a>
<a class="btn btn-secondary btn-sm" [% HTML.attributes(href => actualLog _ "/tail") %]>tail</a>
</td>
</tr>
[% END %]
@@ -350,12 +336,12 @@ END;
[% IF eval.nixexprinput %]
<tr>
<th>Nix expression:</th>
<td>file <tt>[% HTML.escape(eval.nixexprpath) %]</tt> in input <tt>[% HTML.escape(eval.nixexprinput) %]</tt></td>
<td>file <tt>[% eval.nixexprpath | html %]</tt> in input <tt>[% eval.nixexprinput | html %]</tt></td>
</tr>
[% END %]
<tr>
<th>Nix name:</th>
<td><tt>[% build.nixname %]</tt></td>
<td><tt>[% build.nixname | html %]</tt></td>
</tr>
<tr>
<th>Short description:</th>
@@ -375,11 +361,11 @@ END;
</tr>
<tr>
<th>System:</th>
<td><tt>[% build.system %]</tt></td>
<td><tt>[% build.system | html %]</tt></td>
</tr>
<tr>
<th>Derivation store path:</th>
<td><tt>[% build.drvpath %]</tt></td>
<td><tt>[% build.drvpath | html %]</tt></td>
</tr>
<tr>
<th>Output store paths:</th>
@@ -390,14 +376,14 @@ END;
<tr>
<th>Closure size:</th>
<td>[% mibs(build.closuresize / (1024 * 1024)) %] MiB
(<a href="[%chartsURL%]">history</a>)</td>
(<a [% HTML.attributes(href => chartsURL) %]>history</a>)</td>
</tr>
[% END %]
[% IF build.finished && build.closuresize %]
<tr>
<th>Output size:</th>
<td>[% mibs(build.size / (1024 * 1024)) %] MiB
(<a href="[%chartsURL%]">history</a>)</td>
(<a [% HTML.attributes(href => chartsURL) %]>history</a>)</td>
</tr>
[% END %]
[% IF build.finished && build.buildproducts %]
@@ -426,9 +412,9 @@ END;
<tbody>
[% FOREACH metric IN build.buildmetrics %]
<tr>
<td><tt><a class="row-link" [% HTML.attributes(href => c.uri_for('/job' project.name jobset.name job 'metric' metric.name)) %]">[%HTML.escape(metric.name)%]</a></tt></td>
<td style="text-align: right">[%metric.value%]</td>
<td>[%metric.unit%]</td>
<td><tt><a class="row-link" [% HTML.attributes(href => c.uri_for('/job' project.name jobset.name job 'metric' metric.name)) %]">[% metric.name | html %]</a></tt></td>
<td style="text-align: right">[% HTML.escape(metric.value) %]</td>
<td>[% HTML.escape(metric.unit) %]</td>
</tr>
[% END %]
</tbody>
@@ -470,8 +456,8 @@ END;
[% FOREACH input IN build.dependents %]
<tr>
<td>[% INCLUDE renderFullBuildLink build=input.build %]</td>
<td><tt>[% input.name %]</tt></td>
<td><tt>[% input.build.system %]</tt></td>
<td><tt>[% input.name | html %]</tt></td>
<td><tt>[% input.build.system | html %]</tt></td>
<td>[% INCLUDE renderDateTime timestamp = input.build.timestamp %]</td>
</tr>
[% END %]
@@ -498,7 +484,7 @@ END;
[% ELSIF runcommandlogProblem == "disabled-jobset" %]
This jobset does not enable Dynamic RunCommand support.
[% ELSE %]
Dynamic RunCommand is not enabled: [% runcommandlogProblem %].
Dynamic RunCommand is not enabled: [% HTML.escape(runcommandlogProblem) %].
[% END %]
</div>
[% END %]
@@ -517,18 +503,18 @@ END;
</div>
<div class="d-flex flex-column mr-auto align-self-center">
<div><tt>[% runcommandlog.command | html%]</tt></div>
<div><tt>[% runcommandlog.command | html %]</tt></div>
<div>
[% IF not runcommandlog.is_running() %]
[% IF runcommandlog.did_fail_with_signal() %]
Exit signal: [% runcommandlog.signal %]
Exit signal: [% runcommandlog.signal | html %]
[% IF runcommandlog.core_dumped %]
(Core Dumped)
[% END %]
[% ELSIF runcommandlog.did_fail_with_exec_error() %]
Exec error: [% runcommandlog.error_number %]
Exec error: [% runcommandlog.error_number | html %]
[% ELSIF not runcommandlog.did_succeed() %]
Exit code: [% runcommandlog.exit_code %]
Exit code: [% runcommandlog.exit_code | html %]
[% END %]
[% END %]
</div>
@@ -546,9 +532,9 @@ END;
[% IF runcommandlog.uuid != undef %]
[% runLog = c.uri_for('/build', build.id, 'runcommandlog', runcommandlog.uuid) %]
<div>
<a class="btn btn-secondary btn-sm" href="[% runLog %]">pretty</a>
<a class="btn btn-secondary btn-sm" href="[% runLog %]/raw">raw</a>
<a class="btn btn-secondary btn-sm" href="[% runLog %]/tail">tail</a>
<a class="btn btn-secondary btn-sm" [% HTML.attributes(href => runLog) %]>pretty</a>
<a class="btn btn-secondary btn-sm" [% HTML.attributes(href => runLog) %]/raw">raw</a>
<a class="btn btn-secondary btn-sm" [% HTML.attributes(href => runLog) %]/tail">tail</a>
</div>
[% END %]
</div>
@@ -577,17 +563,17 @@ END;
[% IF eval.flake %]
<p>If you have <a href='https://nixos.org/nix/download.html'>Nix
<p>If you have <a href='https://nixos.org/download/'>Nix
installed</a>, you can reproduce this build on your own machine by
running the following command:</p>
<div class="card bg-light"><div class="card-body p-2"><code>
<span class="shell-prompt"># </span>nix build [% HTML.escape(eval.flake) %]#hydraJobs.[% HTML.escape(job) %]
<span class="shell-prompt"># </span>nix build '[% HTML.escape(eval.flake) %]#hydraJobs.[% HTML.escape(job) %]'
</code></div></div>
[% ELSE %]
<p>If you have <a href='https://nixos.org/nix/download.html'>Nix
<p>If you have <a href='https://nixos.org/download/'>Nix
installed</a>, you can reproduce this build on your own machine by
downloading <a [% HTML.attributes(href => url) %]>a script</a>
that checks out all inputs of the build and then invokes Nix to

View File

@@ -7,7 +7,7 @@ href="http://nixos.org/">Nix package manager</a>. If you have Nix
installed, you can subscribe to this channel by once executing</p>
<div class="card bg-light"><div class="card-body"><pre>
<span class="shell-prompt">$ </span>nix-channel --add [% curUri +%]
<span class="shell-prompt">$ </span>nix-channel --add [% HTML.escape(curUri) +%]
<span class="shell-prompt">$ </span>nix-channel --update
</pre></div></div>
@@ -49,9 +49,9 @@ installed, you can subscribe to this channel by once executing</p>
[% b = pkg.build %]
<tr>
<td><a href="[% c.uri_for('/build' b.id) %]">[% b.id %]</a></td>
<td><tt>[% b.get_column('releasename') || b.nixname %]</tt></td>
<td><tt>[% b.system %]</tt></td>
<td><a [% HTML.attributes(href => c.uri_for('/build' b.id)) %]>[% HTML.escape(b.id) %]</a></td>
<td><tt>[% b.get_column('releasename') || b.nixname | html %]</tt></td>
<td><tt>[% b.system | html %]</tt></td>
<td>
[% IF b.homepage %]
<a [% HTML.attributes(href => b.homepage) %]>[% HTML.escape(b.description) %]</a>

View File

@@ -55,17 +55,17 @@ BLOCK renderRelativeDate %]
[% END;
BLOCK renderProjectName %]
<a [% IF inRow %]class="row-link"[% END %] href="[% c.uri_for('/project' project) %]"><tt>[% project %]</tt></a>
<a [% IF inRow %]class="row-link"[% END %] [% HTML.attributes(href => c.uri_for('/project' project)) %]><tt>[% project | html %]</tt></a>
[% END;
BLOCK renderJobsetName %]
<a [% IF inRow %]class="row-link"[% END %] href="[% c.uri_for('/jobset' project jobset) %]"><tt>[% jobset %]</tt></a>
<a [% IF inRow %]class="row-link"[% END %] [% HTML.attributes(href => c.uri_for('/jobset' project jobset)) %]><tt>[% jobset | html %]</tt></a>
[% END;
BLOCK renderJobName %]
<a [% IF inRow %]class="row-link"[% END %] href="[% c.uri_for('/job' project jobset job) %]">[% job %]</a>
<a [% IF inRow %]class="row-link"[% END %] [% HTML.attributes(href => c.uri_for('/job' project jobset job)) %]>[% job | html %]</a>
[% END;
@@ -91,6 +91,17 @@ BLOCK renderDuration;
duration % 60 %]s[%
END;
BLOCK renderDrvInfo;
drvname = step.drvpath
.substr(11) # strip `/nix/store/`
.split('-').slice(1).join("-") # strip hash part
.substr(0, -4); # strip `.drv`
IF drvname != releasename;
IF step.type == 0; action = "Build"; ELSE; action = "Substitution"; END;
IF drvname; %]<em> ([% HTML.escape(action) %] of [% HTML.escape(drvname) %])</em>[% END;
END;
END;
BLOCK renderBuildListHeader %]
<table class="table table-striped table-condensed clickable-rows">
@@ -129,20 +140,25 @@ BLOCK renderBuildListBody;
[% IF showSchedulingInfo %]
<td>[% IF busy %]<span class="badge badge-success">Started</span>[% ELSE %]<span class="badge badge-secondary">Queued</span>[% END %]</td>
[% END %]
<td><a class="row-link" href="[% link %]">[% build.id %]</a></td>
<td><a class="row-link" [% HTML.attributes(href => link) %]>[% HTML.escape(build.id) %]</a></td>
[% IF !hideJobName %]
<td><a href="[%link%]">[% IF !hideJobsetName %][%build.jobset.get_column("project")%]:[%build.jobset.get_column("name")%]:[% END %][%build.get_column("job")%]</td>
<td>
<a [% HTML.attributes(href => link) %]>[% IF !hideJobsetName %][% HTML.escape(build.jobset.get_column("project")) %]:[% HTML.escape(build.jobset.get_column("name")) %]:[% END %][% HTML.escape(build.get_column("job")) %]</a>
[% IF showStepName %]
[% INCLUDE renderDrvInfo step=build.buildsteps releasename=build.nixname %]
[% END %]
</td>
[% END %]
<td class="nowrap">[% t = showSchedulingInfo ? build.timestamp : build.stoptime; IF t; INCLUDE renderRelativeDate timestamp=(showSchedulingInfo ? build.timestamp : build.stoptime); ELSE; "-"; END %]</td>
<td>[% !showSchedulingInfo and build.get_column('releasename') ? build.get_column('releasename') : build.nixname %]</td>
<td class="nowrap"><tt>[% build.system %]</tt></td>
<td>[% !showSchedulingInfo and build.get_column('releasename') ? HTML.escape(build.get_column('releasename')) : HTML.escape(build.nixname) %]</td>
<td class="nowrap"><tt>[% build.system | html %]</tt></td>
[% IF showDescription %]
<td>[% build.description %]</td>
<td>[% HTML.escape(build.description) %]</td>
[% END %]
</tr>
[% END;
IF linkToAll %]
<tr><td class="centered" colspan="5"><a href="[% linkToAll %]"><em>More...</em></a></td></tr>
<tr><td class="centered" colspan="5"><a [% HTML.attributes(href => linkToAll) %]><em>More...</em></a></td></tr>
[% END;
END;
@@ -160,11 +176,11 @@ BLOCK renderBuildList;
END;
BLOCK renderLink %]<a href="[% uri %]">[% title %]</a>[% END;
BLOCK renderLink %]<a [% HTML.attributes(href => uri) %]>[% HTML.escape(title) %]</a>[% END;
BLOCK maybeLink;
IF uri %]<a [% HTML.attributes(href => uri, class => class); IF confirmmsg +%] onclick="javascript:return confirm('[% confirmmsg %]')"[% END %]>[% content %]</a>[% ELSE; content; END;
IF uri %]<a [% HTML.attributes(href => uri, class => class); IF confirmmsg +%] onclick="javascript:return confirm('[% confirmmsg %]')"[% END %]>[% HTML.escape(content) %]</a>[% ELSE; HTML.escape(content); END;
END;
@@ -176,7 +192,7 @@ BLOCK renderSelection;
<label class="radio inline">
<input type="radio" [% HTML.attributes(id => param, name => param, value => name) %]
[% IF name == curValue; "checked='1'"; END %]>
[% options.$name %]
[% HTML.escape(options.$name) %]
</input>
</label>
[% END %]
@@ -184,7 +200,7 @@ BLOCK renderSelection;
[% ELSE %]
<select class="custom-select" [% HTML.attributes(id => param, name => param) %]>
[% FOREACH name IN options.keys.sort %]
<option [% IF name == curValue; "selected='selected'"; END; " "; HTML.attributes(value => name) %]>[% options.$name %]</option>
<option [% IF name == curValue; "selected='selected'"; END; " "; HTML.attributes(value => name) %]>[% HTML.escape(options.$name) %]</option>
[% END %]
</select>
[% END;
@@ -200,12 +216,12 @@ BLOCK editString; %]
BLOCK renderFullBuildLink;
INCLUDE renderFullJobNameOfBuild build=build %] <a href="[% c.uri_for('/build' build.id) %]">build [% build.id %]</a>[%
INCLUDE renderFullJobNameOfBuild build=build %] <a [% HTML.attributes(href => c.uri_for('/build' build.id)) %]>build [% HTML.escape(build.id) %]</a>[%
END;
BLOCK renderBuildIdLink; %]
<a href="[% c.uri_for('/build' id) %]">build [% id %]</a>
<a [% HTML.attributes(href => c.uri_for('/build' id)) %]>build [% HTML.escape(id) %]</a>
[% END;
@@ -245,6 +261,27 @@ BLOCK renderBuildStatusIcon;
END;
BLOCK renderBusyStatus;
IF step.busy == 1 %]
<strong>Preparing</strong>
[% ELSIF step.busy == 10 %]
<strong>Connecting</strong>
[% ELSIF step.busy == 20 %]
<strong>Sending inputs</strong>
[% ELSIF step.busy == 30 %]
<strong>Building</strong>
[% ELSIF step.busy == 35 %]
<strong>Waiting to receive outputs</strong>
[% ELSIF step.busy == 40 %]
<strong>Receiving outputs</strong>
[% ELSIF step.busy == 50 %]
<strong>Post-processing</strong>
[% ELSE %]
<strong>Unknown state</strong>
[% END;
END;
BLOCK renderStatus;
IF build.finished;
buildstatus = build.buildstatus;
@@ -283,7 +320,7 @@ END;
BLOCK renderShortInputValue;
IF input.type == "build" || input.type == "sysbuild" %]
<a href="[% c.uri_for('/build' input.dependency.id) %]">[% input.dependency.id %]</a>
<a [% HTML.attributes(href => c.uri_for('/build' input.dependency.id)) %]>[% HTML.escape(input.dependency.id) %]</a>
[% ELSIF input.type == "string" %]
<tt>"[% HTML.escape(input.value) %]"</tt>
[% ELSIF input.type == "nix" || input.type == "boolean" %]
@@ -301,7 +338,7 @@ BLOCK renderDiffUri;
url = bi1.uri;
path = url.replace(base, '');
IF url.match(base) %]
<a target="_blank" href="[% m.uri.replace('_path_', path).replace('_1_', bi1.revision).replace('_2_', bi2.revision) %]">[% contents %]</a>
<a target="_blank" [% HTML.attributes(href => m.uri.replace('_path_', path).replace('_1_', bi1.revision).replace('_2_', bi2.revision)) %]>[% HTML.escape(contents) %]</a>
[% nouri = 0;
END;
END;
@@ -310,13 +347,13 @@ BLOCK renderDiffUri;
url = res.0;
branch = res.1;
IF bi1.type == "hg" || bi1.type == "git" %]
<a target="_blank" href="[% HTML.escape(c.uri_for('/api/scmdiff', {
<a target="_blank" [% HTML.attributes(href => c.uri_for('/api/scmdiff', {
uri = url,
rev1 = bi1.revision,
rev2 = bi2.revision,
type = bi1.type,
branch = branch
})) %]">[% contents %]</a>
})) %]>[% HTML.escape(contents) %]</a>
[% ELSE;
contents;
END;
@@ -332,8 +369,8 @@ BLOCK renderInputs; %]
<tbody>
[% FOREACH input IN inputs %]
<tr>
<td><tt>[% input.name %]</tt></td>
<td>[% type = input.type; inputTypes.$type %]</td>
<td><tt>[% input.name | html %]</tt></td>
<td>[% type = input.type; HTML.escape(inputTypes.$type) %]</td>
<td>
[% IF input.type == "build" || input.type == "sysbuild" %]
[% INCLUDE renderFullBuildLink build=input.dependency %]
@@ -346,7 +383,7 @@ BLOCK renderInputs; %]
[% END %]
</td>
<td>[% IF input.revision %][% HTML.escape(input.revision) %][% END %]</td>
<td><tt>[% input.path %]</tt></td>
<td><tt>[% input.path | html %]</tt></td>
</tr>
[% END %]
</tbody>
@@ -370,33 +407,33 @@ BLOCK renderInputDiff; %]
IF bi1.name == bi2.name;
IF bi1.type == bi2.type;
IF bi1.value != bi2.value || bi1.uri != bi2.uri %]
<tr><td><b>[% bi1.name %]</b></td><td><tt>[% INCLUDE renderShortInputValue input=bi1 %]</tt> to <tt>[% INCLUDE renderShortInputValue input=bi2 %]</tt></td></tr>
<tr><td><b>[% HTML.escape(bi1.name) %]</b></td><td><tt>[% INCLUDE renderShortInputValue input=bi1 %]</tt> to <tt>[% INCLUDE renderShortInputValue input=bi2 %]</tt></td></tr>
[% ELSIF bi1.uri == bi2.uri && bi1.revision != bi2.revision %]
[% IF bi1.type == "git" %]
<tr><td>
<b>[% bi1.name %]</b></td><td><tt>[% INCLUDE renderDiffUri contents=(bi1.revision.substr(0, 6) _ ' to ' _ bi2.revision.substr(0, 6)) %]</tt>
<b>[% HTML.escape(bi1.name) %]</b></td><td><tt>[% INCLUDE renderDiffUri contents=(bi1.revision.substr(0, 12) _ ' to ' _ bi2.revision.substr(0, 12)) %]</tt>
</td></tr>
[% ELSE %]
<tr><td>
<b>[% bi1.name %]</b></td><td><tt>[% INCLUDE renderDiffUri contents=(bi1.revision _ ' to ' _ bi2.revision) %]</tt>
<b>[% HTML.escape(bi1.name) %]</b></td><td><tt>[% INCLUDE renderDiffUri contents=(bi1.revision _ ' to ' _ bi2.revision) %]</tt>
</td></tr>
[% END %]
[% ELSIF bi1.dependency.id != bi2.dependency.id || bi1.path != bi2.path %]
<tr><td>
<b>[% bi1.name %]</b></td><td><tt>[% INCLUDE renderShortInputValue input=bi1 %]</tt> to <tt>[% INCLUDE renderShortInputValue input=bi2 %]</tt>
<b>[% HTML.escape(bi1.name) %]</b></td><td><tt>[% INCLUDE renderShortInputValue input=bi1 %]</tt> to <tt>[% INCLUDE renderShortInputValue input=bi2 %]</tt>
<br/>
<br/>
[% INCLUDE renderInputDiff inputs1=bi1.dependency.inputs inputs2=bi2.dependency.inputs nestedDiff=1 nestLevel=nestLevel+1 %]
</td></tr>
[% END %]
[% ELSE %]
<tr><td><b>[% bi1.name %]</b></td><td>Changed input type from '[% type = bi1.type; inputTypes.$type %]' to '[% type = bi2.type; inputTypes.$type %]'</td></tr>
<tr><td><b>[% HTML.escape(bi1.name) %]</b></td><td>Changed input type from '[% type = bi1.type; HTML.escape(inputTypes.$type) %]' to '[% type = bi2.type; HTML.escape(inputTypes.$type) %]'</td></tr>
[% END;
deletedInput = 0;
END;
END;
IF deletedInput == 1 %]
<tr><td><b>[% bi1.name %]</b></td><td>Input not present in this build.</td></tr>
<tr><td><b>[% HTML.escape(bi1.name) %]</b></td><td>Input not present in this build.</td></tr>
[% END;
END;
END %]
@@ -406,10 +443,10 @@ BLOCK renderInputDiff; %]
BLOCK renderPager %]
<ul class="pagination">
<li class="page-item[% IF page == 1 %] disabled[% END %]"><a class="page-link" href="[% "$baseUri?page=1" %]">&laquo; First</a></li>
<li class="page-item[% IF page == 1 %] disabled[% END %]"><a class="page-link" href="[% "$baseUri?page="; (page - 1) %]">&lsaquo; Previous</a></li>
<li class="page-item[% IF page * resultsPerPage >= total %] disabled[% END %]"><a class="page-link" href="[% "$baseUri?page="; (page + 1) %]">Next &rsaquo;</a></li>
<li class="page-item[% IF page * resultsPerPage >= total %] disabled[% END %]"><a class="page-link" href="[% "$baseUri?page="; (total - 1) div resultsPerPage + 1 %]">Last &raquo;</a></li>
<li class="page-item[% IF page == 1 %] disabled[% END %]"><a class="page-link" [% HTML.attributes(href => "$baseUri?page=1") %]>&laquo; First</a></li>
<li class="page-item[% IF page == 1 %] disabled[% END %]"><a class="page-link" [% HTML.attributes(href => "$baseUri?page=" _ (page - 1)) %]>&lsaquo; Previous</a></li>
<li class="page-item[% IF page * resultsPerPage >= total %] disabled[% END %]"><a class="page-link" [% HTML.attributes(href => "$baseUri?page=" _ (page + 1)) %]>Next &rsaquo;</a></li>
<li class="page-item[% IF page * resultsPerPage >= total %] disabled[% END %]"><a class="page-link" [% HTML.attributes(href => "$baseUri?page=" _ ((total - 1) div resultsPerPage + 1)) %]>Last &raquo;</a></li>
</ul>
[% END;
@@ -418,13 +455,13 @@ BLOCK renderShortEvalInput;
IF input.type == "svn" || input.type == "svn-checkout" || input.type == "bzr" || input.type == "bzr-checkout" %]
r[% input.revision %]
[% ELSIF input.type == "git" %]
<tt>[% input.revision.substr(0, 7) %]</tt>
<tt>[% input.revision.substr(0, 7) | html %]</tt>
[% ELSIF input.type == "hg" %]
<tt>[% input.revision.substr(0, 12) %]</tt>
<tt>[% input.revision.substr(0, 12) | html %]</tt>
[% ELSIF input.type == "build" || input.type == "sysbuild" %]
<a href="[% c.uri_for('/build' input.get_column('dependency')) %]">[% input.get_column('dependency') %]</a>
<a [% HTML.attributes(href => c.uri_for('/build' input.get_column('dependency'))) %]>[% HTML.escape(input.get_column('dependency')) %]</a>
[% ELSE %]
<tt>[% input.revision %]</tt>
<tt>[% input.revision | html %]</tt>
[% END;
END;
@@ -461,7 +498,7 @@ BLOCK renderEvals %]
eval = e.eval;
link = c.uri_for(c.controller('JobsetEval').action_for('view'), [eval.id]) %]
<tr>
<td><a class="row-link" href="[% link %]">[% eval.id %]</a></td>
<td><a class="row-link" [% HTML.attributes(href => link) %]>[% HTML.escape(eval.id) %]</a></td>
[% IF !jobset && !build %]
<td>[% INCLUDE renderFullJobsetName project=eval.jobset.project.name jobset=eval.jobset.name %]</td>
[% END %]
@@ -470,40 +507,40 @@ BLOCK renderEvals %]
[% IF e.changedInputs.size > 0;
sep='';
FOREACH input IN e.changedInputs;
sep; %] [% input.name %] → [% INCLUDE renderShortEvalInput input=input;
sep; %] [% HTML.escape(input.name) %] → [% INCLUDE renderShortEvalInput input=input;
sep=', ';
END;
ELSE %]
-
[% END %]
[% IF eval.evaluationerror.errormsg %]
[% IF eval.evaluationerror.has_error %]
<span class="badge badge-warning">Eval Errors</span>
[% END %]
</td>
<td align='right' class="nowrap">
<span class="badge badge-success">[% e.nrSucceeded %]</span>
<span class="badge badge-success">[% HTML.escape(e.nrSucceeded) %]</span>
</td>
<td align="right" class="nowrap">
[% IF e.nrFailed > 0 %]
<span class="badge badge-danger">[% e.nrFailed %]</span>
<span class="badge badge-danger">[% HTML.escape(e.nrFailed) %]</span>
[% END %]
</td>
<td align="right" class="nowrap">
[% IF e.nrScheduled > 0 %]
<span class="badge badge-secondary">[% e.nrScheduled %]</span>
<span class="badge badge-secondary">[% HTML.escape(e.nrScheduled) %]</span>
[% END %]
</td>
<td align='right' class="nowrap">
[% IF e.diff > 0 %]
<span class='badge badge-success'><strong>+[% e.diff %]</strong></span>
<span class='badge badge-success'><strong>+[% HTML.escape(e.diff) %]</strong></span>
[% ELSIF e.diff < 0 && e.nrScheduled == 0 %]
<span class='badge badge-danger'><strong>[% e.diff %]</strong></span>
<span class='badge badge-danger'><strong>[% HTML.escape(e.diff) %]</strong></span>
[% END %]
</td>
</tr>
[% END;
IF linkToAll %]
<tr><td class="centered" colspan="7"><a href="[% linkToAll %]"><em>More...</em></a></td></tr>
<tr><td class="centered" colspan="7"><a [% HTML.attributes(href => linkToAll) %]><em>More...</em></a></td></tr>
[% END %]
</tbody>
</table>
@@ -511,19 +548,19 @@ BLOCK renderEvals %]
BLOCK renderLogLinks %]
(<a [% IF inRow %]class="row-link"[% END %] href="[% url %]">log</a>, <a href="[% "$url/raw" %]">raw</a>, <a href="[% "$url/tail" %]">tail</a>)
(<a [% IF inRow %]class="row-link"[% END %] [% HTML.attributes(href => url) %]>log</a>, <a [% HTML.attributes(href => "$url/raw") %]>raw</a>, <a [% HTML.attributes(href => "$url/tail") %]>tail</a>)
[% END;
BLOCK makeLazyTab %]
<div id="[% tabName %]" class="tab-pane">
<div [% HTML.attributes(id => tabName) %] class="tab-pane">
<center><span class="spinner-border spinner-border-sm"/></center>
</div>
<script>
[% IF callback.defined %]
$(function() { makeLazyTab("[% tabName %]", "[% uri %]", [% callback %] ); });
$(function() { makeLazyTab("[% HTML.escape(tabName) %]", "[% uri %]", [% callback %] ); });
[% ELSE %]
$(function() { makeLazyTab("[% tabName %]", "[% uri %]", null ); });
$(function() { makeLazyTab("[% HTML.escape(tabName) %]", "[% uri %]", null ); });
[% END %]
</script>
[% END;
@@ -550,7 +587,7 @@ BLOCK navItem %]
<li class="nav-item">
<a class="nav-link[% IF "${root}${curUri}" == uri %] active[% END %]"
[% HTML.attributes(href => uri) %]>
[% title %]
[% HTML.escape(title) %]
</a>
</li>
[% END;
@@ -602,7 +639,7 @@ BLOCK renderJobsetOverview %]
<td>[% HTML.escape(j.description) %]</td>
<td>[% IF j.lastcheckedtime;
INCLUDE renderDateTime timestamp = j.lastcheckedtime;
IF j.errormsg || j.fetcherrormsg; %]&nbsp;<span class = 'badge badge-warning'>Error</span>[% END;
IF j.has_error || j.fetcherrormsg; %]&nbsp;<span class = 'badge badge-warning'>Error</span>[% END;
ELSE; "-";
END %]</td>
[% IF j.get_column('nrtotal') > 0 %]
@@ -620,17 +657,17 @@ BLOCK renderJobsetOverview %]
<td><span class="[% class %]">[% successrate FILTER format('%d') %]%</span></td>
<td>
[% IF j.get_column('nrsucceeded') > 0 %]
<span class="badge badge-success">[% j.get_column('nrsucceeded') %]</span>
<span class="badge badge-success">[% HTML.escape(j.get_column('nrsucceeded')) %]</span>
[% END %]
</td>
<td>
[% IF j.get_column('nrfailed') > 0 %]
<span class="badge badge-danger">[% j.get_column('nrfailed') %]</span>
<span class="badge badge-danger">[% HTML.escape(j.get_column('nrfailed')) %]</span>
[% END %]
</td>
<td>
[% IF j.get_column('nrscheduled') > 0 %]
<span class="badge badge-secondary">[% j.get_column('nrscheduled') %]</span>
<span class="badge badge-secondary">[% HTML.escape(j.get_column('nrscheduled')) %]</span>
[% END %]
</td>
</tr>
@@ -648,14 +685,22 @@ BLOCK includeFlot %]
[% END;
BLOCK renderYesNo %]
[% IF value %]
<span class="text-success">Yes</span>
[% ELSE %]
<span class="text-danger">No</span>
[% END %]
[% END;
BLOCK createChart %]
<div id="[%id%]-chart" style="width: 1000px; height: 400px;"></div>
<div id="[%id%]-overview" style="margin-top: 20px; margin-left: 50px; margin-right: 50px; width: 900px; height: 100px"></div>
<div id="[% id %]-chart" style="width: 1000px; height: 400px;"></div>
<div id="[% id %]-overview" style="margin-top: 20px; margin-left: 50px; margin-right: 50px; width: 900px; height: 100px"></div>
<script type="text/javascript">
$(function() {
showChart("[%id%]", "[%dataUrl%]", "[%yaxis%]");
showChart("[% HTML.escape(id) %]", "[% dataUrl %]", "[% yaxis %]");
});
</script>

View File

@@ -9,7 +9,7 @@
[% ELSE %]
<p>Below are the most recent builds of the [% builds.size %] jobs of which you
<p>Below are the most recent builds of the [% HTML.escape(builds.size) %] jobs of which you
(<tt>[% HTML.escape(user.emailaddress) %]</tt>) are a maintainer.</p>
[% INCLUDE renderBuildList %]

View File

@@ -24,7 +24,7 @@
<tr>
<td><span class="[% IF !jobExists(j.job.jobset j.job.job) %]disabled-job[% END %]">[% INCLUDE renderFullJobName project=j.job.get_column('project') jobset=j.job.get_column('jobset') job=j.job.job %]</span></td>
[% FOREACH b IN j.builds %]
<td><a href="[% c.uri_for('/build' b.id) %]">[% INCLUDE renderBuildStatusIcon size=16 build=b %]</a></td>
<td><a [% HTML.attributes(href => c.uri_for('/build' b.id)) %]>[% INCLUDE renderBuildStatusIcon size=16 build=b %]</a></td>
[% END %]
</tr>
[% END %]

View File

@@ -3,20 +3,20 @@
[% BLOCK renderNode %]
<li>
[% IF done.${node.path} %]
<tt>[% node.name %]</tt> (<a href="#[% done.${node.path} %]"><em>repeated</em></a>)
<tt>[% node.name | html %]</tt> (<a [% HTML.attributes(href => "#" _ done.${node.path}) %]><em>repeated</em></a>)
[% ELSE %]
[% done.${node.path} = global.nodeId; global.nodeId = global.nodeId + 1; %]
[% IF node.refs.size > 0 %]
<a href="javascript:" class="tree-toggle"></a>
[% END %]
<span id="[% done.${node.path} %]"><span class="dep-tree-line">
<span [% HTML.attributes(id => done.${node.path}) %]><span class="dep-tree-line">
[% IF node.buildStep %]
<a href="[% c.uri_for('/build' node.buildStep.get_column('build')) %]"><tt>[% node.name %]</tt></a> [%
<a [% HTML.attributes(href => c.uri_for('/build' node.buildStep.get_column('build'))) %]><tt>[% node.name %]</tt></a> [%
IF buildStepLogExists(node.buildStep);
INCLUDE renderLogLinks url=c.uri_for('/build' node.buildStep.get_column('build') 'nixlog' node.buildStep.stepnr);
END %]
[% ELSE %]
<tt>[% node.name %]</tt> (<em>no info</em>)
<tt>[% node.name | html %]</tt> (<em>no info</em>)
[% END %]
</span></span>
[% IF isRoot %]

View File

@@ -7,17 +7,17 @@
[% USE format %]
[% BLOCK renderJobsetInput %]
<tr class="input [% extraClass %]" [% IF id %]id="[% id %]"[% END %]>
<tr class="input [% extraClass %]" [% IF id %][% HTML.attributes(id => id) %][% END %]>
<td>
<button type="button" class="btn btn-warning" onclick='$(this).parents(".input").remove()'><i class="fas fa-trash"></i></button>
</td>
<td>
<input type="text" id="[% baseName %]-name" name="[% baseName %]-name" [% HTML.attributes(value => input.name) %]/>
<input type="text" [% HTML.attributes(id => baseName _ "-name", name => baseName _ "-name", value => input.name) %] />
</td>
<td>
[% INCLUDE renderSelection curValue=input.type param="$baseName-type" options=inputTypes edit=1 %]
</td>
<td id="[% baseName %]">
<td [% HTML.attributes(id => baseName) %]>
[% IF createFromEval %]
[% value = (input.uri or input.value); IF input.revision; value = value _ " " _ input.revision; END;
warn = input.altnr != 0;
@@ -36,7 +36,7 @@
<input style="width: 95%" type="text" [% HTML.attributes(value => value, id => "$baseName-value", name => "$baseName-value") %]/>
</td>
<td>
<input type="checkbox" id="[% baseName %]-emailresponsible" name="[% baseName %]-emailresponsible" [% IF input.emailresponsible; 'checked="checked"'; END %]/>
<input type="checkbox" [% HTML.attributes(id => "$baseName-emailresponsible", name => "$baseName-emailresponsible") %] [% IF input.emailresponsible; 'checked="checked"'; END %]/>
</td>
</tr>
[% END %]
@@ -149,7 +149,7 @@
<label class="col-sm-3" for="editjobsetschedulingshares">
Scheduling shares
[% IF totalShares %]
<small class="form-text text-muted">([% f = format("%.2f"); f(jobset.schedulingshares / totalShares * 100) %]% out of [% totalShares %] shares)</small>
<small class="form-text text-muted">([% f = format("%.2f"); f(jobset.schedulingshares / totalShares * 100) %]% out of [% HTML.escape(totalShares) %] shares)</small>
[% END %]
</label>
<div class="col-sm-9">
@@ -195,7 +195,7 @@
[% INCLUDE renderJobsetInputs %]
<button id="submit-jobset" type="submit" class="btn btn-primary"><i class="fas fa-check"></i> [%IF !edit %]Create jobset[% ELSE %]Apply changes[% END %]</button>
<button id="submit-jobset" type="submit" class="btn btn-primary"><i class="fas fa-check"></i> [% IF !edit %]Create jobset[% ELSE %]Apply changes[% END %]</button>
<table style="display: none">
[% INCLUDE renderJobsetInput input="" extraClass="template" id="input-template" baseName="input-template" %]

View File

@@ -86,7 +86,7 @@
<button id="submit-project" type="submit" class="btn btn-primary">
<i class="fas fa-check"></i>
[%IF create %]Create project[% ELSE %]Apply changes[% END %]
[% IF create %]Create project[% ELSE %]Apply changes[% END %]
</button>
</form>

26
src/root/eval-error.tt Normal file
View File

@@ -0,0 +1,26 @@
[% PROCESS common.tt %]
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
[% INCLUDE style.tt %]
</head>
<body>
<div class="tab-content tab-pane">
<div id="tabs-errors" class="">
[% IF eval %]
<p>Errors occurred at [% INCLUDE renderDateTime timestamp=(eval.evaluationerror.errortime || eval.timestamp) %].</p>
<div class="card bg-light"><div class="card-body"><pre>[% HTML.escape(eval.evaluationerror.errormsg) %]</pre></div></div>
[% ELSIF jobset %]
<p>Errors occurred at [% INCLUDE renderDateTime timestamp=(jobset.errortime || jobset.lastcheckedtime) %].</p>
<div class="card bg-light"><div class="card-body"><pre>[% HTML.escape(jobset.fetcherrormsg || jobset.errormsg) %]</pre></div></div>
[% END %]
</div>
</div>
</body>
</html>

View File

@@ -10,7 +10,7 @@
[% PROCESS common.tt %]
<p>Showing evaluations [% (page - 1) * resultsPerPage + 1 %] - [%
(page - 1) * resultsPerPage + evals.size %] out of [% total %].</p>
(page - 1) * resultsPerPage + evals.size %] out of [% HTML.escape(total) %].</p>
[% INCLUDE renderEvals %]

View File

@@ -16,10 +16,9 @@
[% FOREACH metric IN metrics %]
<h3>Metric: <a [% HTML.attributes(href => c.uri_for('/job' project.name jobset.name job 'metric' metric.name)) %]><tt>[%HTML.escape(metric.name)%]</tt></a></h3>
<h3>Metric: <a [% HTML.attributes(href => c.uri_for('/job' project.name jobset.name job 'metric' metric.name)) %]><tt>[% HTML.escape(metric.name) %]</tt></a></h3>
[% 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 %]

View File

@@ -9,9 +9,9 @@
[% INCLUDE includeFlot %]
[% IF !jobExists(jobset, job) %]
<div class="alert alert-warning">This job is not a member of the <a
href="[%c.uri_for('/jobset' project.name jobset.name
'evals')%]">latest evaluation</a> of its jobset. This means it was
<div class="alert alert-warning">This job is not a member of the
<a [% HTML.attributes(href => c.uri_for('/jobset' project.name jobset.name
'evals')) %]>latest evaluation</a> of its jobset. This means it was
removed or had an evaluation error.</div>
[% END %]
@@ -46,7 +46,7 @@ removed or had an evaluation error.</div>
its success or failure is determined entirely by the result of
building its <em>constituent jobs</em>. The table below shows
the status of each constituent job for the [%
aggregates.keys.size %] most recent builds of the
HTML.escape(aggregates.keys.size) %] most recent builds of the
aggregate.</div>
[% aggs = aggregates.keys.nsort.reverse %]
@@ -58,7 +58,7 @@ removed or had an evaluation error.</div>
<th class="rotate-45">
[% agg_ = aggregates.$agg %]
<div><span class="[% agg_.build.finished == 0 ? "text-info" : (agg_.build.buildstatus == 0 ? "text-success" : "text-warning") %] override-link">
<a href="[% c.uri_for('/build' agg) %]">[% agg %]</a>
<a [% HTML.attributes(href => c.uri_for('/build' agg)) %]>[% agg %]</a>
</span></div></th>
[% END %]
</tr>
@@ -70,7 +70,7 @@ removed or had an evaluation error.</div>
[% FOREACH agg IN aggs %]
<td>
[% r = aggregates.$agg.constituents.$j; IF r.id %]
<a href="[% c.uri_for('/build' r.id) %]">
<a [% HTML.attributes(href => c.uri_for('/build' r.id)) %]>
[% INCLUDE renderBuildStatusIcon size=16 build=r %]
</a>
[% END %]
@@ -89,8 +89,8 @@ removed or had an evaluation error.</div>
<div id="tabs-links" class="tab-pane">
<ul>
<li><a href="[% c.uri_for('/job' project.name jobset.name job 'latest') %]">Latest successful build</a></li>
<li><a href="[% c.uri_for('/job' project.name jobset.name job 'latest-finished') %]">Latest successful build from a finished evaluation</a></li>
<li><a [% HTML.attributes(href => c.uri_for('/job' project.name jobset.name job 'latest')) %]>Latest successful build</a></li>
<li><a [% HTML.attributes(href => c.uri_for('/job' project.name jobset.name job 'latest-finished')) %]>Latest successful build from a finished evaluation</a></li>
</ul>
</div>

View File

@@ -14,7 +14,7 @@
[% FOREACH eval IN evalIds %]
<th class="rotate-45">
<div><span>
<a href="[% c.uri_for('/eval' eval) %]">[% INCLUDE renderRelativeDate timestamp=evals.$eval.timestamp %]</a>
<a [% HTML.attributes(href => c.uri_for('/eval' eval)) %]>[% INCLUDE renderRelativeDate timestamp=evals.$eval.timestamp %]</a>
</span></div></th>
[% END %]
</tr>
@@ -22,9 +22,9 @@
<tbody>
[% FOREACH chan IN channels-%]
<tr>
<th><span><a href="[% c.uri_for('/channel/custom' project.name jobset.name chan) %]">[% chan %]</a></span></th>
<th><span><a [% HTML.attributes(href => c.uri_for('/channel/custom' project.name jobset.name chan)) %]>[% HTML.escape(chan) %]</a></span></th>
[% FOREACH eval IN evalIds %]
<td>[% r = evals.$eval.builds.$chan; IF r.id %]<a href="[% c.uri_for('/build' r.id) %]">[% INCLUDE renderBuildStatusIcon size=16 build=r %]</a>[% END %]</td>
<td>[% r = evals.$eval.builds.$chan; IF r.id %]<a [% HTML.attributes(href => c.uri_for('/build' r.id)) %]>[% INCLUDE renderBuildStatusIcon size=16 build=r %]</a>[% END %]</td>
[% END %]
</tr>
[% END %]

View File

@@ -13,25 +13,23 @@
<a class="dropdown-item" href="?compare=-[% 31 * 24 * 60 * 60 %]&full=[% full ? 1 : 0 %]">This jobset <strong>one month</strong> earlier</a>
[% IF project.jobsets_rs.count > 1 %]
<div class="dropdown-divider"></div>
[% FOREACH j IN project.jobsets.sort('name'); IF j.name != jobset.name %]
<a class="dropdown-item" href="?compare=[% j.name %]&full=[% full ? 1 : 0 %]">Jobset <tt>[% project.name %]:[% j.name %]</tt></a>
[% FOREACH j IN project.jobsets.sort('name'); IF j.name != jobset.name && j.enabled == 1 %]
<a class="dropdown-item" href="?compare=[% j.name | uri %]&full=[% full ? 1 : 0 %]">Jobset <tt>[% project.name | html %]:[% j.name | html %]</tt></a>
[% END; END %]
[% END %]
</div>
</div>
<p>This evaluation was performed [% IF eval.flake %]from the flake
<tt>[%HTML.escape(eval.flake)%]</tt>[%END%] on [% INCLUDE renderDateTime
<tt>[% HTML.escape(eval.flake) %]</tt>[% END %] on [% INCLUDE renderDateTime
timestamp=eval.timestamp %]. Fetching the dependencies took [%
eval.checkouttime %]s and evaluation took [% eval.evaltime %]s.</p>
eval.checkouttime %]s and evaluation took [% HTML.escape(eval.evaltime) %]s.</p>
[% IF otherEval %]
<p>Comparisons are relative to [% INCLUDE renderFullJobsetName
project=otherEval.jobset.project.name jobset=otherEval.jobset.name %] evaluation <a href="[%
c.uri_for(c.controller('JobsetEval').action_for('view'),
[otherEval.id]) %]">[% otherEval.id %]</a>.</p>
[% ELSE %]
<div class="alert alert-danger">Couldn't find an evaluation to compare to.</div>
project=otherEval.jobset.project.name jobset=otherEval.jobset.name %] evaluation <a [%
HTML.attributes(href => c.uri_for(c.controller('JobsetEval').action_for('view'),
[otherEval.id])) %]>[% HTML.escape(otherEval.id) %]</a>.</p>
[% END %]
<form>
@@ -47,50 +45,50 @@ c.uri_for(c.controller('JobsetEval').action_for('view'),
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-toggle="dropdown" href="#">Actions</a>
<div class="dropdown-menu">
<a class="dropdown-item" href="[% c.uri_for(c.controller('JobsetEval').action_for('create_jobset'), [eval.id]) %]">Create a jobset from this evaluation</a>
[% IF unfinished.size > 0 %]
<a class="dropdown-item" href="[% c.uri_for(c.controller('JobsetEval').action_for('cancel'), [eval.id]) %]">Cancel all scheduled builds</a>
<a class="dropdown-item" [% HTML.attributes(href => c.uri_for(c.controller('JobsetEval').action_for('create_jobset'), [eval.id])) %]>Create a jobset from this evaluation</a>
[% IF totalQueued > 0 %]
<a class="dropdown-item" [% HTML.attributes(href => c.uri_for(c.controller('JobsetEval').action_for('cancel'), [eval.id])) %]>Cancel all scheduled builds</a>
[% END %]
[% IF aborted.size > 0 || stillFail.size > 0 || nowFail.size > 0 || failed.size > 0 %]
<a class="dropdown-item" href="[% c.uri_for(c.controller('JobsetEval').action_for('restart_failed'), [eval.id]) %]">Restart all failed builds</a>
[% IF totalFailed > 0 %]
<a class="dropdown-item" [% HTML.attributes(href => c.uri_for(c.controller('JobsetEval').action_for('restart_failed'), [eval.id])) %]>Restart all failed builds</a>
[% END %]
[% IF aborted.size > 0 %]
<a class="dropdown-item" href="[% c.uri_for(c.controller('JobsetEval').action_for('restart_aborted'), [eval.id]) %]">Restart all aborted builds</a>
[% IF totalAborted > 0 %]
<a class="dropdown-item" [% HTML.attributes(href => c.uri_for(c.controller('JobsetEval').action_for('restart_aborted'), [eval.id])) %]>Restart all aborted builds</a>
[% END %]
[% IF unfinished.size > 0 %]
<a class="dropdown-item" href="[% c.uri_for(c.controller('JobsetEval').action_for('bump'), [eval.id]) %]">Bump builds to front of queue</a>
[% IF totalQueued > 0 %]
<a class="dropdown-item" [% HTML.attributes(href => c.uri_for(c.controller('JobsetEval').action_for('bump'), [eval.id])) %]>Bump builds to front of queue</a>
[% END %]
</div>
</li>
[% END %]
[% IF aborted.size > 0 %]
<li class="nav-item"><a class="nav-link" href="#tabs-aborted" data-toggle="tab"><span class="text-warning">Aborted Jobs ([% aborted.size %])</span></a></li>
<li class="nav-item"><a class="nav-link" href="#tabs-aborted" data-toggle="tab"><span class="text-warning">Aborted / Timed out Jobs ([% HTML.escape(aborted.size) %])</span></a></li>
[% END %]
[% IF nowFail.size > 0 %]
<li class="nav-item"><a class="nav-link" href="#tabs-now-fail" data-toggle="tab"><span class="text-warning">Newly Failing Jobs ([% nowFail.size %])</span></a></li>
<li class="nav-item"><a class="nav-link" href="#tabs-now-fail" data-toggle="tab"><span class="text-warning">Newly Failing Jobs ([% HTML.escape(nowFail.size) %])</span></a></li>
[% END %]
[% IF nowSucceed.size > 0 %]
<li class="nav-item"><a class="nav-link" href="#tabs-now-succeed" data-toggle="tab"><span class="text-success">Newly Succeeding Jobs ([% nowSucceed.size %])</span></a></li>
<li class="nav-item"><a class="nav-link" href="#tabs-now-succeed" data-toggle="tab"><span class="text-success">Newly Succeeding Jobs ([% HTML.escape(nowSucceed.size) %])</span></a></li>
[% END %]
[% IF new.size > 0 %]
<li class="nav-item"><a class="nav-link" href="#tabs-new" data-toggle="tab">New Jobs ([% new.size %])</a></li>
<li class="nav-item"><a class="nav-link" href="#tabs-new" data-toggle="tab">New Jobs ([% HTML.escape(new.size) %])</a></li>
[% END %]
[% IF removed.size > 0 %]
<li class="nav-item"><a class="nav-link" href="#tabs-removed" data-toggle="tab">Removed Jobs ([% removed.size %])</a></li>
<li class="nav-item"><a class="nav-link" href="#tabs-removed" data-toggle="tab">Removed Jobs ([% HTML.escape(removed.size) %])</a></li>
[% END %]
[% IF stillFail.size > 0 %]
<li class="nav-item"><a class="nav-link" href="#tabs-still-fail" data-toggle="tab">Still Failing Jobs ([% stillFail.size %])</a></li>
<li class="nav-item"><a class="nav-link" href="#tabs-still-fail" data-toggle="tab">Still Failing Jobs ([% HTML.escape(stillFail.size) %])</a></li>
[% END %]
[% IF stillSucceed.size > 0 %]
<li class="nav-item"><a class="nav-link" href="#tabs-still-succeed" data-toggle="tab">Still Succeeding Jobs ([% stillSucceed.size %])</a></li>
<li class="nav-item"><a class="nav-link" href="#tabs-still-succeed" data-toggle="tab">Still Succeeding Jobs ([% HTML.escape(stillSucceed.size) %])</a></li>
[% END %]
[% IF unfinished.size > 0 %]
<li class="nav-item"><a class="nav-link" href="#tabs-unfinished" data-toggle="tab">Queued Jobs ([% unfinished.size %])</a></li>
<li class="nav-item"><a class="nav-link" href="#tabs-unfinished" data-toggle="tab">Queued Jobs ([% HTML.escape(unfinished.size) %])</a></li>
[% END %]
<li class="nav-item"><a class="nav-link" href="#tabs-inputs" data-toggle="tab">Inputs</a></li>
[% IF eval.evaluationerror.errormsg %]
[% IF eval.evaluationerror.has_error %]
<li class="nav-item"><a class="nav-link" href="#tabs-errors" data-toggle="tab"><span class="text-warning">Evaluation Errors</span></a></li>
[% END %]
</ul>
@@ -101,20 +99,13 @@ c.uri_for(c.controller('JobsetEval').action_for('view'),
[% INCLUDE renderBuildListBody builds=builds.slice(0, (size > max ? max : size) - 1)
hideProjectName=1 hideJobsetName=1 busy=0 %]
[% IF size > max; params = c.req.params; params.full = 1 %]
<tr><td class="centered" colspan="6"><a href="[% c.uri_for(c.controller('JobsetEval').action_for('view'), [eval.id], params) %][% tabname %]"><em>([% size - max %] more builds omitted)</em></a></td></tr>
<tr><td class="centered" colspan="6"><a [% HTML.attributes(href => c.uri_for(c.controller('JobsetEval').action_for('view'), [eval.id], params) _ tabname) %]><em>([% size - max %] more builds omitted)</em></a></td></tr>
[% END %]
[% INCLUDE renderBuildListFooter %]
[% END %]
<div class="tab-content">
[% IF eval.evaluationerror.errormsg %]
<div id="tabs-errors" class="tab-pane">
<p>Errors occurred at [% INCLUDE renderDateTime timestamp=(eval.evaluationerror.errortime || eval.timestamp) %].</p>
<div class="card bg-light"><div class="card-body"><pre>[% HTML.escape(eval.evaluationerror.errormsg) %]</pre></div></div>
</div>
[% END %]
<div id="tabs-aborted" class="tab-pane">
[% INCLUDE renderSome builds=aborted tabname="#tabs-aborted" %]
</div>
@@ -141,11 +132,11 @@ c.uri_for(c.controller('JobsetEval').action_for('view'),
[% FOREACH j IN removed.slice(0,(size > max ? max : size) - 1) %]
<tr>
<td>[% INCLUDE renderJobName project=project.name jobset=jobset.name job=j.job %]</td>
<td><tt>[% j.system %]</tt></td>
<td><tt>[% j.system | html %]</tt></td>
</tr>
[% END %]
[% IF size > max; params = c.req.params; params.full = 1 %]
<tr><td class="centered" colspan="2"><a href="[% c.uri_for(c.controller('JobsetEval').action_for('view'), [eval.id], params) %]#tabs-removed"><em>([% size - max %] more jobs omitted)</em></a></td></tr>
<tr><td class="centered" colspan="2"><a [% HTML.attributes(c.uri_for(c.controller('JobsetEval').action_for('view'), [eval.id], params) _ "#tabs-removed") %]><em>([% size - max %] more jobs omitted)</em></a></td></tr>
[% END %]
</tbody>
</table>
@@ -172,10 +163,9 @@ c.uri_for(c.controller('JobsetEval').action_for('view'),
[% END %]
</div>
[% IF eval.evaluationerror.errormsg %]
[% IF eval.evaluationerror.has_error %]
<div id="tabs-errors" class="tab-pane">
<p>Errors occurred at [% INCLUDE renderDateTime timestamp=(eval.evaluationerror.errortime || eval.timestamp) %].</p>
<div class="card bg-light"><div class="card-body"><pre>[% HTML.escape(eval.evaluationerror.errormsg) %]</pre></div></div>
<iframe src="[% c.uri_for(c.controller('JobsetEval').action_for('errors'), [eval.id], params) %]" loading="lazy" frameBorder="0" width="100%"></iframe>
</div>
[% END %]
</div>

View File

@@ -41,7 +41,7 @@
[% ELSE %]
[% IF nrJobs > jobs.size %]
<div class="alert alert-info">Showing the first [% jobs.size %] jobs. <a href="javascript:setFilter('filter=%')">Show all [% nrJobs %] jobs...</a></div>
<div class="alert alert-info">Showing the first [% HTML.escape(jobs.size) %] jobs. <a href="javascript:setFilter('filter=%')">Show all [% HTML.escape(nrJobs) %] jobs...</a></div>
[% END %]
[% evalIds = evals.keys.nsort.reverse %]
@@ -52,7 +52,7 @@
[% FOREACH eval IN evalIds %]
<th class="rotate-45">
<div><span>
<a href="[% c.uri_for('/eval' eval) %]">[% INCLUDE renderRelativeDate timestamp=evals.$eval.timestamp %]</a>
<a [% HTML.attributes(href => c.uri_for('/eval' eval)) %]>[% INCLUDE renderRelativeDate timestamp=evals.$eval.timestamp %]</a>
</span></div></th>
[% END %]
</tr>
@@ -62,7 +62,7 @@
<tr>
<th><span [% IF inactiveJobs.$j %]class="muted override-link"[% END %]>[% INCLUDE renderJobName project=project.name jobset=jobset.name job=j %]</span></th>
[% FOREACH eval IN evalIds %]
<td>[% r = evals.$eval.builds.$j; IF r.id %]<a href="[% c.uri_for('/build' r.id) %]">[% INCLUDE renderBuildStatusIcon size=16 build=r %]</a>[% END %]</td>
<td>[% r = evals.$eval.builds.$j; IF r.id %]<a [% HTML.attributes(href => c.uri_for('/build' r.id)) %]>[% INCLUDE renderBuildStatusIcon size=16 build=r %]</a>[% END %]</td>
[% END %]
</tr>
[% END %]

View File

@@ -6,14 +6,14 @@
[% BLOCK renderJobsetInput %]
<tr class="input [% extraClass %]" [% IF id %]id="[% id %]"[% END %]>
<tr class="input [% extraClass %]" [% IF id %][% HTML.attributes(id => id) %][% END %]>
<td>
<tt>[% HTML.escape(input.name) %]</tt>
</td>
<td>
[% INCLUDE renderSelection curValue=input.type param="$baseName-type" options=inputTypes %]
</td>
<td class="inputalts" id="[% baseName %]">
<td class="inputalts" [% HTML.attributes(id => baseName) %]>
[% FOREACH alt IN input.search_related('jobsetinputalts', {}, { order_by => 'altnr' }) %]
<tt class="inputalt">
[% IF input.type == "string" %]
@@ -61,7 +61,7 @@
[% END %]
<li class="nav-item"><a class="nav-link active" href="#tabs-evaluations" data-toggle="tab">Evaluations</a></li>
[% IF jobset.errormsg || jobset.fetcherrormsg %]
[% IF jobset.has_error || jobset.fetcherrormsg %]
<li class="nav-item"><a class="nav-link" href="#tabs-errors" data-toggle="tab"><span class="text-warning">Evaluation Errors</span></a></li>
[% END %]
<li class="nav-item"><a class="nav-link" href="#tabs-jobs" data-toggle="tab">Jobs</a></li>
@@ -79,7 +79,7 @@
<th>Last checked:</th>
<td>
[% IF jobset.lastcheckedtime %]
[% INCLUDE renderDateTime timestamp = jobset.lastcheckedtime %], [% IF jobset.errormsg || jobset.fetcherrormsg %]<em class="text-warning">with errors!</em>[% ELSE %]<em>no errors</em>[% END %]
[% INCLUDE renderDateTime timestamp = jobset.lastcheckedtime %], [% IF jobset.has_error || jobset.fetcherrormsg %]<em class="text-warning">with errors!</em>[% ELSE %]<em>no errors</em>[% END %]
[% ELSE %]
<em>never</em>
[% END %]
@@ -117,10 +117,9 @@
</div>
[% IF jobset.errormsg || jobset.fetcherrormsg %]
[% IF jobset.has_error || jobset.fetcherrormsg %]
<div id="tabs-errors" class="tab-pane">
<p>Errors occurred at [% INCLUDE renderDateTime timestamp=(jobset.errortime || jobset.lastcheckedtime) %].</p>
<div class="card bg-light"><div class="card-body"><pre>[% HTML.escape(jobset.fetcherrormsg || jobset.errormsg) %]</pre></div></div>
<iframe src="[% c.uri_for('/jobset' project.name jobset.name "errors") %]" loading="lazy" frameBorder="0" width="100%"></iframe>
</div>
[% END %]
@@ -154,11 +153,11 @@
[% END %]
<tr>
<th>Check interval:</th>
<td>[% jobset.checkinterval || "<em>disabled</em>" %]</td>
<td>[% HTML.escape(jobset.checkinterval) || "<em>disabled</em>" %]</td>
</tr>
<tr>
<th>Scheduling shares:</th>
<td>[% jobset.schedulingshares %] [% IF totalShares %] ([% f = format("%.2f"); f(jobset.schedulingshares / totalShares * 100) %]% out of [% totalShares %] shares)[% END %]</td>
<td>[% HTML.escape(jobset.schedulingshares) %] [% IF totalShares %] ([% f = format("%.2f"); f(jobset.schedulingshares / totalShares * 100) %]% out of [% HTML.escape(totalShares) %] shares)[% END %]</td>
</tr>
<tr>
<th>Enable Dynamic RunCommand Hooks:</th>
@@ -176,7 +175,7 @@
[% END %]
<tr>
<th>Number of evaluations to keep:</th>
<td>[% jobset.keepnr %]</td>
<td>[% HTML.escape(jobset.keepnr) %]</td>
</tr>
</table>
@@ -189,7 +188,7 @@
<div id="tabs-links" class="tab-pane">
<ul>
<li><a href="[% c.uri_for(c.controller('Jobset').action_for('latest_eval'), c.req.captures) %]">Latest finished evaluation</a></li>
<li><a [% HTML.attributes(href => c.uri_for(c.controller('Jobset').action_for('latest_eval'), c.req.captures)) %]>Latest finished evaluation</a></li>
</ul>
</div>
@@ -205,6 +204,7 @@
if (!c) return;
requestJSON({
url: "[% HTML.escape(c.uri_for('/api/push', { jobsets = project.name _ ':' _ jobset.name, force = "1" })) %]",
type: 'POST',
success: function(data) {
bootbox.alert("The jobset has been scheduled for evaluation.");
}

View File

@@ -10,31 +10,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<script type="text/javascript" src="[% c.uri_for("/static/js/jquery/jquery-3.4.1.min.js") %]"></script>
<script type="text/javascript" src="[% c.uri_for("/static/js/jquery/jquery-ui-1.10.4.min.js") %]"></script>
<script type="text/javascript" src="[% c.uri_for("/static/js/moment/moment-2.24.0.min.js") %]"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="[% c.uri_for("/static/fontawesome/css/all.css") %]" rel="stylesheet" />
<script type="text/javascript" src="[% c.uri_for("/static/js/popper.min.js") %]"></script>
<script type="text/javascript" src="[% c.uri_for("/static/bootstrap/js/bootstrap.min.js") %]"></script>
<link href="[% c.uri_for("/static/bootstrap/css/bootstrap.min.css") %]" rel="stylesheet" />
<!-- hydra.css may need to be moved to before boostrap to make the @media rule work. -->
<link rel="stylesheet" href="[% c.uri_for("/static/css/hydra.css") %]" type="text/css" />
<link rel="stylesheet" href="[% c.uri_for("/static/css/rotated-th.css") %]" type="text/css" />
<style>
.popover { max-width: 40%; }
</style>
<script type="text/javascript" src="[% c.uri_for("/static/js/bootbox.min.js") %]"></script>
<link rel="stylesheet" href="[% c.uri_for("/static/css/tree.css") %]" type="text/css" />
<script type="text/javascript" src="[% c.uri_for("/static/js/common.js") %]"></script>
[% INCLUDE style.tt %]
[% IF c.config.enable_google_login %]
<meta name="google-signin-client_id" content="[% c.config.google_client_id %]">
@@ -48,7 +24,7 @@
<nav class="navbar navbar-expand-md navbar-light bg-light">
<div class="container">
<a class="navbar-brand" href="[% c.uri_for(c.controller('Root').action_for('index')) %]">
<a class="navbar-brand" [% HTML.attributes(href => c.uri_for(c.controller('Root').action_for('index'))) %]>
[% IF logo == "" %]
Hydra
[% ELSE %]
@@ -93,7 +69,7 @@
<footer class="navbar">
<hr />
<small>
<em><a href="http://nixos.org/hydra" target="_blank">Hydra</a> [% HTML.escape(version) %] (using [% HTML.escape(nixVersion) %]).</em>
<em><a href="http://nixos.org/hydra" target="_blank" class="squiggle">Hydra</a> [% HTML.escape(version) %] (using [% HTML.escape(nixVersion) %] and [% HTML.escape(nixEvalJobsVersion) %]).</em>
[% IF c.user_exists %]
You are signed in as <tt>[% HTML.escape(c.user.username) %]</tt>
[%- IF c.user.type == 'google' %] via Google[% END %].

View File

@@ -11,13 +11,14 @@
[% ELSE %]
is
[% END %]
the build log of derivation <tt>[% IF step; step.drvpath; ELSE; build.drvpath; END %]</tt>.
the build log (<a [% HTML.attributes(href => step ? c.uri_for('/build' build.id 'nixlog' step.stepnr, 'raw')
: c.uri_for('/build' build.id 'log', 'raw')) %]>raw</a>) of derivation <tt>[% IF step; step.drvpath; ELSE; build.drvpath; END %]</tt>.
[% IF step && step.machine %]
It was built on <tt>[% step.machine %]</tt>.
It was built on <tt>[% step.machine | html %]</tt>.
[% END %]
[% IF tail %]
The <a href="[% step ? c.uri_for('/build' build.id 'nixlog' step.stepnr)
: c.uri_for('/build' build.id 'log') %]">full log</a> is also available.
The <a [% HTML.attributes(href => step ? c.uri_for('/build' build.id 'nixlog' step.stepnr)
: c.uri_for('/build' build.id 'log')) %]>full log</a> is also available.
[% END %]
</p>
@@ -36,7 +37,7 @@
[% IF tail %]
/* The server may give us a full log (e.g. if the log is in
S3). So extract the last lines. */
log_data = log_data.split("\n").slice(-[%tail%]).join("\n");
log_data = log_data.split("\n").slice(-[% HTML.escape(tail) %]).join("\n");
[% END %]
$("#contents").text(log_data);

View File

@@ -6,10 +6,10 @@
<thead>
<tr>
<th>Job</th>
<th>System</th>
<th>Build</th>
<th>Step</th>
<th>What</th>
<th>Status</th>
<th>Since</th>
</tr>
</thead>
@@ -17,12 +17,48 @@
[% name = m.key ? stripSSHUser(m.key) : "localhost" %]
<thead>
<tr>
<th colspan="6">
<th colspan="7">
<tt [% IF m.value.disabled %]style="text-decoration: line-through;"[% END %]>[% INCLUDE renderMachineName machine=m.key %]</tt>
[% IF m.value.systemTypes %]
[% IF m.value.primarySystemType %]
<span class="muted" style="font-weight: normal;">
([% comma=0; FOREACH system IN m.value.systemTypes %][% IF comma; %], [% ELSE; comma = 1; END %]<tt>[% system %]</tt>[% END %])
(<tt>[% m.value.primarySystemType | html %]</tt>)
</span>
&nbsp;
[% WRAPPER makePopover title="Details" classes="btn-secondary btn-sm" %]
<ul class="list-unstyled mb-0">
<li><b>System types:&nbsp;</b>[% comma=0; FOREACH system IN m.value.systemTypes %][% IF comma; %], [% ELSE; comma = 1; END %]<tt>[% system | html%]</tt>[% END %]</li>
<li><b>Supported Features:&nbsp;</b>[% comma=0; FOREACH feat IN m.value.supportedFeatures %][% IF comma; %], [% ELSE; comma = 1; END %]<tt>[% feat| html %]</tt>[% END %]</li>
<li><b>Mandatory Features:&nbsp;</b>[% comma=0; FOREACH feat IN m.value.mandatoryFeatures %][% IF comma; %], [% ELSE; comma = 1; END %]<tt>[% feat| html %]</tt>[% END %]</li>
<li><b>Capacity:&nbsp;</b>[% INCLUDE renderYesNo value=m.value.hasCapacity %]&nbsp;<b>Static:&nbsp;</b>[% INCLUDE renderYesNo value=m.value.hasStaticCapacity %]&nbsp;<b>Dynamic:&nbsp;</b>[% INCLUDE renderYesNo value=m.value.hasDynamicCapacity %]</li>
<li><b>Scheduling Score:&nbsp;</b>[% HTML.escape(m.value.score) %]</li>
<li><b>Load:&nbsp;</b><tt>[% pretty_load(m.value.stats.load1) | html %]</tt>&nbsp;&nbsp;&nbsp;<tt>[% pretty_load(m.value.stats.load5) | html %]</tt>&nbsp;&nbsp;&nbsp;<tt>[% pretty_load(m.value.stats.load15) | html %]</tt></li>
<li><b>Memory:&nbsp;</b><tt>[% human_bytes(m.value.stats.memUsage) | html %]</tt> of <tt>[% human_bytes(m.value.memTotal) | html %]</tt> used (<tt>[% human_bytes(m.value.memTotal - m.value.stats.memUsage) | html %]</tt> free)</li>
[% pressure = m.value.stats.pressure %]
[% MACRO render_pressure(title, pressure) BLOCK %]
[% IF pressure %]
<tr><td><b>[% HTML.escape(title) %]:</b></td><td><tt>[% pretty_percent(pressure.avg10) %]%</tt></td><td><td><tt>[% pretty_percent(pressure.avg60) %]%</tt></td><td><td><tt>[% pretty_percent(pressure.avg300) %]%</tt></td><td>
[% END %]
[% END %]
[% IF pressure %]
<li><b>Pressure:&nbsp;</b>
<table class="pressureTable">
[% render_pressure('Some CPU', pressure.cpuSome) %]
[% render_pressure('Some IO', pressure.ioSome) %]
[% render_pressure('Full IO', pressure.ioFull) %]
[% render_pressure('Full IRQ', pressure.irqFull) %]
[% render_pressure('Some Memory', pressure.memSome) %]
[% render_pressure('Full Memory', pressure.memFull) %]
</table>
</li>
[% END %]
</ul>
[% END %]
[% ELSE %]
[% IF m.value.systemTypes %]
<span class="muted" style="font-weight: normal;">
([% comma=0; FOREACH system IN m.value.systemTypes %][% IF comma; %], [% ELSE; comma = 1; END %]<tt>[% system | html %]</tt>[% END %])
</span>
[% END %]
[% END %]
[% IF m.value.nrStepsDone %]
<span class="muted" style="font-weight: normal;">
@@ -40,10 +76,10 @@
[% idle = 0 %]
<tr>
<td><tt>[% INCLUDE renderFullJobName project=step.project jobset=step.jobset job=step.job %]</tt></td>
<td><tt>[% step.system %]</tt></td>
<td><a href="[% c.uri_for('/build' step.build) %]">[% step.build %]</a></td>
<td>[% IF step.busy >= 30 %]<a class="row-link" href="[% c.uri_for('/build' step.build 'nixlog' step.stepnr 'tail') %]">[% step.stepnr %]</a>[% ELSE; step.stepnr; END %]</td>
<td><tt>[% step.drvpath.match('-(.*)').0 %]</tt></td>
<td><a [% HTML.attributes(href => c.uri_for('/build' step.build)) %]>[% HTML.escape(step.build) %]</a></td>
<td>[% IF step.busy >= 30 %]<a class="row-link" [% HTML.attributes(href => c.uri_for('/build' step.build 'nixlog' step.stepnr 'tail')) %]>[% HTML.escape(step.stepnr) %]</a>[% ELSE; HTML.escape(step.stepnr); END %]</td>
<td><tt>[% step.drvpath.match('-(.*)').0 | html %]</tt></td>
<td>[% INCLUDE renderBusyStatus %]</td>
<td style="width: 10em">[% INCLUDE renderDuration duration = curTime - step.starttime %] </td>
</tr>
[% END %]

View File

@@ -15,11 +15,11 @@
[% FOREACH m IN machines %]
<tr>
<td><input type="checkbox" name="enabled" [% IF m.value.maxJobs > 0 %]CHECKED[% END %] disabled="true" /></td>
<td>[% m.key %]</a></td>
<td>[% m.value.maxJobs %]</td>
<td>[% m.value.speedFactor %]</td>
<td>[% HTML.escape(m.key) %]</a></td>
<td>[% HTML.escape(m.value.maxJobs) %]</td>
<td>[% HTML.escape(m.value.speedFactor) %]</td>
<td>
[% comma=0; FOREACH system IN m.value.systemTypes %][% IF comma; %], [% ELSE; comma = 1; END; system; END %]
[% comma=0; FOREACH system IN m.value.systemTypes %][% IF comma; %], [% ELSE; comma = 1; END; HTML.escape(system); END %]
</td>
</tr>
[% END %]

View File

@@ -6,7 +6,7 @@
[% FOREACH i IN newsItems %]
<div class="news-item">
[% contents = String.new(i.contents) %]
<h4 class="alert-heading">[% INCLUDE renderDateTime timestamp=i.createtime %] by [% i.author.fullname %]</h4>
<h4 class="alert-heading">[% INCLUDE renderDateTime timestamp=i.createtime %] by [% HTML.escape(i.author.fullname) %]</h4>
[% contents.replace('\n','<br />\n') %]
</div>
[% END %]
@@ -65,7 +65,7 @@
[% ELSE %]
<div class="alert alert-warning">Hydra has no projects yet. Please
<a href="[% c.uri_for(c.controller('Project').action_for('create')) %]">create a project</a>.</div>
<a [% HTML.attributes(href => c.uri_for(c.controller('Project').action_for('create'))) %]>create a project</a>.</div>
[% END %]

View File

@@ -1,17 +1,17 @@
[% BLOCK renderProductLinks %]
<tr>
<th>URL:</th>
<td><a href="[% uri %]"><tt>[% uri %]</tt></a></td>
<td><a [% HTML.attributes(href => uri) %]><tt>[% uri | html %]</tt></a></td>
</tr>
[% IF latestRoot %]
<tr>
<th>Links to latest:</th>
<td>
[% uri2 = "${c.uri_for(latestRoot.join('/') 'download-by-type' product.type product.subtype)}" %]
<a href="[% uri2 %]"><tt>[% uri2 %]</tt></a>
<a [% HTML.attributes(href => uri2) %]><tt>[% uri2 | html %]</tt></a>
<br />
[% uri2 = "${c.uri_for(latestRoot.join('/') 'download' product.productnr)}" %]
<a href="[% uri2 %]"><tt>[% uri2 %]</tt></a>
<a [% HTML.attributes(href => uri2) %]><tt>[% uri2 | html %]</tt></a>
</td>
</tr>
[% END %]
@@ -49,7 +49,7 @@
Error
</td>
<td>
<a href="[% contents %]">
<a [% HTML.attributes(href => contents) %]>
Failed build produced output. Click here to inspect the output.
</a>
</td>
@@ -58,9 +58,9 @@
<p>If you have Nix installed on your machine, this failed build output and
all its dependencies can be unpacked into your local Nix store by doing:</p>
<div class="card bg-light"><div class="card-body p-2"><code><span class="shell-prompt">$ </span>curl [% uri %] | gunzip | nix-store --import</code></div></div>
<div class="card bg-light"><div class="card-body p-2"><code><span class="shell-prompt">$ </span>curl [% HTML.escape(uri) %] | gunzip | nix-store --import</code></div></div>
<p>The build output can then be found in the path <tt>[% product.path %]</tt>.</p>
<p>The build output can then be found in the path <tt>[% product.path | html %]</tt>.</p>
[% END %]
</td>
</tr>
@@ -74,17 +74,17 @@
Nix package
</td>
<td>
<tt>[% HTML.escape(build.nixname) %]</tt>
<tt>[% build.nixname | html %]</tt>
</td>
<td>
[% WRAPPER makePopover title="Help" classes="btn-secondary btn-sm"
%] <p>You can install this package using the Nix package
manager from the command-line:</p>
<div class="card bg-light"><div class="card-body p-2"><code><span class="shell-prompt">$ </span>nix-env -i [%HTML.escape(product.path)%][% IF binaryCachePublicUri %] --option binary-caches [% HTML.escape(binaryCachePublicUri) %][% END %]</code></div></div>
<div class="card bg-light"><div class="card-body p-2"><code><span class="shell-prompt">$ </span>nix-env -i [% HTML.escape(product.path) %][% IF binaryCachePublicUri %] --option binary-caches [% HTML.escape(binaryCachePublicUri) %][% END %]</code></div></div>
[% END %]
[% IF localStore %]
<a class="btn btn-secondary btn-sm" href="[% contents %]">Contents</a>
<a class="btn btn-secondary btn-sm" [% HTML.attributes(href => contents) %]>Contents</a>
[% END %]
</td>
</tr>
@@ -100,8 +100,8 @@
[% filename = build.nixname _ (product.subtype ? "-" _ product.subtype : "") _ ".closure.gz" %]
[% uri = c.uri_for('/build' build.id 'nix' 'closure' filename ) %]
<a href="[% uri %]">
<tt>[% product.path %]</tt>
<a [% HTML.attributes(href => uri) %]>
<tt>[% product.path | html %]</tt>
</a>
</td>
<td>
@@ -110,16 +110,16 @@
all its dependencies can be unpacked into your local Nix
store by doing:</p>
<div class="card bg-light"><div class="card-body p-2"><code><span class="shell-prompt">$ </span>gunzip &lt; [% filename %] | nix-store --import</code></div></div>
<div class="card bg-light"><div class="card-body p-2"><code><span class="shell-prompt">$ </span>gunzip &lt; [% HTML.escape(filename) %] | nix-store --import</code></div></div>
<p>or to download and unpack in one command:</p>
<div class="card bg-light"><div class="card-body p-2"><code><span class="shell-prompt">$ </span>curl [% uri %] | gunzip | nix-store --import</code></div></div>
<div class="card bg-light"><div class="card-body p-2"><code><span class="shell-prompt">$ </span>curl [% HTML.escape(uri) %] | gunzip | nix-store --import</code></div></div>
<p>The package can then be found in the path <tt>[%
product.path %]</tt>. Youll probably also want to do</p>
product.path | html %]</tt>. Youll probably also want to do</p>
<div class="card bg-light"><div class="card-body p-2"><code><span class="shell-prompt">$ </span>nix-env -i [% product.path %]</code></div></div>
<div class="card bg-light"><div class="card-body p-2"><code><span class="shell-prompt">$ </span>nix-env -i [% HTML.escape(product.path) %]</code></div></div>
<p>to actually install the package in your Nix user environment.</p>
@@ -174,16 +174,16 @@
</td>
<td>
Channel expression tarball
[% IF product.subtype != "-" %]for <tt>[% product.subtype %]</tt>[% END %]
[% IF product.subtype != "-" %]for <tt>[% product.subtype | html %]</tt>[% END %]
</td>
[% ELSE %]
<td>File</td>
<td>[% product.subtype %]</td>
<td>[% HTML.escape(product.subtype) %]</td>
[% END %]
[% END %]
<td>
<a href="[% uri %]">
<tt>[% product.name %]</tt>
<a [% HTML.attributes(href => uri) %]>
<tt>[% product.name | html %]</tt>
</a>
</td>
<td>
@@ -191,12 +191,12 @@
<table class="info-table">
[% INCLUDE renderProductLinks %]
<tr><th>File size:</th><td>[% product.filesize %] bytes ([% mibs(product.filesize / (1024 * 1024)) %] MiB)</td></tr>
<tr><th>SHA-256 hash:</th><td><tt>[% product.sha256hash %]</tt></td></tr>
<tr><th>Full path:</th><td><tt>[% product.path %]</tt></td></tr>
<tr><th>SHA-256 hash:</th><td><tt>[% product.sha256hash | html %]</tt></td></tr>
<tr><th>Full path:</th><td><tt>[% product.path | html %]</tt></td></tr>
</table>
[% END %]
[% IF localStore %]
<a class="btn btn-secondary btn-sm" href="[% contents %]">Contents</a>
<a class="btn btn-secondary btn-sm" [% HTML.attributes(href => contents) %]>Contents</a>
[% END %]
</td>
</tr>
@@ -211,15 +211,15 @@
[% CASE "coverage" %]
<td>Code coverage</td>
<td>
<a href="[% uri %]">
<a [% HTML.attributes(href => uri) %]>
Analysis report
</a>
</td>
[% CASE DEFAULT %]
<td>Report</td>
<td>
<a href="[% uri %]">
<tt>[% product.subtype %]</tt>
<a [% HTML.attributes(href => uri) %]>
<tt>[% product.subtype | html %]</tt>
</a>
</td>
[% END %]
@@ -240,7 +240,7 @@
Documentation
</td>
<td>
<a href="[% uri %]">
<a [% HTML.attributes(href => uri) %]>
[% SWITCH product.subtype %]
[% CASE "readme" %]
Read Me!
@@ -249,7 +249,7 @@
[% CASE "release-notes" %]
Release notes
[% CASE DEFAULT %]
[% product.subtype %]
[% HTML.escape(product.subtype) %]
[% END %]
</a>
</td>
@@ -266,12 +266,12 @@
<tr class="product">
<td>
<tt>[% product.type %]</tt>
<tt>[% product.type | html %]</tt>
</td>
<td>
</td>
<td>
[% product %]
[% HTML.escape(product) %]
</td>
<td>
</td>

View File

@@ -39,7 +39,7 @@
[% FOREACH s IN systems %]
<tr>
<td><tt>[% HTML.escape(s.system) %]</tt></td>
<td>[% s.c %]</td>
<td>[% HTML.escape(s.c) %]</td>
</tr>
[% END %]
</tdata>

View File

@@ -7,7 +7,7 @@ main() {
set -e
tmpDir=${TMPDIR:-/tmp}/build-[% build.id +%]
tmpDir=$(realpath "${TMPDIR:-/tmp}")/build-[% build.id +%]
declare -a args extraArgs

View File

@@ -12,9 +12,9 @@
is
[% END %]
the output of a RunCommand execution of the command <tt>[% HTML.escape(runcommandlog.command) %]</tt>
on <a href="[% c.uri_for('/build', build.id) %]">Build [% build.id %]</a>.
on <a [% HTML.attributes(href => c.uri_for('/build', build.id)) %]>Build [% HTML.escape(build.id) %]</a>.
[% IF tail %]
The <a href="[% c.uri_for('/build', build.id, 'runcommandlog', runcommandlog.uuid) %]">full log</a> is also available.
The <a [% HTML.attributes(href => c.uri_for('/build', build.id, 'runcommandlog', runcommandlog.uuid)) %]>full log</a> is also available.
[% END %]
</p>
@@ -33,7 +33,7 @@
[% IF tail %]
/* The server may give us a full log (e.g. if the log is in
S3). So extract the last lines. */
log_data = log_data.split("\n").slice(-[%tail%]).join("\n");
log_data = log_data.split("\n").slice(-[% HTML.escape(tail) %]).join("\n");
[% END %]
$("#contents").text(log_data);

View File

@@ -7,7 +7,7 @@
[% IF builds.size > 0 %]
<p>The following builds match your query:[% IF builds.size > limit %] <span class="text-warning">(first [% limit %] results only)</span>[% END %]</p>
<p>The following builds match your query:[% IF builds.size > limit %] <span class="text-warning">(first [% HTML.escape(limit) %] results only)</span>[% END %]</p>
[% INCLUDE renderBuildList %]
@@ -58,7 +58,7 @@
[% IF jobs.size > 0; matched = 1 %]
<p>The following jobs match your query:[% IF jobs.size > limit %] <span class="text-warning">(first [% limit %] results only)</span>[% END %]</p>
<p>The following jobs match your query:[% IF jobs.size > limit %] <span class="text-warning">(first [% HTML.escape(limit) %] results only)</span>[% END %]</p>
<table class="table table-striped table-condensed clickable-rows">
<thead>

View File

@@ -171,12 +171,30 @@ body {
line-height: 1.5;
}
a.squiggle:hover {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg id='squiggle-link' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' xmlns:ev='http://www.w3.org/2001/xml-events' viewBox='0 0 10 18'%3E%3Cstyle type='text/css'%3E.squiggle{animation:shift .5s linear infinite;}@keyframes shift {from {transform:translateX(-10px);}to {transform:translateX(0);}}%3C/style%3E%3Cpath fill='none' stroke='%230056b3' stroke-width='0.65' class='squiggle' d='M0,17.5 c 2.5,0,2.5,-1.5,5,-1.5 s 2.5,1.5,5,1.5 c 2.5,0,2.5,-1.5,5,-1.5 s 2.5,1.5,5,1.5' /%3E%3C/svg%3E");
background-position: 0 100%;
background-size: auto 24px;
background-repeat: repeat;
text-decoration: none;
border-bottom: none;
padding-bottom: 1px;
}
table.pressureTable {
margin-left: 2em;
}
table.pressureTable td {
padding: 0 .4em;
}
@media (prefers-color-scheme: dark) {
/* Prevent some flickering */
html {
background-color: #1f1f1f;
}
body, div.popover {
body, div.popover, div.popover-body {
background-color: #1f1f1f;
color: #fafafa !important;
}

View File

@@ -129,6 +129,12 @@ $(document).ready(function() {
el.addClass("is-local");
}
});
[...document.getElementsByTagName("iframe")].forEach((element) => {
element.contentWindow.addEventListener("DOMContentLoaded", (_) => {
element.style.height = element.contentWindow.document.body.scrollHeight + 'px';
})
})
});
var tabsLoaded = {};

View File

@@ -7,7 +7,7 @@
[% ELSE %]
[% INCLUDE renderBuildList builds=resource showSchedulingInfo=1 hideResultInfo=1 busy=1 %]
[% INCLUDE renderBuildList builds=resource showSchedulingInfo=1 hideResultInfo=1 busy=1 showStepName=1 %]
[% END %]

View File

@@ -2,7 +2,7 @@
[% PROCESS common.tt %]
<p>Showing steps [% (page - 1) * resultsPerPage + 1 %] - [% (page - 1)
* resultsPerPage + steps.size %] of about [% total %] in
* resultsPerPage + steps.size %] of about [% HTML.escape(total) %] in
order of descending finish time.</p>
<table class="table table-striped table-condensed clickable-rows">
@@ -24,8 +24,8 @@ order of descending finish time.</p>
<td>[% INCLUDE renderBuildStatusIcon buildstatus=step.status size=16 %]</td>
<td><tt>[% step.drvpath.match('-(.*).drv').0 %]</tt></td>
<td><tt>[% INCLUDE renderFullJobNameOfBuild build=step.build %]</tt></td>
<td><a href="[% c.uri_for('/build' step.build.id) %]">[% step.build.id %]</a></td>
<td><a class="row-link" href="[% c.uri_for('/build' step.build.id 'nixlog' step.stepnr 'tail') %]">[% step.stepnr %]</a></td>
<td><a [% HTML.attributes(href => c.uri_for('/build' step.build.id)) %]>[% HTML.escape(step.build.id) %]</a></td>
<td><a class="row-link" [% HTML.attributes(href => c.uri_for('/build' step.build.id 'nixlog' step.stepnr 'tail')) %]>[% HTML.escape(step.stepnr) %]</a></td>
<td>[% INCLUDE renderRelativeDate timestamp=step.stoptime %]</td>
<td style="width: 10em">[% INCLUDE renderDuration duration = step.stoptime - step.starttime %] </td>
<td><tt>[% INCLUDE renderMachineName machine=step.machine %]</tt></td>

24
src/root/style.tt Normal file
View File

@@ -0,0 +1,24 @@
<script type="text/javascript" src="[% c.uri_for("/static/js/jquery/jquery-3.4.1.min.js") %]"></script>
<script type="text/javascript" src="[% c.uri_for("/static/js/jquery/jquery-ui-1.10.4.min.js") %]"></script>
<script type="text/javascript" src="[% c.uri_for("/static/js/moment/moment-2.24.0.min.js") %]"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link [% HTML.attributes(href => c.uri_for("/static/fontawesome/css/all.css")) %] rel="stylesheet" />
<script type="text/javascript" src="[% c.uri_for("/static/js/popper.min.js") %]"></script>
<script type="text/javascript" src="[% c.uri_for("/static/bootstrap/js/bootstrap.min.js") %]"></script>
<link [% HTML.attributes(href => c.uri_for("/static/bootstrap/css/bootstrap.min.css")) %] rel="stylesheet" />
<!-- hydra.css may need to be moved to before boostrap to make the @media rule work. -->
<link rel="stylesheet" [% HTML.attributes(href => c.uri_for("/static/css/hydra.css")) %] type="text/css" />
<link rel="stylesheet" [% HTML.attributes(href => c.uri_for("/static/css/rotated-th.css")) %] type="text/css" />
<style>
.popover { max-width: 40%; }
</style>
<script type="text/javascript" src="[% c.uri_for("/static/js/bootbox.min.js") %]"></script>
<link rel="stylesheet" [% HTML.attributes(href => c.uri_for("/static/css/tree.css")) %] type="text/css" />
<script type="text/javascript" src="[% c.uri_for("/static/js/common.js") %]"></script>

View File

@@ -1,6 +1,6 @@
[% BLOCK makeSubMenu %]
<li class="nav-item dropdown" [% IF id; HTML.attributes(id => id); END %] >
<a class="nav-link dropdown-toggle" href="#" data-toggle="dropdown">[% title %]<b class="caret"></b></a>
<a class="nav-link dropdown-toggle" href="#" data-toggle="dropdown">[% HTML.escape(title) %]<b class="caret"></b></a>
<div class="dropdown-menu[% IF align == 'right' %] dropdown-menu-right[% END %]">
[% content %]
</div>
@@ -34,6 +34,9 @@
[% INCLUDE menuItem
uri = c.uri_for(c.controller('Root').action_for('steps'))
title = "Latest steps" %]
[% INCLUDE menuItem
uri = c.uri_for(c.controller('Root').action_for('queue_runner_status'))
title = "Queue Runner Status" %]
[% END %]
[% IF project %]
@@ -42,7 +45,7 @@
<div class="dropdown-divider"></div>
[% INCLUDE menuItem uri = c.uri_for(c.controller('Project').action_for('project'), [project.name]) title = "Overview" %]
[% INCLUDE menuItem uri = c.uri_for(c.controller('Project').action_for('all'), [project.name]) title = "Latest builds" %]
[% INCLUDE menuItem uri = c.uri_for('/project' project.name 'channel' 'latest') title = "Channel" %]
[% IF localStore %][% INCLUDE menuItem uri = c.uri_for('/project' project.name 'channel' 'latest') title = "Channel" %][% END %]
[% END %]
[% END %]
@@ -59,7 +62,7 @@
[% INCLUDE menuItem
uri = c.uri_for(c.controller('Jobset').action_for('all'), [project.name, jobset.name])
title = "Latest builds" %]
[% INCLUDE menuItem uri = c.uri_for('/jobset' project.name jobset.name 'channel' 'latest') title = "Channel" %]
[% IF localStore %][% INCLUDE menuItem uri = c.uri_for('/jobset' project.name jobset.name 'channel' 'latest') title = "Channel" %][% END %]
[% END %]
[% END %]
@@ -73,7 +76,7 @@
[% INCLUDE menuItem
uri = c.uri_for(c.controller('Job').action_for('all'), [project.name, jobset.name, job])
title = "Latest builds" %]
[% INCLUDE menuItem uri = c.uri_for('/job' project.name jobset.name job 'channel' 'latest') title = "Channel" %]
[% IF localStore %][% INCLUDE menuItem uri = c.uri_for('/job' project.name jobset.name job 'channel' 'latest') title = "Channel" %][% END %]
[% END %]
[% END %]
@@ -133,12 +136,14 @@
[% ELSE %]
[% WRAPPER makeSubMenu title="Sign in" id="sign-in-menu" align="right" %]
[% IF c.config.enable_google_login %]
<div style="display: none" class="g-signin2" data-onsuccess="onGoogleSignIn" data-theme="dark"></div>
<a class="dropdown-item" href="#" id="google-signin">Sign in with Google</a>
<script src="https://accounts.google.com/gsi/client" async defer></script>
<div id="g_id_onload" data-client_id="[% c.config.google_client_id %]" data-auto_prompt="false" data-callback="onGoogleSignIn">
</div>
<div class="g_id_signin" data-type="standard"></div>
<div class="dropdown-divider"></div>
[% END %]
[% IF c.config.github_client_id %]
<a class="dropdown-item" href="/github-redirect?after=[% c.req.path %]">Sign in with GitHub</a>
<a class="dropdown-item" href="/github-redirect?after=[% c.req.path | uri %]">Sign in with GitHub</a>
<div class="dropdown-divider"></div>
[% END %]
<a class="dropdown-item" href="#hydra-signin" data-toggle="modal">Sign in with a Hydra account</a>

View File

@@ -17,7 +17,7 @@
disabled="disabled"
[% END %]
[% HTML.attributes(id => "role-${role}", value => role) %] />
<label [% HTML.attributes(for => "role-${role}") %]> [% role %]</label><br />
<label [% HTML.attributes(for => "role-${role}") %]> [% HTML.escape(role) %]</label><br />
[% END %]
<form>
@@ -91,6 +91,7 @@
[% INCLUDE roleoption mutable=mutable role="restart-jobs" %]
[% INCLUDE roleoption mutable=mutable role="bump-to-front" %]
[% INCLUDE roleoption mutable=mutable role="cancel-build" %]
[% INCLUDE roleoption mutable=mutable role="eval-jobset" %]
</p>
</div>
</div>

View File

@@ -14,17 +14,17 @@
<tbody>
[% FOREACH u IN users %]
<tr>
<td><a class="row-link" href="[% c.uri_for(c.controller('User').action_for('edit'), [u.username]) %]">[% HTML.escape(u.username) %]</a></td>
<td><a class="row-link" [% HTML.attributes(href => c.uri_for(c.controller('User').action_for('edit'), [u.username])) %]>[% HTML.escape(u.username) %]</a></td>
<td>[% HTML.escape(u.fullname) %]</td>
<td>[% HTML.escape(u.emailaddress) %]</td>
<td>[% FOREACH r IN u.userroles %]<i>[% r.role %]</i> [% END %]</td>
<td>[% FOREACH r IN u.userroles %]<i>[% HTML.escape(r.role) %]</i> [% END %]</td>
<td>[% IF u.emailonerror %]Yes[% ELSE %]No[% END %]</td>
</tr>
[% END %]
</tbody>
</table>
<a class="btn btn-primary" href="[% c.uri_for(c.controller('Root').action_for('register')) %]">
<a class="btn btn-primary" [% HTML.attributes(href => c.uri_for(c.controller('Root').action_for('register'))) %]>
<i class="fas fa-plus"></i> Add a new user
</a>

View File

@@ -1,19 +0,0 @@
EXTRA_DIST = \
$(distributable_scripts)
distributable_scripts = \
hydra-backfill-ids \
hydra-init \
hydra-eval-jobset \
hydra-server \
hydra-update-gc-roots \
hydra-s3-backup-collect-garbage \
hydra-create-user \
hydra-notify \
hydra-send-stats \
nix-prefetch-git \
nix-prefetch-bzr \
nix-prefetch-hg
bin_SCRIPTS = \
$(distributable_scripts)

View File

@@ -17,6 +17,7 @@ use Hydra::Helper::Nix;
use Hydra::Model::DB;
use Hydra::Plugin;
use Hydra::Schema;
use IPC::Run;
use JSON::MaybeXS;
use Net::Statsd;
use Nix::Store;
@@ -85,14 +86,14 @@ sub attrsToSQL {
# Fetch a store path from 'eval_substituter' if not already present.
sub getPath {
my ($path) = @_;
return 1 if isValidPath($path);
return 1 if $MACHINE_LOCAL_STORE->isValidPath($path);
my $substituter = $config->{eval_substituter};
system("nix", "--experimental-features", "nix-command", "copy", "--from", $substituter, "--", $path)
if defined $substituter;
return isValidPath($path);
return $MACHINE_LOCAL_STORE->isValidPath($path);
}
@@ -143,7 +144,7 @@ sub fetchInputBuild {
, version => $version
, outputName => $mainOutput->name
};
if (isValidPath($prevBuild->drvpath)) {
if ($MACHINE_LOCAL_STORE->isValidPath($prevBuild->drvpath)) {
$result->{drvPath} = $prevBuild->drvpath;
}
@@ -233,7 +234,7 @@ sub fetchInputEval {
my $out = $build->buildoutputs->find({ name => "out" });
next unless defined $out;
# FIXME: Should we fail if the path is not valid?
next unless isValidPath($out->path);
next unless $MACHINE_LOCAL_STORE->isValidPath($out->path);
$jobs->{$build->get_column('job')} = $out->path;
}
@@ -357,22 +358,39 @@ sub evalJobs {
my @cmd;
if (defined $flakeRef) {
@cmd = ("hydra-eval-jobs",
"--flake", $flakeRef,
"--gc-roots-dir", getGCRootsDir,
"--max-jobs", 1);
my $nix_expr =
"let " .
"flake = builtins.getFlake (toString \"$flakeRef\"); " .
"in " .
"flake.hydraJobs " .
"or flake.checks " .
"or (throw \"flake '$flakeRef' does not provide any Hydra jobs or checks\")";
@cmd = ("nix-eval-jobs",
# Disable the eval cache to prevent SQLite database contention.
# Since Hydra typically evaluates each revision only once,
# parallel workers would compete for database locks without
# providing any benefit from caching.
"--option", "eval-cache", "false",
"--expr", $nix_expr);
} else {
my $nixExprInput = $inputInfo->{$nixExprInputName}->[0]
or die "cannot find the input containing the job expression\n";
@cmd = ("hydra-eval-jobs",
@cmd = ("nix-eval-jobs",
"--option", "restrict-eval", "true",
"<" . $nixExprInputName . "/" . $nixExprPath . ">",
"--gc-roots-dir", getGCRootsDir,
"--max-jobs", 1,
inputsToArgs($inputInfo));
}
push @cmd, "--no-allow-import-from-derivation" if $config->{allow_import_from_derivation} // "true" ne "true";
push @cmd, ("--gc-roots-dir", getGCRootsDir);
push @cmd, ("--max-jobs", 1);
push @cmd, "--meta";
push @cmd, "--constituents";
push @cmd, "--force-recurse";
push @cmd, ("--option", "allow-import-from-derivation", "false") if ($config->{allow_import_from_derivation} // "false") ne "true";
push @cmd, ("--workers", $config->{evaluator_workers} // 1);
push @cmd, ("--max-memory-size", $config->{evaluator_max_memory_size} // 4096);
if (defined $ENV{'HYDRA_DEBUG'}) {
sub escape {
@@ -384,14 +402,45 @@ sub evalJobs {
print STDERR "evaluator: @escaped\n";
}
(my $res, my $jobsJSON, my $stderr) = captureStdoutStderr(21600, @cmd);
die "hydra-eval-jobs returned " . ($res & 127 ? "signal $res" : "exit code " . ($res >> 8))
. ":\n" . ($stderr ? decode("utf-8", $stderr) : "(no output)\n")
if $res;
# Unset NIX_PATH for nix-eval-jobs to ensure reproducible evaluations
my %env = %ENV;
delete $env{'NIX_PATH'};
print STDERR "$stderr";
my $evalProc = IPC::Run::start \@cmd,
'>', IPC::Run::new_chunker, \my $out,
'2>', \my $err,
init => sub { %ENV = %env; };
return decode_json($jobsJSON);
return sub {
while (1) {
$evalProc->pump;
if (!defined $out && !defined $err) {
$evalProc->finish;
if ($?) {
die "nix-eval-jobs returned " . ($? & 127 ? "signal $?" : "exit code " . ($? >> 8)) . "\n";
}
return;
}
if (defined $err) {
print STDERR "$err";
undef $err;
}
if (defined $out && $out ne '') {
my $job;
try {
$job = decode_json($out);
} catch {
warn "nix-eval-jobs sent invalid JSON.\n parse error: $_\n invalid json: $out\n";
};
undef $out;
if (defined $job) {
return $job;
}
}
}
};
}
@@ -420,7 +469,7 @@ sub checkBuild {
my $firstOutputName = $outputNames[0];
my $firstOutputPath = $buildInfo->{outputs}->{$firstOutputName};
my $jobName = $buildInfo->{jobName} or die;
my $jobName = $buildInfo->{attr} or die;
my $drvPath = $buildInfo->{drvPath} or die;
my $build;
@@ -438,13 +487,17 @@ sub checkBuild {
# new build to be scheduled if the meta.maintainers field is
# changed?
if (defined $prevEval) {
my $pathOrDrvConstraint = defined $firstOutputPath
? { path => $firstOutputPath }
: { drvPath => $drvPath };
my ($prevBuild) = $prevEval->builds->search(
# The "project" and "jobset" constraints are
# semantically unnecessary (because they're implied by
# the eval), but they give a factor 1000 speedup on
# the Nixpkgs jobset with PostgreSQL.
{ jobset_id => $jobset->get_column('id'), job => $jobName,
name => $firstOutputName, path => $firstOutputPath },
name => $firstOutputName, %$pathOrDrvConstraint },
{ rows => 1, columns => ['id', 'finished'], join => ['buildoutputs'] });
if (defined $prevBuild) {
#print STDERR " already scheduled/built as build ", $prevBuild->id, "\n";
@@ -470,9 +523,30 @@ sub checkBuild {
my $time = time();
sub null {
my ($s) = @_;
return $s eq "" ? undef : $s;
sub getMeta {
my ($s, $def) = @_;
return ($s || "") eq "" ? $def : $s;
}
sub getMetaStrings {
my ($v, $k, $acc) = @_;
my $t = ref $v;
if ($t eq 'HASH') {
push @$acc, $v->{$k} if exists $v->{$k};
} elsif ($t eq 'ARRAY') {
getMetaStrings($_, $k, $acc) foreach @$v;
} elsif (defined $v) {
push @$acc, $v;
}
}
sub getMetaConcatStrings {
my ($v, $k) = @_;
my @strings;
getMetaStrings($v, $k, \@strings);
return join(", ", @strings) || undef;
}
# Add the build to the database.
@@ -480,19 +554,19 @@ sub checkBuild {
{ timestamp => $time
, jobset_id => $jobset->id
, job => $jobName
, description => null($buildInfo->{description})
, license => null($buildInfo->{license})
, homepage => null($buildInfo->{homepage})
, maintainers => null($buildInfo->{maintainers})
, maxsilent => $buildInfo->{maxSilent}
, timeout => $buildInfo->{timeout}
, nixname => $buildInfo->{nixName}
, description => getMeta($buildInfo->{meta}->{description}, undef)
, license => getMetaConcatStrings($buildInfo->{meta}->{license}, "shortName")
, homepage => getMeta($buildInfo->{meta}->{homepage}, undef)
, maintainers => getMetaConcatStrings($buildInfo->{meta}->{maintainers}, "email")
, maxsilent => getMeta($buildInfo->{meta}->{maxSilent}, 7200)
, timeout => getMeta($buildInfo->{meta}->{timeout}, 36000)
, nixname => $buildInfo->{name}
, drvpath => $drvPath
, system => $buildInfo->{system}
, priority => $buildInfo->{schedulingPriority}
, priority => getMeta($buildInfo->{meta}->{schedulingPriority}, 100)
, finished => 0
, iscurrent => 1
, ischannel => $buildInfo->{isChannel}
, ischannel => getMeta($buildInfo->{meta}->{isChannel}, 0)
});
$build->buildoutputs->create({ name => $_, path => $buildInfo->{outputs}->{$_} })
@@ -661,7 +735,7 @@ sub checkJobsetWrapped {
return;
}
# Hash the arguments to hydra-eval-jobs and check the
# Hash the arguments to nix-eval-jobs and check the
# JobsetInputHashes to see if the previous evaluation had the same
# inputs. If so, bail out.
my @args = ($jobset->nixexprinput // "", $jobset->nixexprpath // "", inputsToArgs($inputInfo));
@@ -683,19 +757,12 @@ sub checkJobsetWrapped {
# Evaluate the job expression.
my $evalStart = clock_gettime(CLOCK_MONOTONIC);
my $jobs = evalJobs($project->name . ":" . $jobset->name, $inputInfo, $jobset->nixexprinput, $jobset->nixexprpath, $flakeRef);
my $evalStop = clock_gettime(CLOCK_MONOTONIC);
if ($jobsetsJobset) {
my @keys = keys %$jobs;
die "The .jobsets jobset must only have a single job named 'jobsets'"
unless (scalar @keys) == 1 && $keys[0] eq "jobsets";
}
Net::Statsd::timing("hydra.evaluator.eval_time", int(($evalStop - $evalStart) * 1000));
my $evalStop;
my $jobsIter = evalJobs($project->name . ":" . $jobset->name, $inputInfo, $jobset->nixexprinput, $jobset->nixexprpath, $flakeRef);
if ($dryRun) {
foreach my $name (keys %{$jobs}) {
my $job = $jobs->{$name};
while (defined(my $job = $jobsIter->())) {
my $name = $job->{attr};
if (defined $job->{drvPath}) {
print STDERR "good job $name: $job->{drvPath}\n";
} else {
@@ -705,36 +772,23 @@ sub checkJobsetWrapped {
return;
}
die "Jobset contains a job with an empty name. Make sure the jobset evaluates to an attrset of jobs.\n"
if defined $jobs->{""};
$jobs->{$_}->{jobName} = $_ for keys %{$jobs};
my $jobOutPathMap = {};
my $jobsetChanged = 0;
my $dbStart = clock_gettime(CLOCK_MONOTONIC);
# Store the error messages for jobs that failed to evaluate.
my $evaluationErrorTime = time;
my $evaluationErrorMsg = "";
foreach my $job (values %{$jobs}) {
next unless defined $job->{error};
$evaluationErrorMsg .=
($job->{jobName} ne "" ? "in job $job->{jobName}" : "at top-level") .
":\n" . $job->{error} . "\n\n";
}
setJobsetError($jobset, $evaluationErrorMsg, $evaluationErrorTime);
my $evaluationErrorRecord = $db->resultset('EvaluationErrors')->create(
{ errormsg => $evaluationErrorMsg
, errortime => $evaluationErrorTime
}
);
my $jobOutPathMap = {};
my $jobsetChanged = 0;
my %buildMap;
$db->txn_do(sub {
my @jobs;
push @jobs, $_ while defined($_ = $jobsIter->());
$db->txn_do(sub {
my $prevEval = getPrevJobsetEval($db, $jobset, 1);
# Clear the "current" flag on all builds. Since we're in a
@@ -747,7 +801,7 @@ sub checkJobsetWrapped {
, evaluationerror => $evaluationErrorRecord
, timestamp => time
, checkouttime => abs(int($checkoutStop - $checkoutStart))
, evaltime => abs(int($evalStop - $evalStart))
, evaltime => 0
, hasnewbuilds => 0
, nrbuilds => 0
, flake => $flakeRef
@@ -755,11 +809,24 @@ sub checkJobsetWrapped {
, nixexprpath => $jobset->nixexprpath
});
# Schedule each successfully evaluated job.
foreach my $job (permute(values %{$jobs})) {
next if defined $job->{error};
#print STDERR "considering job " . $project->name, ":", $jobset->name, ":", $job->{jobName} . "\n";
checkBuild($db, $jobset, $ev, $inputInfo, $job, \%buildMap, $prevEval, $jobOutPathMap, $plugins);
my @jobsWithConstituents;
foreach my $job (@jobs) {
if ($jobsetsJobset) {
die "The .jobsets jobset must only have a single job named 'jobsets'"
unless $job->{attr} eq "jobsets";
}
$evaluationErrorMsg .=
($job->{attr} ne "" ? "in job $job->{attr}" : "at top-level") .
":\n" . $job->{error} . "\n\n" if defined $job->{error};
checkBuild($db, $jobset, $ev, $inputInfo, $job, \%buildMap, $prevEval, $jobOutPathMap, $plugins)
unless defined $job->{error};
if (defined $job->{constituents}) {
push @jobsWithConstituents, $job;
}
}
# Have any builds been added or removed since last time?
@@ -797,21 +864,20 @@ sub checkJobsetWrapped {
$drvPathToId{$x->{drvPath}} = $x;
}
foreach my $job (values %{$jobs}) {
next unless $job->{constituents};
foreach my $job (values @jobsWithConstituents) {
next unless defined $job->{constituents};
if (defined $job->{error}) {
die "aggregate job $job->{jobName} failed with the error: $job->{error}\n";
die "aggregate job $job->{attr} failed with the error: $job->{error}\n";
}
my $x = $drvPathToId{$job->{drvPath}} or
die "aggregate job $job->{jobName} has no corresponding build record.\n";
die "aggregate job $job->{attr} has no corresponding build record.\n";
foreach my $drvPath (@{$job->{constituents}}) {
my $constituent = $drvPathToId{$drvPath};
if (defined $constituent) {
$db->resultset('AggregateConstituents')->update_or_create({aggregate => $x->{id}, constituent => $constituent->{id}});
} else {
warn "aggregate job $job->{jobName} has a constituent $drvPath that doesn't correspond to a Hydra build\n";
warn "aggregate job $job->{attr} has a constituent $drvPath that doesn't correspond to a Hydra build\n";
}
}
}
@@ -853,11 +919,15 @@ sub checkJobsetWrapped {
$jobset->update({ enabled => 0 }) if $jobset->enabled == 2;
$jobset->update({ lastcheckedtime => time, forceeval => undef });
$evaluationErrorRecord->update({ errormsg => $evaluationErrorMsg });
setJobsetError($jobset, $evaluationErrorMsg, $evaluationErrorTime);
$evalStop = clock_gettime(CLOCK_MONOTONIC);
$ev->update({ evaltime => abs(int($evalStop - $evalStart)) });
});
my $dbStop = clock_gettime(CLOCK_MONOTONIC);
Net::Statsd::timing("hydra.evaluator.db_time", int(($dbStop - $dbStart) * 1000));
Net::Statsd::timing("hydra.evaluator.eval_time", int(($evalStop - $evalStart) * 1000));
Net::Statsd::increment("hydra.evaluator.evals");
Net::Statsd::increment("hydra.evaluator.cached_evals") unless $jobsetChanged;
}

View File

@@ -9,6 +9,7 @@ use Net::Statsd;
use File::Slurper qw(read_text);
use JSON::MaybeXS;
use Getopt::Long qw(:config gnu_getopt);
use IPC::Run3;
STDERR->autoflush(1);
binmode STDERR, ":encoding(utf8)";
@@ -25,10 +26,11 @@ sub gauge {
}
sub sendQueueRunnerStats {
my $s = `hydra-queue-runner --status`;
die "cannot get queue runner stats\n" if $? != 0;
my ($stdout, $stderr);
run3(['hydra-queue-runner', '--status'], \undef, \$stdout, \$stderr);
die "cannot get queue runner stats: $stderr\n" if $? != 0;
my $json = decode_json($s) or die "cannot decode queue runner status";
my $json = decode_json($stdout) or die "cannot decode queue runner status";
gauge("hydra.queue.up", $json->{status} eq "up" ? 1 : 0);

View File

@@ -5,7 +5,6 @@ use warnings;
use File::Path;
use File::stat;
use File::Basename;
use Nix::Store;
use Hydra::Config;
use Hydra::Schema;
use Hydra::Helper::Nix;
@@ -47,7 +46,7 @@ sub keepBuild {
$build->finished && ($build->buildstatus == 0 || $build->buildstatus == 6))
{
foreach my $path (split / /, $build->get_column('outpaths')) {
if (isValidPath($path)) {
if ($MACHINE_LOCAL_STORE->isValidPath($path)) {
addRoot $path;
} else {
print STDERR " warning: output ", $path, " has disappeared\n" if $build->finished;
@@ -55,7 +54,7 @@ sub keepBuild {
}
}
if (!$build->finished || ($keepFailedDrvs && $build->buildstatus != 0)) {
if (isValidPath($build->drvpath)) {
if ($MACHINE_LOCAL_STORE->isValidPath($build->drvpath)) {
addRoot $build->drvpath;
} else {
print STDERR " warning: derivation ", $build->drvpath, " has disappeared\n";

View File

@@ -78,7 +78,7 @@ fi
init_remote(){
local url=$1;
git init;
git init --initial-branch=trunk;
git remote add origin $url;
}

Some files were not shown because too many files have changed in this diff Show More