Merge branch 'master' into persona

Conflicts:
	src/lib/Hydra/Helper/CatalystUtils.pm
	src/root/layout.tt
	src/root/topbar.tt
	src/root/user.tt
This commit is contained in:
Eelco Dolstra 2013-11-05 11:11:48 +01:00
commit b54cfbf032
114 changed files with 3593 additions and 1883 deletions

View file

@ -1,4 +1,8 @@
SUBDIRS = src tests doc
BOOTCLEAN_SUBDIRS = $(SUBDIRS)
DIST_SUBDIRS = $(SUBDIRS)
EXTRA_DIST = hydra-module.nix
install-data-local: hydra-module.nix
$(INSTALL) -d $(DESTDIR)$(datadir)/nix
$(INSTALL_DATA) hydra-module.nix $(DESTDIR)$(datadir)/nix/

View file

@ -52,8 +52,7 @@ then
NIX_STATE_DIR="$TMPDIR"
export NIX_STATE_DIR
fi
if "$NIX_STORE_PROGRAM" --timeout 123 -q > /dev/null 2>&1
then
if NIX_REMOTE=daemon "$NIX_STORE_PROGRAM" --timeout 123 -q; then
AC_MSG_RESULT([yes])
else
AC_MSG_RESULT([no])
@ -68,7 +67,7 @@ LDFLAGS="$LDFLAGS -L$nix/lib/nix"
AC_CHECK_HEADER([store-api.hh], [:],
[AC_MSG_ERROR([Nix headers not found; please install Nix or check the `--with-nix' option.])])
AC_CHECK_LIB([expr], [_ZN3nix9EvalState17parseExprFromFileESs], [:],
AC_CHECK_LIB([expr], [_ZN3nix9EvalState8evalFileERKSsRNS_5ValueE], [:],
[AC_MSG_ERROR([Nix library not found; please install Nix or check the `--with-nix' option.])])
CPPFLAGS="$old_CPPFLAGS"

7
dev-shell Executable file
View file

@ -0,0 +1,7 @@
#! /bin/sh
s=$(type -p nix-shell)
exec $s release.nix -A build.x86_64-linux --exclude tarball --command "
export NIX_REMOTE=daemon
export NIX_PATH='$NIX_PATH'
export NIX_BUILD_SHELL=$(type -p bash)
exec $s release.nix -A tarball" "$@"

View file

@ -127,3 +127,13 @@
succeed in the nixpkgs:trunk jobset:
select job, system from builds b natural join buildresultinfo where project = 'nixpkgs' and jobset = 'stdenv' and iscurrent = 1 and finished = 1 and buildstatus != 0 and exists (select 1 from builds natural join buildresultinfo where project = 'nixpkgs' and jobset = 'trunk' and job = b.job and system = b.system and iscurrent = 1 and finished = 1 and buildstatus = 0) order by job, system;
* Get all Nixpkgs jobs that have never built succesfully:
select project, jobset, job from builds b1
where project = 'nixpkgs' and jobset = 'trunk' and iscurrent = 1
group by project, jobset, job
having not exists
(select 1 from builds b2 where b1.project = b2.project and b1.jobset = b2.jobset and b1.job = b2.job and finished = 1 and buildstatus = 0)
order by project, jobset, job;

View file

@ -1,4 +1,4 @@
DOCBOOK_FILES = installation.xml introduction.xml manual.xml projects.xml
DOCBOOK_FILES = installation.xml introduction.xml manual.xml projects.xml hacking.xml
EXTRA_DIST = $(DOCBOOK_FILES)

39
doc/manual/hacking.xml Normal file
View file

@ -0,0 +1,39 @@
<appendix xmlns="http://docbook.org/ns/docbook"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:id="chap-hacking">
<title>Hacking</title>
<para>This section provides some notes on how to hack on Hydra. To
get the latest version of Hydra from GitHub:
<screen>
$ git clone git://github.com/NixOS/hydra.git
$ cd hydra
</screen>
</para>
<para>To build it and its dependencies:
<screen>
$ nix-build release.nix -A build.x86_64-linux
</screen>
</para>
<para>To build all dependencies and start a shell in which all
environment variables (such as <envar>PERL5LIB</envar>) are set up so
that those dependencies can be found:
<screen>
$ ./dev-shell
</screen>
To build Hydra, you should then do:
<screen>
[nix-shell]$ ./bootstrap
[nix-shell]$ configurePhase
[nix-shell]$ make
</screen>
You can run the Hydra web server in your source tree as follows:
<screen>
$ ./src/script/hydra-server
</screen>
</para>
</appendix>

View file

@ -52,8 +52,7 @@
<copyright>
<year>2009</year>
<year>2010</year>
<year>2009-2013</year>
<holder>Eelco Dolstra</holder>
</copyright>
@ -64,6 +63,7 @@
<xi:include href="introduction.xml" />
<xi:include href="installation.xml" />
<xi:include href="projects.xml" />
<xi:include href="hacking.xml" />
</book>

View file

@ -7,13 +7,7 @@ let
baseDir = "/var/lib/hydra";
hydraConf = pkgs.writeScript "hydra.conf"
''
using_frontend_proxy 1
base_uri ${cfg.hydraURL}
notification_sender ${cfg.notificationSender}
max_servers 25
'';
hydraConf = pkgs.writeScript "hydra.conf" cfg.extraConfig;
env =
{ NIX_REMOTE = "daemon";
@ -28,7 +22,7 @@ let
serverEnv = env //
{ HYDRA_LOGO = if cfg.logo != null then cfg.logo else "";
HYDRA_TRACKER = cfg.tracker;
};
} // (optionalAttrs cfg.debugServer { DBIC_TRACE = 1; });
in
{
@ -64,6 +58,15 @@ in
'';
};
listenHost = mkOption {
default = "*";
example = "localhost";
description = ''
The hostname or address to listen on or <literal>*</literal> to listen
on all interfaces.
'';
};
port = mkOption {
default = 3000;
description = ''
@ -112,6 +115,17 @@ in
'';
};
debugServer = mkOption {
default = false;
type = types.bool;
description = "Whether to run the server in debug mode";
};
extraConfig = mkOption {
type = types.lines;
description = "Extra lines for the hydra config";
};
};
};
@ -120,6 +134,14 @@ in
###### implementation
config = mkIf cfg.enable {
services.hydra.extraConfig =
''
using_frontend_proxy 1
base_uri ${cfg.hydraURL}
notification_sender ${cfg.notificationSender}
max_servers 25
'';
environment.systemPackages = [ cfg.hydra ];
users.extraUsers.hydra =
@ -151,14 +173,36 @@ in
systemd.services."hydra-init" =
{ wantedBy = [ "multi-user.target" ];
requires = [ "postgresql.service" ];
after = [ "postgresql.service" ];
environment = env;
script = ''
mkdir -p ${baseDir}/data
chown hydra ${baseDir}/data
ln -sf ${hydraConf} ${baseDir}/data/hydra.conf
pass=$(HOME=/root ${pkgs.openssl}/bin/openssl rand -base64 32)
if [ ! -f ${baseDir}/.pgpass ]; then
${config.services.postgresql.package}/bin/psql postgres << EOF
CREATE USER hydra PASSWORD '$pass';
EOF
${config.services.postgresql.package}/bin/createdb -O hydra hydra
cat > ${baseDir}/.pgpass-tmp << EOF
localhost:*:hydra:hydra:$pass
EOF
chown hydra ${baseDir}/.pgpass-tmp
chmod 600 ${baseDir}/.pgpass-tmp
mv ${baseDir}/.pgpass-tmp ${baseDir}/.pgpass
fi
${pkgs.shadow}/bin/su hydra -c ${cfg.hydra}/bin/hydra-init
${config.services.postgresql.package}/bin/psql hydra << EOF
BEGIN;
INSERT INTO Users(userName, emailAddress, password) VALUES ('admin', '${cfg.notificationSender}', '$(echo -n $pass | sha1sum | cut -c1-40)');
INSERT INTO UserRoles(userName, role) values('admin', 'admin');
COMMIT;
EOF
'';
serviceConfig.Type = "oneshot";
serviceConfig.RemainAfterExit = true;
};
systemd.services."hydra-server" =
@ -167,7 +211,7 @@ in
after = [ "hydra-init.service" ];
environment = serverEnv;
serviceConfig =
{ ExecStart = "@${cfg.hydra}/bin/hydra-server hydra-server -f -h \* --max_spare_servers 5 --max_servers 25 --max_requests 100";
{ ExecStart = "@${cfg.hydra}/bin/hydra-server hydra-server -f -h '${cfg.listenHost}' --max_spare_servers 5 --max_servers 25 --max_requests 100${optionalString cfg.debugServer " -d"}";
User = "hydra";
Restart = "always";
};
@ -177,7 +221,7 @@ in
{ wantedBy = [ "multi-user.target" ];
wants = [ "hydra-init.service" ];
after = [ "hydra-init.service" "network.target" ];
path = [ pkgs.nettools pkgs.ssmtp ];
path = [ pkgs.nettools ];
environment = env;
serviceConfig =
{ ExecStartPre = "${cfg.hydra}/bin/hydra-queue-runner --unlock";
@ -191,7 +235,7 @@ in
{ wantedBy = [ "multi-user.target" ];
wants = [ "hydra-init.service" ];
after = [ "hydra-init.service" "network.target" ];
path = [ pkgs.nettools pkgs.ssmtp ];
path = [ pkgs.nettools ];
environment = env;
serviceConfig =
{ ExecStart = "@${cfg.hydra}/bin/hydra-evaluator hydra-evaluator";
@ -226,9 +270,10 @@ in
'';
compressLogs = pkgs.writeScript "compress-logs" ''
#! ${pkgs.stdenv.shell} -e
touch -d 'last month' r
find /nix/var/log/nix/drvs -type f -a ! -newer r -name '*.drv' | xargs bzip2 -v
#! ${pkgs.stdenv.shell} -e
find /nix/var/log/nix/drvs \
-type f -a ! -newermt 'last month' \
-name '*.drv' -exec bzip2 -v {} +
'';
in
[ "*/5 * * * * root ${checkSpace} &> ${baseDir}/data/checkspace.log"

View file

@ -6,7 +6,7 @@ let
pkgs = import <nixpkgs> {};
genAttrs' = pkgs.lib.genAttrs [ "x86_64-linux" "i686-linux" ];
genAttrs' = pkgs.lib.genAttrs [ "x86_64-linux" ];
in rec {
@ -24,13 +24,17 @@ in rec {
versionSuffix = if officialRelease then "" else "pre${toString hydraSrc.revCount}-${hydraSrc.gitTag}";
preConfigure = ''
preHook = ''
# TeX needs a writable font cache.
export VARTEXFONTS=$TMPDIR/texfonts
addToSearchPath PATH $(pwd)/src/script
addToSearchPath PATH $(pwd)/src/c
addToSearchPath PERL5LIB $(pwd)/src/lib
'';
configureFlags =
[ "--with-nix=${nix}"
[ "--with-nix=${nixUnstable}"
"--with-docbook-xsl=${docbook_xsl}/xml/xsl/docbook"
];
@ -88,6 +92,7 @@ in rec {
PadWalker
CatalystDevel
Readonly
SetScalar
SQLSplitStatement
Starman
SysHostnameLong
@ -95,6 +100,7 @@ in rec {
TextDiff
TextTable
XMLSimple
NetAmazonS3
nix git
];
};
@ -108,17 +114,20 @@ in rec {
buildInputs =
[ makeWrapper libtool unzip nukeReferences pkgconfig boehmgc sqlite
gitAndTools.topGit mercurial subversion bazaar openssl bzip2
gitAndTools.topGit mercurial darcs subversion bazaar openssl bzip2
guile # optional, for Guile + Guix support
perlDeps perl
];
hydraPath = lib.makeSearchPath "bin" (
[ libxslt sqlite subversion openssh nix coreutils findutils
gzip bzip2 lzma gnutar unzip git gitAndTools.topGit mercurial gnused graphviz bazaar
gzip bzip2 lzma gnutar unzip git gitAndTools.topGit mercurial darcs gnused graphviz bazaar
] ++ lib.optionals stdenv.isLinux [ rpm dpkg cdrkit ] );
preConfigure = "patchShebangs .";
preCheck = ''
patchShebangs .
export LOGNAME=${LOGNAME:-foo}
'';
postInstall = ''
mkdir -p $out/nix-support
@ -134,14 +143,13 @@ in rec {
done
''; # */
LOGNAME = "foo";
meta.description = "Build of Hydra on ${system}";
passthru.perlDeps = perlDeps;
});
tests.install = genAttrs' (system:
with import <nixos/lib/testing.nix> { inherit system; };
with import <nixpkgs/nixos/lib/testing.nix> { inherit system; };
let hydra = builtins.getAttr system build; in # build.${system}
simpleTest {
machine =
@ -169,8 +177,8 @@ in rec {
});
tests.api = genAttrs' (system:
with import <nixos/lib/testing.nix> { inherit system; };
let hydra = builtins.getAttr system build; in # build.${system}
with import <nixpkgs/nixos/lib/testing.nix> { inherit system; };
let hydra = builtins.getAttr system build; in # build."${system}"
simpleTest {
machine =
{ config, pkgs, ... }:
@ -178,6 +186,7 @@ in rec {
services.postgresql.package = pkgs.postgresql92;
environment.systemPackages = [ hydra pkgs.perlPackages.LWP pkgs.perlPackages.JSON ];
virtualisation.memorySize = 2047;
boot.kernelPackages = pkgs.linuxPackages_3_10;
};
testScript =
@ -204,4 +213,43 @@ in rec {
$machine->mustSucceed("perl ${./tests/api-test.pl} >&2");
'';
});
tests.s3backup = genAttrs' (system:
with import <nixpkgs/nixos/lib/testing.nix> { inherit system; };
let hydra = builtins.getAttr system build; in # build."${system}"
simpleTest {
machine =
{ config, pkgs, ... }:
{ services.postgresql.enable = true;
services.postgresql.package = pkgs.postgresql92;
environment.systemPackages = [ hydra pkgs.rubyLibs.fakes3 ];
virtualisation.memorySize = 2047;
boot.kernelPackages = pkgs.linuxPackages_3_10;
virtualisation.writableStore = true;
networking.extraHosts = ''
127.0.0.1 hydra.s3.amazonaws.com
'';
};
testScript =
''
$machine->waitForJob("postgresql");
# Initialise the database and the state.
$machine->mustSucceed
( "createdb -O root hydra"
, "psql hydra -f ${hydra}/libexec/hydra/sql/hydra-postgresql.sql"
, "mkdir /var/lib/hydra"
, "mkdir /tmp/jobs"
, "cp ${./tests/s3-backup-test.pl} /tmp/s3-backup-test.pl"
, "cp ${./tests/api-test.nix} /tmp/jobs/default.nix"
);
# start fakes3
$machine->mustSucceed("fakes3 --root /tmp/s3 --port 80 &>/dev/null &");
$machine->waitForOpenPort("80");
$machine->mustSucceed("cd /tmp && LOGNAME=root AWS_ACCESS_KEY_ID=foo AWS_SECRET_ACCESS_KEY=bar HYDRA_DBI='dbi:Pg:dbname=hydra;user=root;' HYDRA_CONFIG=${./tests/s3-backup-test.config} perl -I ${hydra}/libexec/hydra/lib -I ${hydra.perlDeps}/lib/perl5/site_perl ./s3-backup-test.pl >&2");
'';
});
}

View file

@ -109,6 +109,22 @@ static int queryMetaFieldInt(MetaInfo & meta, const string & name, int def)
}
static string queryMetaField(MetaInfo & meta, const string & name)
{
string res;
MetaValue value = meta[name];
if (value.type == MetaValue::tpString)
res = value.stringValue;
else if (value.type == MetaValue::tpStrings) {
foreach (Strings::const_iterator, i, value.stringValues) {
if (res.size() != 0) res += ", ";
res += *i;
}
}
return res;
}
static void findJobsWrapped(EvalState & state, XMLWriter & doc,
const ArgsUsed & argsUsed, const AutoArgs & argsLeft,
Value & v, const string & attrPath)
@ -136,8 +152,9 @@ static void findJobsWrapped(EvalState & state, XMLWriter & doc,
MetaInfo meta = drv.queryMetaInfo(state);
xmlAttrs["description"] = queryMetaFieldString(meta, "description");
xmlAttrs["longDescription"] = queryMetaFieldString(meta, "longDescription");
xmlAttrs["license"] = queryMetaFieldString(meta, "license");
xmlAttrs["license"] = queryMetaField(meta, "license");
xmlAttrs["homepage"] = queryMetaFieldString(meta, "homepage");
xmlAttrs["maintainers"] = queryMetaField(meta, "maintainers");
int prio = queryMetaFieldInt(meta, "schedulingPriority", 100);
xmlAttrs["schedulingPriority"] = int2String(prio);
@ -148,17 +165,22 @@ static void findJobsWrapped(EvalState & state, XMLWriter & doc,
int maxsilent = queryMetaFieldInt(meta, "maxSilent", 3600);
xmlAttrs["maxSilent"] = int2String(maxsilent);
string maintainers;
MetaValue value = meta["maintainers"];
if (value.type == MetaValue::tpString)
maintainers = value.stringValue;
else if (value.type == MetaValue::tpStrings) {
foreach (Strings::const_iterator, i, value.stringValues) {
if (maintainers.size() != 0) maintainers += ", ";
maintainers += *i;
}
/* If this is an aggregate, then get its constituents. */
Bindings::iterator a = v.attrs->find(state.symbols.create("_hydraAggregate"));
if (a != v.attrs->end() && state.forceBool(*a->value)) {
Bindings::iterator a = v.attrs->find(state.symbols.create("constituents"));
if (a == v.attrs->end())
throw EvalError("derivation must have a constituents attribute");
PathSet context;
state.coerceToString(*a->value, context, true, false);
PathSet drvs;
foreach (PathSet::iterator, i, context)
if (i->at(0) == '!') {
size_t index = i->find("!", 1);
drvs.insert(string(*i, index + 1));
}
xmlAttrs["constituents"] = concatStringsSep(" ", drvs);
}
xmlAttrs["maintainers"] = maintainers;
/* Register the derivation as a GC root. !!! This
registers roots for jobs that we may have already
@ -267,9 +289,8 @@ void run(Strings args)
store = openStore();
Expr * e = state.parseExprFromFile(releaseExpr);
Value v;
state.mkThunk_(v, e);
state.evalFile(releaseExpr, v);
XMLWriter doc(true, std::cout);
XMLOpenElement root(doc, "jobs");

View file

@ -7,46 +7,6 @@ use Hydra::Helper::Nix;
use Hydra::Helper::CatalystUtils;
sub getJobStatus {
my ($self, $c) = @_;
my $maintainer = $c->request->params->{"maintainer"};
my $latest = $c->stash->{jobStatus}->search(
defined $maintainer ? { maintainers => { like => "%$maintainer%" } } : {},
{ '+select' => ["me.statusChangeId", "me.statusChangeTime"]
, '+as' => ["statusChangeId", "statusChangeTime"]
, order_by => "coalesce(statusChangeTime, 0) desc"
});
return $latest;
}
sub jobstatus : Chained('get_builds') PathPart Args(0) {
my ($self, $c) = @_;
$c->stash->{template} = 'jobstatus.tt';
$c->stash->{latestBuilds} = [getJobStatus($self, $c)->all];
}
# A convenient way to see all the errors - i.e. things demanding
# attention - at a glance.
sub errors : Chained('get_builds') PathPart Args(0) {
my ($self, $c) = @_;
$c->stash->{template} = 'errors.tt';
$c->stash->{brokenJobsets} =
[$c->stash->{allJobsets}->search({errormsg => {'!=' => ''}})]
if defined $c->stash->{allJobsets};
$c->stash->{brokenJobs} =
[$c->stash->{allJobs}->search({errormsg => {'!=' => ''}})]
if defined $c->stash->{allJobs};
$c->stash->{brokenBuilds} =
[getJobStatus($self, $c)->search({buildStatus => {'!=' => 0}})];
}
sub all : Chained('get_builds') PathPart {
my ($self, $c) = @_;
@ -56,13 +16,12 @@ sub all : Chained('get_builds') PathPart {
my $resultsPerPage = 20;
my $nrBuilds = $c->stash->{allBuilds}->search({finished => 1})->count;
$c->stash->{baseUri} = $c->uri_for($self->action_for("all"), $c->req->captures);
$c->stash->{page} = $page;
$c->stash->{resultsPerPage} = $resultsPerPage;
$c->stash->{total} = $nrBuilds;
$c->stash->{total} = $c->stash->{allBuilds}->search({finished => 1})->count
unless defined $c->stash->{total};
$c->stash->{builds} = [ $c->stash->{allBuilds}->search(
{ finished => 1 },
@ -82,6 +41,7 @@ sub nix : Chained('get_builds') PathPart('channel') CaptureArgs(1) {
->search_literal("exists (select 1 from buildproducts where build = me.id and type = 'nix-build')")
->search({}, { columns => [@buildListColumns, 'drvpath', 'description', 'homepage']
, join => ["buildoutputs"]
, order_by => ["me.id", "buildoutputs.name"]
, '+select' => ['buildoutputs.path', 'buildoutputs.name'], '+as' => ['outpath', 'outname'] });
}
else {
@ -120,4 +80,22 @@ sub latest_for : Chained('get_builds') PathPart('latest-for') {
}
# Redirect to the latest successful build in a finished evaluation
# (i.e. an evaluation that has no unfinished builds).
sub latest_finished : Chained('get_builds') PathPart('latest-finished') {
my ($self, $c, @rest) = @_;
my $latest = $c->stash->{allBuilds}->find(
{ finished => 1, buildstatus => 0 },
{ order_by => ["id DESC"], rows => 1, join => ["jobsetevalmembers"]
, where => \
"not exists (select 1 from jobsetevalmembers m2 join builds b2 on jobsetevalmembers.eval = m2.eval and m2.build = b2.id and b2.finished = 0)"
});
notFound($c, "There is no successful build to redirect to.") unless defined $latest;
$c->res->redirect($c->uri_for($c->controller('Build')->action_for("build"), [$latest->id], @rest));
}
1;

View file

@ -14,20 +14,36 @@ sub getChannelData {
my @storePaths = ();
$c->stash->{nixPkgs} = [];
foreach my $build ($c->stash->{channelBuilds}->all) {
my $outPath = $build->get_column("outpath");
my $outName = $build->get_column("outname");
next if $checkValidity && !isValidPath($outPath);
push @storePaths, $outPath;
my $pkgName = $build->nixname . "-" . $build->system . "-" . $build->id . ($outName ne "out" ? "-" . $outName : "");
push @{$c->stash->{nixPkgs}}, { build => $build, name => $pkgName, outPath => $outPath, outName => $outName };
# Put the system type in the manifest (for top-level paths) as
# a hint to the binary patch generator. (It shouldn't try to
# generate patches between builds for different systems.) It
# would be nice if Nix stored this info for every path but it
# doesn't.
$c->stash->{systemForPath}->{$outPath} = $build->system;
};
my @builds = $c->stash->{channelBuilds}->all;
for (my $n = 0; $n < scalar @builds; ) {
# Since channelData is a join of Builds and BuildOutputs, we
# need to gather the rows that belong to a single build.
my $build = $builds[$n++];
my @outputs = ($build);
push @outputs, $builds[$n++] while $n < scalar @builds && $builds[$n]->id == $build->id;
@outputs = grep { $_->get_column("outpath") } @outputs;
my $outputs = {};
foreach my $output (@outputs) {
my $outPath = $output->get_column("outpath");
next if $checkValidity && !isValidPath($outPath);
$outputs->{$output->get_column("outname")} = $outPath;
push @storePaths, $outPath;
# Put the system type in the manifest (for top-level
# paths) as a hint to the binary patch generator. (It
# shouldn't try to generate patches between builds for
# different systems.) It would be nice if Nix stored this
# info for every path but it doesn't.
$c->stash->{systemForPath}->{$outPath} = $build->system;
}
next if !%$outputs;
my $pkgName = $build->nixname . "-" . $build->system . "-" . $build->id;
push @{$c->stash->{nixPkgs}}, { build => $build, name => $pkgName, outputs => $outputs };
}
$c->stash->{storePaths} = [@storePaths];
}

View file

@ -4,8 +4,12 @@ use strict;
use warnings;
use base 'Catalyst::Controller::REST';
# Hack: Erase the map set by C::C::REST
__PACKAGE__->config( map => undef );
__PACKAGE__->config(
map => {
'application/json' => 'JSON',
'text/x-json' => 'JSON',
'text/html' => [ 'View', 'TT' ]
},
default => 'text/html',

View file

@ -8,36 +8,26 @@ use base 'DBIx::Class';
sub TO_JSON {
my $self = shift;
my $json = { $self->get_columns };
my $rs = $self->result_source;
my @relnames = $rs->relationships;
RELLOOP: foreach my $relname (@relnames) {
my $relinfo = $rs->relationship_info($relname);
next unless defined $relinfo->{attrs}->{accessor};
my $accessor = $relinfo->{attrs}->{accessor};
if ($accessor eq "single" and exists $self->{_relationship_data}{$relname}) {
$json->{$relname} = $self->$relname->TO_JSON;
} else {
unless (defined $self->{related_resultsets}{$relname}) {
my $cond = $relinfo->{cond};
if (ref $cond eq 'HASH') {
foreach my $k (keys %{$cond}) {
my $v = $cond->{$k};
$v =~ s/^self\.//;
next RELLOOP unless $self->has_column_loaded($v);
}
} #!!! TODO: Handle ARRAY conditions
}
if (defined $self->related_resultset($relname)->get_cache) {
if ($accessor eq "multi") {
$json->{$relname} = [ map { $_->TO_JSON } $self->$relname ];
} else {
$json->{$relname} = $self->$relname->TO_JSON;
}
}
}
my $hint = $self->json_hint;
my %json = ();
foreach my $column (@{$hint->{columns}}) {
$json{$column} = $self->get_column($column);
}
return $json;
foreach my $relname (keys %{$hint->{relations}}) {
my $key = $hint->{relations}->{$relname};
$json{$relname} = [ map { $_->$key } $self->$relname ];
}
foreach my $relname (keys %{$hint->{eager_relations}}) {
my $key = $hint->{eager_relations}->{$relname};
$json{$relname} = { map { $_->$key => $_ } $self->$relname };
}
return \%json;
}
1;

View file

@ -15,8 +15,6 @@ use Digest::SHA qw(sha256_hex);
use Text::Diff;
use File::Slurp;
# !!! Rewrite this to use View::JSON.
sub api : Chained('/') PathPart('api') CaptureArgs(0) {
my ($self, $c) = @_;
@ -24,32 +22,6 @@ sub api : Chained('/') PathPart('api') CaptureArgs(0) {
}
sub projectToHash {
my ($project) = @_;
return {
name => $project->name,
description => $project->description
};
}
sub projects : Chained('api') PathPart('projects') Args(0) {
my ($self, $c) = @_;
my @projects = $c->model('DB::Projects')->search({hidden => 0}, {order_by => 'name'});
my @list;
foreach my $p (@projects) {
push @list, projectToHash($p);
}
$c->stash->{'plain'} = {
data => scalar (JSON::Any->objToJson(\@list))
};
$c->forward('Hydra::View::Plain');
}
sub buildToHash {
my ($build) = @_;
my $result = {

View file

@ -34,8 +34,12 @@ sub machines : Chained('admin') PathPart('machines') Args(0) {
sub clear_queue_non_current : Chained('admin') PathPart('clear-queue-non-current') Args(0) {
my ($self, $c) = @_;
my $time = time();
$c->model('DB::Builds')->search({finished => 0, iscurrent => 0, busy => 0})->update({ finished => 1, buildstatus => 4, starttime => $time, stoptime => $time });
my $builds = $c->model('DB::Builds')->search(
{ finished => 0, busy => 0
, id => { -not_in => \ "select build from JobsetEvalMembers where eval in (select max(id) from JobsetEvals where hasNewBuilds = 1 group by project, jobset)" }
});
my $n = cancelBuilds($c->model('DB')->schema, $builds);
$c->flash->{successMsg} = "$n builds have been cancelled.";
$c->res->redirect($c->request->referer // "/admin");
}
@ -49,19 +53,11 @@ sub clearfailedcache : Chained('admin') PathPart('clear-failed-cache') Args(0) {
sub clearvcscache : Chained('admin') PathPart('clear-vcs-cache') Args(0) {
my ($self, $c) = @_;
print STDERR "Clearing path cache\n";
$c->model('DB::CachedPathInputs')->delete_all;
print STDERR "Clearing git cache\n";
$c->model('DB::CachedGitInputs')->delete_all;
print STDERR "Clearing subversion cache\n";
$c->model('DB::CachedSubversionInputs')->delete_all;
print STDERR "Clearing bazaar cache\n";
$c->model('DB::CachedBazaarInputs')->delete_all;
$c->model('DB::CachedPathInputs')->delete;
$c->model('DB::CachedGitInputs')->delete;
$c->model('DB::CachedSubversionInputs')->delete;
$c->model('DB::CachedBazaarInputs')->delete;
$c->flash->{successMsg} = "VCS caches have been cleared.";
$c->res->redirect($c->request->referer // "/admin");
}

View file

@ -35,18 +35,18 @@ sub buildChain :Chained('/') :PathPart('build') :CaptureArgs(1) {
sub findBuildStepByOutPath {
my ($self, $c, $path, $status) = @_;
my ($self, $c, $path) = @_;
return $c->model('DB::BuildSteps')->search(
{ path => $path, busy => 0, status => $status },
{ join => ["buildstepoutputs"], order_by => ["stopTime"], limit => 1 })->single;
{ path => $path, busy => 0 },
{ join => ["buildstepoutputs"], order_by => ["status", "stopTime"], rows => 1 })->single;
}
sub findBuildStepByDrvPath {
my ($self, $c, $drvPath, $status) = @_;
my ($self, $c, $drvPath) = @_;
return $c->model('DB::BuildSteps')->search(
{ drvpath => $drvPath, busy => 0, status => $status },
{ order_by => ["stopTime"], limit => 1 })->single;
{ drvpath => $drvPath, busy => 0 },
{ order_by => ["status", "stopTime"], rows => 1 })->single;
}
@ -60,7 +60,6 @@ sub build_GET {
$c->stash->{template} = 'build.tt';
$c->stash->{available} = all { isValidPath($_->path) } $build->buildoutputs->all;
$c->stash->{drvAvailable} = isValidPath $build->drvpath;
$c->stash->{flashMsg} = $c->flash->{buildMsg};
if (!$build->finished && $build->busy) {
$c->stash->{logtext} = read_file($build->logfile, err_mode => 'quiet') // "";
@ -68,8 +67,7 @@ sub build_GET {
if ($build->finished && $build->iscachedbuild) {
my $path = ($build->buildoutputs)[0]->path or die;
my $cachedBuildStep = findBuildStepByOutPath($self, $c, $path,
$build->buildstatus == 0 || $build->buildstatus == 6 ? 0 : 1);
my $cachedBuildStep = findBuildStepByOutPath($self, $c, $path);
$c->stash->{cachedBuild} = $cachedBuildStep->build if defined $cachedBuildStep;
}
@ -95,25 +93,16 @@ sub build_GET {
# Get the first eval of which this build was a part.
($c->stash->{nrEvals}) = $c->stash->{build}->jobsetevals->search({ hasnewbuilds => 1 })->count;
($c->stash->{eval}) = $c->stash->{build}->jobsetevals->search(
$c->stash->{eval} = $c->stash->{build}->jobsetevals->search(
{ hasnewbuilds => 1},
{ limit => 1, order_by => ["id"] });
{ rows => 1, order_by => ["id"] })->single;
$self->status_ok(
$c,
entity => $c->model('DB::Builds')->find($build->id,{
columns => [
'id',
'finished',
'timestamp',
'buildstatus',
'job',
'project',
'jobset',
'starttime',
'stoptime',
]
})
entity => $build
);
# If this is an aggregate build, get its constituents.
$c->stash->{constituents} = [$c->stash->{build}->constituents_->search({}, {order_by => ["job"]})];
}
@ -125,35 +114,43 @@ sub view_nixlog : Chained('buildChain') PathPart('nixlog') {
$c->stash->{step} = $step;
showLog($c, $step->drvpath, $mode);
showLog($c, $mode, $step->drvpath, map { $_->path } $step->buildstepoutputs->all);
}
sub view_log : Chained('buildChain') PathPart('log') {
my ($self, $c, $mode) = @_;
showLog($c, $c->stash->{build}->drvpath, $mode);
showLog($c, $mode, $c->stash->{build}->drvpath, map { $_->path } $c->stash->{build}->buildoutputs->all);
}
sub showLog {
my ($c, $drvPath, $mode) = @_;
my ($c, $mode, $drvPath, @outPaths) = @_;
my $logPath = getDrvLogPath($drvPath);
my $logPath = findLog($c, $drvPath, @outPaths);
notFound($c, "The build log of derivation $drvPath is not available.") unless defined $logPath;
my $size = stat($logPath)->size;
error($c, "This build log is too big to display ($size bytes).")
if $size >= 64 * 1024 * 1024;
if (!$mode) {
# !!! quick hack
my $pipeline = "nix-store -l $drvPath"
my $pipeline = ($logPath =~ /.bz2$/ ? "bzip2 -d < $logPath" : "cat $logPath")
. " | nix-log2xml | xsltproc " . $c->path_to("xsl/mark-errors.xsl") . " -"
. " | xsltproc " . $c->path_to("xsl/log2html.xsl") . " - | tail -n +2";
$c->stash->{template} = 'log.tt';
$c->stash->{logtext} = `$pipeline`;
$c->stash->{logtext} = `ulimit -t 5 ; $pipeline`;
}
elsif ($mode eq "raw") {
$c->stash->{'plain'} = { data => (scalar logContents($drvPath)) || " " };
$c->forward('Hydra::View::Plain');
if ($logPath !~ /.bz2$/) {
$c->serve_static_file($logPath);
} else {
$c->stash->{'plain'} = { data => (scalar logContents($logPath)) || " " };
$c->forward('Hydra::View::Plain');
}
}
elsif ($mode eq "tail-reload") {
@ -162,12 +159,12 @@ sub showLog {
$c->stash->{url} = $url;
$c->stash->{reload} = !$c->stash->{build}->finished && $c->stash->{build}->busy;
$c->stash->{title} = "";
$c->stash->{contents} = (scalar logContents($drvPath, 50)) || " ";
$c->stash->{contents} = (scalar logContents($logPath, 50)) || " ";
$c->stash->{template} = 'plain-reload.tt';
}
elsif ($mode eq "tail") {
$c->stash->{'plain'} = { data => (scalar logContents($drvPath, 50)) || " " };
$c->stash->{'plain'} = { data => (scalar logContents($logPath, 50)) || " " };
$c->forward('Hydra::View::Plain');
}
@ -238,6 +235,21 @@ sub download : Chained('buildChain') PathPart {
}
sub output : Chained('buildChain') PathPart Args(1) {
my ($self, $c, $outputName) = @_;
my $build = $c->stash->{build};
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;
error($c, "Output is not available.") unless isValidPath $output->path;
$c->response->header('Content-Disposition', "attachment; filename=\"build-${\$build->id}-${\$outputName}.nar.bz2\"");
$c->stash->{current_view} = 'NixNAR';
$c->stash->{storePath} = $output->path;
}
# Redirect to a download with the given type. Useful when you want to
# link to some build product of the latest build (i.e. in conjunction
# with the .../latest redirect).
@ -269,7 +281,7 @@ 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";
$path =~ /^\/[\/[A-Za-z0-9_\-\.=+:]+$/ or die "Filename contains illegal characters.\n";
# FIXME: don't use shell invocations below.
@ -339,8 +351,8 @@ sub getDependencyGraph {
{ path => $path
, name => $name
, buildStep => $runtime
? findBuildStepByOutPath($self, $c, $path, 0)
: findBuildStepByDrvPath($self, $c, $path, 0)
? findBuildStepByOutPath($self, $c, $path)
: findBuildStepByDrvPath($self, $c, $path)
};
$$done{$path} = $node;
my @refs;
@ -409,49 +421,22 @@ sub nix : Chained('buildChain') PathPart('nix') CaptureArgs(0) {
sub restart : Chained('buildChain') PathPart Args(0) {
my ($self, $c) = @_;
my $build = $c->stash->{build};
requireProjectOwner($c, $build->project);
my $drvpath = $build->drvpath;
error($c, "This build cannot be restarted.")
unless $build->finished && -f $drvpath;
restartBuild($c->model('DB')->schema, $build);
$c->flash->{buildMsg} = "Build has been restarted.";
my $n = restartBuilds($c->model('DB')->schema, $c->model('DB::Builds')->search({ id => $build->id }));
error($c, "This build cannot be restarted.") if $n != 1;
$c->flash->{successMsg} = "Build has been restarted.";
$c->res->redirect($c->uri_for($self->action_for("build"), $c->req->captures));
}
sub cancel : Chained('buildChain') PathPart Args(0) {
my ($self, $c) = @_;
my $build = $c->stash->{build};
requireProjectOwner($c, $build->project);
txn_do($c->model('DB')->schema, sub {
error($c, "This build cannot be cancelled.")
if $build->finished || $build->busy;
# !!! Actually, it would be nice to be able to cancel busy
# builds as well, but we would have to send a signal or
# something to the build process.
my $time = time();
$build->update(
{ finished => 1, busy => 0
, iscachedbuild => 0, buildstatus => 4 # = cancelled
, starttime => $time
, stoptime => $time
});
});
$c->flash->{buildMsg} = "Build has been cancelled.";
my $n = cancelBuilds($c->model('DB')->schema, $c->model('DB::Builds')->search({ id => $build->id }));
error($c, "This build cannot be cancelled.") if $n != 1;
$c->flash->{successMsg} = "Build has been cancelled.";
$c->res->redirect($c->uri_for($self->action_for("build"), $c->req->captures));
}
@ -472,7 +457,7 @@ sub keep : Chained('buildChain') PathPart Args(1) {
$build->update({keep => $keep});
});
$c->flash->{buildMsg} =
$c->flash->{successMsg} =
$keep ? "Build will be kept." : "Build will not be kept.";
$c->res->redirect($c->uri_for($self->action_for("build"), $c->req->captures));
@ -502,89 +487,12 @@ sub add_to_release : Chained('buildChain') PathPart('add-to-release') Args(0) {
$release->releasemembers->create({build => $build->id, description => $build->description});
$c->flash->{buildMsg} = "Build added to project <tt>$releaseName</tt>.";
$c->flash->{successMsg} = "Build added to project <tt>$releaseName</tt>.";
$c->res->redirect($c->uri_for($self->action_for("build"), $c->req->captures));
}
sub clone : Chained('buildChain') PathPart('clone') Args(0) {
my ($self, $c) = @_;
my $build = $c->stash->{build};
requireProjectOwner($c, $build->project);
$c->stash->{template} = 'clone-build.tt';
}
sub clone_submit : Chained('buildChain') PathPart('clone/submit') Args(0) {
my ($self, $c) = @_;
my $build = $c->stash->{build};
requireProjectOwner($c, $build->project);
my ($nixExprPath, $nixExprInputName) = Hydra::Controller::Jobset::nixExprPathFromParams $c;
# When the expression is in a .scm file, assume it's a Guile + Guix
# build expression.
my $exprType =
$c->request->params->{"nixexprpath"} =~ /.scm$/ ? "guile" : "nix";
my $jobName = trim $c->request->params->{"jobname"};
error($c, "Invalid job name: $jobName") if $jobName !~ /^$jobNameRE$/;
my $inputInfo = {};
foreach my $param (keys %{$c->request->params}) {
next unless $param =~ /^input-(\w+)-name$/;
my $baseName = $1;
my ($inputName, $inputType) =
Hydra::Controller::Jobset::checkInput($c, $baseName);
my $inputValue = Hydra::Controller::Jobset::checkInputValue(
$c, $inputType, $c->request->params->{"input-$baseName-value"});
eval {
# !!! fetchInput can take a long time, which might cause
# the current HTTP request to time out. So maybe this
# should be done asynchronously. But then error reporting
# becomes harder.
my $info = fetchInput(
$c->hydra_plugins, $c->model('DB'), $build->project, $build->jobset,
$inputName, $inputType, $inputValue);
push @{$$inputInfo{$inputName}}, $info if defined $info;
};
error($c, $@) if $@;
}
my ($jobs, $nixExprInput) = evalJobs($inputInfo, $exprType, $nixExprInputName, $nixExprPath);
my $job;
foreach my $j (@{$jobs->{job}}) {
print STDERR $j->{jobName}, "\n";
if ($j->{jobName} eq $jobName) {
error($c, "Nix expression returned multiple builds for job $jobName.")
if $job;
$job = $j;
}
}
error($c, "Nix expression did not return a job named $jobName.") unless $job;
my %currentBuilds;
my $newBuild = checkBuild(
$c->model('DB'), $build->project, $build->jobset,
$inputInfo, $nixExprInput, $job, \%currentBuilds, undef, {});
error($c, "This build has already been performed.") unless $newBuild;
$c->flash->{buildMsg} = "Build " . $newBuild->id . " added to the queue.";
$c->res->redirect($c->uri_for($c->controller('Root')->action_for('queue')));
}
sub get_info : Chained('buildChain') PathPart('api/get-info') Args(0) {
my ($self, $c) = @_;
my $build = $c->stash->{build};
@ -614,6 +522,22 @@ sub evals : Chained('buildChain') PathPart('evals') Args(0) {
}
# Redirect to the latest finished evaluation that contains this build.
sub eval : Chained('buildChain') PathPart('eval') {
my ($self, $c, @rest) = @_;
my $eval = $c->stash->{build}->jobsetevals->find(
{ hasnewbuilds => 1 },
{ order_by => "id DESC", rows => 1
, "not exists (select 1 from jobsetevalmembers m2 join builds b2 on me.eval = m2.eval and m2.build = b2.id and b2.finished = 0)"
});
notFound($c, "There is no finished evaluation containing this build.") unless defined $eval;
$c->res->redirect($c->uri_for($c->controller('JobsetEval')->action_for("view"), [$eval->id], @rest, $c->req->params));
}
sub reproduce : Chained('buildChain') PathPart('reproduce') Args(0) {
my ($self, $c) = @_;
$c->response->content_type('text/x-shellscript');

View file

@ -20,24 +20,52 @@ sub job : Chained('/') PathPart('job') CaptureArgs(3) {
sub overview : Chained('job') PathPart('') Args(0) {
my ($self, $c) = @_;
my $job = $c->stash->{job};
$c->stash->{template} = 'job.tt';
$c->stash->{lastBuilds} =
[ $c->stash->{job}->builds->search({ finished => 1 },
[ $job->builds->search({ finished => 1 },
{ order_by => 'id DESC', rows => 10, columns => [@buildListColumns] }) ];
$c->stash->{queuedBuilds} = [
$c->stash->{job}->builds->search(
$job->builds->search(
{ finished => 0 },
{ join => ['project']
, order_by => ["priority DESC", "id"]
, '+select' => ['project.enabled']
, '+as' => ['enabled']
}
{ order_by => ["priority DESC", "id"] }
) ];
$c->stash->{systems} = [$c->stash->{job}->builds->search({iscurrent => 1}, {select => ["system"], distinct => 1})];
# If this is an aggregate job, then get its constituents.
my @constituents = $c->model('DB::Builds')->search(
{ aggregate => { -in => $job->builds->search({}, { columns => ["id"], order_by => "id desc", rows => 15 })->as_query } },
{ join => 'aggregateconstituents_constituents',
columns => ['id', 'job', 'finished', 'buildstatus'],
+select => ['aggregateconstituents_constituents.aggregate'],
+as => ['aggregate']
});
my $aggregates = {};
my %constituentJobs;
foreach my $b (@constituents) {
my $jobName = $b->get_column('job');
$aggregates->{$b->get_column('aggregate')}->{constituents}->{$jobName} =
{ id => $b->id, finished => $b->finished, buildstatus => $b->buildstatus };
$constituentJobs{$jobName} = 1;
}
foreach my $agg (keys %$aggregates) {
# FIXME: could be done in one query.
$aggregates->{$agg}->{build} =
$c->model('DB::Builds')->find({id => $agg}, {columns => [@buildListColumns]}) or die;
}
$c->stash->{aggregates} = $aggregates;
$c->stash->{constituentJobs} = [sort (keys %constituentJobs)];
$c->stash->{starred} = $c->user->starredjobs(
{ project => $c->stash->{project}->name
, jobset => $c->stash->{jobset}->name
, job => $c->stash->{job}->name
})->count == 1 if $c->user_exists;
}
@ -45,9 +73,6 @@ sub overview : Chained('job') PathPart('') Args(0) {
sub get_builds : Chained('job') PathPart('') CaptureArgs(0) {
my ($self, $c) = @_;
$c->stash->{allBuilds} = $c->stash->{job}->builds;
$c->stash->{jobStatus} = $c->model('DB')->resultset('JobStatusForJob')
->search({}, {bind => [$c->stash->{project}->name, $c->stash->{jobset}->name, $c->stash->{job}->name]});
$c->stash->{allJobs} = $c->stash->{job_};
$c->stash->{latestSucceeded} = $c->model('DB')->resultset('LatestSucceededForJob')
->search({}, {bind => [$c->stash->{project}->name, $c->stash->{jobset}->name, $c->stash->{job}->name]});
$c->stash->{channelBaseName} =
@ -55,4 +80,22 @@ sub get_builds : Chained('job') PathPart('') CaptureArgs(0) {
}
sub star : Chained('job') PathPart('star') Args(0) {
my ($self, $c) = @_;
requirePost($c);
requireUser($c);
my $args =
{ project => $c->stash->{project}->name
, jobset => $c->stash->{jobset}->name
, job => $c->stash->{job}->name
};
if ($c->request->params->{star} eq "1") {
$c->user->starredjobs->update_or_create($args);
} else {
$c->user->starredjobs->find($args)->delete;
}
$c->stash->{resource}->{success} = 1;
}
1;

View file

@ -1,5 +1,6 @@
package Hydra::Controller::Jobset;
use utf8;
use strict;
use warnings;
use base 'Hydra::Base::Controller::ListBuilds';
@ -9,35 +10,18 @@ use Hydra::Helper::CatalystUtils;
sub jobsetChain :Chained('/') :PathPart('jobset') :CaptureArgs(2) {
my ($self, $c, $projectName, $jobsetName) = @_;
$c->stash->{params}->{name} //= $jobsetName;
my $project = $c->model('DB::Projects')->find($projectName);
if ($project) {
$c->stash->{project} = $project;
notFound($c, "Project $projectName doesn't exist.") if !$project;
$c->stash->{jobset_} = $project->jobsets->search({'me.name' => $jobsetName});
my $jobset = $c->stash->{jobset_}->single;
$c->stash->{project} = $project;
if ($jobset) {
$c->stash->{jobset} = $jobset;
} else {
if ($c->action->name eq "jobset" and $c->request->method eq "PUT") {
$c->stash->{jobsetName} = $jobsetName;
} else {
$self->status_not_found(
$c,
message => "Jobset $jobsetName doesn't exist."
);
$c->detach;
}
}
} else {
$self->status_not_found(
$c,
message => "Project $projectName doesn't exist."
);
$c->detach;
}
$c->stash->{jobset} = $project->jobsets->find({ name => $jobsetName });
notFound($c, "Jobset $jobsetName doesn't exist.")
if !$c->stash->{jobset} && !($c->action->name eq "jobset" and $c->request->method eq "PUT");
}
@ -50,26 +34,11 @@ sub jobset_GET {
$c->stash->{evals} = getEvals($self, $c, scalar $c->stash->{jobset}->jobsetevals, 0, 10);
($c->stash->{latestEval}) = $c->stash->{jobset}->jobsetevals->search({}, { limit => 1, order_by => ["id desc"] });
$c->stash->{latestEval} = $c->stash->{jobset}->jobsetevals->search({}, { rows => 1, order_by => ["id desc"] })->single;
$self->status_ok(
$c,
entity => $c->stash->{jobset_}->find({}, {
columns => [
'me.name',
'me.project',
'me.errormsg',
'jobsetinputs.name',
{
'jobsetinputs.jobsetinputalts.altnr' => 'jobsetinputalts.altnr',
'jobsetinputs.jobsetinputalts.value' => 'jobsetinputalts.value'
}
],
join => { 'jobsetinputs' => 'jobsetinputalts' },
collapse => 1,
order_by => "me.name"
})
);
$c->stash->{totalShares} = getTotalShares($c->model('DB')->schema);
$self->status_ok($c, entity => $c->stash->{jobset});
}
sub jobset_PUT {
@ -78,133 +47,91 @@ sub jobset_PUT {
requireProjectOwner($c, $c->stash->{project});
if (defined $c->stash->{jobset}) {
error($c, "Cannot rename jobset `$c->stash->{params}->{oldName}' over existing jobset `$c->stash->{jobset}->name") if defined $c->stash->{params}->{oldName} and $c->stash->{params}->{oldName} ne $c->stash->{jobset}->name;
txn_do($c->model('DB')->schema, sub {
updateJobset($c, $c->stash->{jobset});
});
if ($c->req->looks_like_browser) {
$c->res->redirect($c->uri_for($self->action_for("jobset"),
[$c->stash->{project}->name, $c->stash->{jobset}->name]) . "#tabs-configuration");
} else {
$self->status_no_content($c);
}
} elsif (defined $c->stash->{params}->{oldName}) {
my $jobset = $c->stash->{project}->jobsets->find({'me.name' => $c->stash->{params}->{oldName}});
my $uri = $c->uri_for($self->action_for("jobset"), [$c->stash->{project}->name, $c->stash->{jobset}->name]) . "#tabs-configuration";
$self->status_ok($c, entity => { redirect => "$uri" });
if (defined $jobset) {
txn_do($c->model('DB')->schema, sub {
updateJobset($c, $jobset);
});
my $uri = $c->uri_for($self->action_for("jobset"), [$c->stash->{project}->name, $jobset->name]);
if ($c->req->looks_like_browser) {
$c->res->redirect($uri . "#tabs-configuration");
} else {
$self->status_created(
$c,
location => "$uri",
entity => { name => $jobset->name, uri => "$uri", type => "jobset" }
);
}
} else {
$self->status_not_found(
$c,
message => "Jobset $c->stash->{params}->{oldName} doesn't exist."
);
}
} else {
my $exprType =
$c->stash->{params}->{"nixexprpath"} =~ /.scm$/ ? "guile" : "nix";
error($c, "Invalid jobset name: $c->stash->{jobsetName}") if $c->stash->{jobsetName} !~ /^$jobsetNameRE$/;
$c->flash->{successMsg} = "The jobset configuration has been updated.";
}
else {
my $jobset;
txn_do($c->model('DB')->schema, sub {
# Note: $jobsetName is validated in updateProject, which will
# abort the transaction if the name isn't valid.
$jobset = $c->stash->{project}->jobsets->create(
{name => $c->stash->{jobsetName}, nixexprinput => "", nixexprpath => "", emailoverride => ""});
{name => ".tmp", nixexprinput => "", nixexprpath => "", emailoverride => ""});
updateJobset($c, $jobset);
});
my $uri = $c->uri_for($self->action_for("jobset"), [$c->stash->{project}->name, $jobset->name]);
if ($c->req->looks_like_browser) {
$c->res->redirect($uri . "#tabs-configuration");
} else {
$self->status_created(
$c,
location => "$uri",
entity => { name => $jobset->name, uri => "$uri", type => "jobset" }
);
}
$self->status_created($c,
location => "$uri",
entity => { name => $jobset->name, uri => "$uri", redirect => "$uri", type => "jobset" });
}
}
sub jobset_DELETE {
my ($self, $c) = @_;
requireProjectOwner($c, $c->stash->{project});
txn_do($c->model('DB')->schema, sub {
$c->stash->{jobset}->jobsetevals->delete;
$c->stash->{jobset}->builds->delete;
$c->stash->{jobset}->delete;
});
my $uri = $c->uri_for($c->controller('Project')->action_for("project"), [$c->stash->{project}->name]);
$self->status_ok($c, entity => { redirect => "$uri" });
$c->flash->{successMsg} = "The jobset has been deleted.";
}
sub jobs_tab : Chained('jobsetChain') PathPart('jobs-tab') Args(0) {
my ($self, $c) = @_;
$c->stash->{template} = 'jobset-jobs-tab.tt';
$c->stash->{activeJobs} = [];
$c->stash->{inactiveJobs} = [];
$c->stash->{filter} = $c->request->params->{filter} // "";
my $filter = "%" . $c->stash->{filter} . "%";
(my $latestEval) = $c->stash->{jobset}->jobsetevals->search(
{ hasnewbuilds => 1}, { limit => 1, order_by => ["id desc"] });
my @evals = $c->stash->{jobset}->jobsetevals->search({ hasnewbuilds => 1}, { order_by => "id desc", rows => 20 });
my %activeJobs;
if (defined $latestEval) {
foreach my $build ($latestEval->builds->search({}, { order_by => ["job"], select => ["job"] })) {
my $job = $build->get_column("job");
if (!defined $activeJobs{$job}) {
$activeJobs{$job} = 1;
push @{$c->stash->{activeJobs}}, $job;
}
my $evals = {};
my %jobs;
my $nrBuilds = 0;
foreach my $eval (@evals) {
my @builds = $eval->builds->search(
{ job => { ilike => $filter } },
{ columns => ['id', 'job', 'finished', 'buildstatus'] });
foreach my $b (@builds) {
my $jobName = $b->get_column('job');
$evals->{$eval->id}->{$jobName} =
{ id => $b->id, finished => $b->finished, buildstatus => $b->buildstatus };
$jobs{$jobName} = 1;
$nrBuilds++;
}
last if $nrBuilds >= 10000;
}
if ($c->request->params->{showInactive}) {
$c->stash->{showInactive} = 1;
foreach my $job ($c->stash->{jobset}->jobs->search({ name => { ilike => $filter } })) {
next if defined $jobs{$job->name};
$c->stash->{inactiveJobs}->{$job->name} = $jobs{$job->name} = 1;
}
}
foreach my $job ($c->stash->{jobset}->jobs->search({}, { order_by => ["name"] })) {
if (!defined $activeJobs{$job->name}) {
push @{$c->stash->{inactiveJobs}}, $job->name;
}
}
}
sub status_tab : Chained('jobsetChain') PathPart('status-tab') Args(0) {
my ($self, $c) = @_;
$c->stash->{template} = 'jobset-status-tab.tt';
# FIXME: use latest eval instead of iscurrent.
$c->stash->{systems} =
[ $c->stash->{jobset}->builds->search({ iscurrent => 1 }, { select => ["system"], distinct => 1, order_by => "system" }) ];
# status per system
my @systems = ();
foreach my $system (@{$c->stash->{systems}}) {
push(@systems, $system->system);
}
my @select = ();
my @as = ();
push(@select, "job"); push(@as, "job");
foreach my $system (@systems) {
push(@select, "(select buildstatus from Builds b where b.id = (select max(id) from Builds t where t.project = me.project and t.jobset = me.jobset and t.job = me.job and t.system = '$system' and t.iscurrent = 1 ))");
push(@as, $system);
push(@select, "(select b.id from Builds b where b.id = (select max(id) from Builds t where t.project = me.project and t.jobset = me.jobset and t.job = me.job and t.system = '$system' and t.iscurrent = 1 ))");
push(@as, "$system-build");
}
$c->stash->{activeJobsStatus} = [
$c->model('DB')->resultset('ActiveJobsForJobset')->search(
{},
{ bind => [$c->stash->{project}->name, $c->stash->{jobset}->name]
, select => \@select
, as => \@as
, order_by => ["job"]
}) ];
$c->stash->{evals} = $evals;
my @jobs = sort (keys %jobs);
$c->stash->{nrJobs} = scalar @jobs;
splice @jobs, 250 if $c->stash->{filter} eq "";
$c->stash->{jobs} = [@jobs];
}
@ -212,10 +139,6 @@ sub status_tab : Chained('jobsetChain') PathPart('status-tab') Args(0) {
sub get_builds : Chained('jobsetChain') PathPart('') CaptureArgs(0) {
my ($self, $c) = @_;
$c->stash->{allBuilds} = $c->stash->{jobset}->builds;
$c->stash->{jobStatus} = $c->model('DB')->resultset('JobStatusForJobset')
->search({}, {bind => [$c->stash->{project}->name, $c->stash->{jobset}->name]});
$c->stash->{allJobsets} = $c->stash->{jobset_};
$c->stash->{allJobs} = $c->stash->{jobset}->jobs;
$c->stash->{latestSucceeded} = $c->model('DB')->resultset('LatestSucceededForJobset')
->search({}, {bind => [$c->stash->{project}->name, $c->stash->{jobset}->name]});
$c->stash->{channelBaseName} =
@ -230,31 +153,8 @@ sub edit : Chained('jobsetChain') PathPart Args(0) {
$c->stash->{template} = 'edit-jobset.tt';
$c->stash->{edit} = 1;
}
sub submit : Chained('jobsetChain') PathPart Args(0) {
my ($self, $c) = @_;
requirePost($c);
if (($c->request->params->{submit} // "") eq "delete") {
txn_do($c->model('DB')->schema, sub {
$c->stash->{jobset}->jobsetevals->delete_all;
$c->stash->{jobset}->builds->delete_all;
$c->stash->{jobset}->delete;
});
return $c->res->redirect($c->uri_for($c->controller('Project')->action_for("project"), [$c->stash->{project}->name]));
}
my $newName = trim $c->stash->{params}->{name};
my $oldName = trim $c->stash->{jobset}->name;
unless ($oldName eq $newName) {
$c->stash->{params}->{oldName} = $oldName;
$c->stash->{jobsetName} = $newName;
undef $c->stash->{jobset};
}
jobset_PUT($self, $c);
$c->stash->{clone} = defined $c->stash->{params}->{clone};
$c->stash->{totalShares} = getTotalShares($c->model('DB')->schema);
}
@ -263,10 +163,10 @@ sub nixExprPathFromParams {
# The Nix expression path must be relative and can't contain ".." elements.
my $nixExprPath = trim $c->stash->{params}->{"nixexprpath"};
error($c, "Invalid Nix expression path: $nixExprPath") if $nixExprPath !~ /^$relPathRE$/;
error($c, "Invalid Nix expression path $nixExprPath.") if $nixExprPath !~ /^$relPathRE$/;
my $nixExprInput = trim $c->stash->{params}->{"nixexprinput"};
error($c, "Invalid Nix expression input name: $nixExprInput") unless $nixExprInput =~ /^\w+$/;
error($c, "Invalid Nix expression input name $nixExprInput.") unless $nixExprInput =~ /^[[:alpha:]][\w-]*$/;
return ($nixExprPath, $nixExprInput);
}
@ -275,7 +175,7 @@ sub nixExprPathFromParams {
sub checkInputValue {
my ($c, $type, $value) = @_;
$value = trim $value;
error($c, "Invalid Boolean value: $value") if
error($c, "Invalid Boolean value $value.") if
$type eq "boolean" && !($value eq "true" || $value eq "false");
return $value;
}
@ -284,8 +184,11 @@ sub checkInputValue {
sub updateJobset {
my ($c, $jobset) = @_;
my $jobsetName = $c->stash->{jobsetName} // $jobset->name;
error($c, "Invalid jobset name: $jobsetName") if $jobsetName !~ /^$jobsetNameRE$/;
my $jobsetName = $c->stash->{params}->{name};
error($c, "Invalid jobset identifier $jobsetName.") if $jobsetName !~ /^$jobsetNameRE$/;
error($c, "Cannot rename jobset to $jobsetName since that identifier is already taken.")
if $jobsetName ne $jobset->name && defined $c->stash->{project}->jobsets->find({ name => $jobsetName });
# When the expression is in a .scm file, assume it's a Guile + Guix
# build expression.
@ -294,118 +197,61 @@ sub updateJobset {
my ($nixExprPath, $nixExprInput) = nixExprPathFromParams $c;
my $enabled = int($c->stash->{params}->{enabled});
die if $enabled < 0 || $enabled > 2;
$jobset->update(
{ name => $jobsetName
, description => trim($c->stash->{params}->{"description"})
, nixexprpath => $nixExprPath
, nixexprinput => $nixExprInput
, enabled => defined $c->stash->{params}->{enabled} ? 1 : 0
, enabled => $enabled
, enableemail => defined $c->stash->{params}->{enableemail} ? 1 : 0
, emailoverride => trim($c->stash->{params}->{emailoverride}) || ""
, hidden => defined $c->stash->{params}->{visible} ? 0 : 1
, keepnr => int(trim($c->stash->{params}->{keepnr})) || 3
, keepnr => int(trim($c->stash->{params}->{keepnr}))
, checkinterval => int(trim($c->stash->{params}->{checkinterval}))
, triggertime => $jobset->triggertime // time()
, triggertime => $enabled ? $jobset->triggertime // time() : undef
, schedulingshares => int($c->stash->{params}->{schedulingshares})
});
# Process the inputs of this jobset.
unless (defined $c->stash->{params}->{inputs}) {
$c->stash->{params}->{inputs} = {};
foreach my $param (keys %{$c->stash->{params}}) {
next unless $param =~ /^input-(\w+)-name$/;
my $baseName = $1;
next if $baseName eq "template";
$c->stash->{params}->{inputs}->{$c->stash->{params}->{$param}} = { type => $c->stash->{params}->{"input-$baseName-type"}, values => $c->stash->{params}->{"input-$baseName-values"} };
unless ($baseName =~ /^\d+$/) { # non-numeric base name is an existing entry
$c->stash->{params}->{inputs}->{$c->stash->{params}->{$param}}->{oldName} = $baseName;
}
}
}
# Set the inputs of this jobset.
$jobset->jobsetinputs->delete;
foreach my $inputName (keys %{$c->stash->{params}->{inputs}}) {
my $inputData = $c->stash->{params}->{inputs}->{$inputName};
error($c, "Invalid input name: $inputName") unless $inputName =~ /^[[:alpha:]]\w*$/;
my $inputType = $inputData->{type};
error($c, "Invalid input type: $inputType") unless
$inputType eq "svn" || $inputType eq "svn-checkout" || $inputType eq "hg" || $inputType eq "tarball" ||
$inputType eq "string" || $inputType eq "path" || $inputType eq "boolean" || $inputType eq "bzr" || $inputType eq "bzr-checkout" ||
$inputType eq "git" || $inputType eq "build" || $inputType eq "sysbuild" ;
my $input;
unless (defined $inputData->{oldName}) {
$input = $jobset->jobsetinputs->update_or_create(
{ name => $inputName
, type => $inputType
});
} else { # it's an existing input
$input = ($jobset->jobsetinputs->search({name => $inputData->{oldName}}))[0];
die unless defined $input;
$input->update({name => $inputName, type => $inputType});
}
# Update the values for this input. Just delete all the
# current ones, then create the new values.
$input->jobsetinputalts->delete_all;
foreach my $name (keys %{$c->stash->{params}->{inputs}}) {
my $inputData = $c->stash->{params}->{inputs}->{$name};
my $type = $inputData->{type};
my $values = $inputData->{values};
$values = [] unless defined $values;
$values = [$values] unless ref($values) eq 'ARRAY';
my $emailresponsible = defined $inputData->{emailresponsible} ? 1 : 0;
error($c, "Invalid input name $name.") unless $name =~ /^[[:alpha:]][\w-]*$/;
error($c, "Invalid input type $type.") unless defined $c->stash->{inputTypes}->{$type};
my $input = $jobset->jobsetinputs->create({
name => $name,
type => $type,
emailresponsible => $emailresponsible
});
# Set the values for this input.
my @values = ref($values) eq 'ARRAY' ? @{$values} : ($values);
my $altnr = 0;
foreach my $value (@{$values}) {
$value = checkInputValue($c, $inputType, $value);
foreach my $value (@values) {
$value = checkInputValue($c, $type, $value);
$input->jobsetinputalts->create({altnr => $altnr++, value => $value});
}
}
# Get rid of deleted inputs.
my @inputs = $jobset->jobsetinputs->all;
foreach my $input (@inputs) {
$input->delete unless defined $c->stash->{params}->{inputs}->{$input->name};
}
}
sub clone : Chained('jobsetChain') PathPart('clone') Args(0) {
my ($self, $c) = @_;
my $jobset = $c->stash->{jobset};
requireProjectOwner($c, $jobset->project);
requireProjectOwner($c, $c->stash->{project});
$c->stash->{template} = 'clone-jobset.tt';
}
sub clone_submit : Chained('jobsetChain') PathPart('clone/submit') Args(0) {
my ($self, $c) = @_;
my $jobset = $c->stash->{jobset};
requireProjectOwner($c, $jobset->project);
requirePost($c);
my $newJobsetName = trim $c->stash->{params}->{"newjobset"};
error($c, "Invalid jobset name: $newJobsetName") unless $newJobsetName =~ /^[[:alpha:]][\w\-]*$/;
my $newJobset;
txn_do($c->model('DB')->schema, sub {
$newJobset = $jobset->project->jobsets->create(
{ name => $newJobsetName
, description => $jobset->description
, nixexprpath => $jobset->nixexprpath
, nixexprinput => $jobset->nixexprinput
, enabled => 0
, enableemail => $jobset->enableemail
, emailoverride => $jobset->emailoverride || ""
});
foreach my $input ($jobset->jobsetinputs) {
my $newinput = $newJobset->jobsetinputs->create({name => $input->name, type => $input->type});
foreach my $inputalt ($input->jobsetinputalts) {
$newinput->jobsetinputalts->create({altnr => $inputalt->altnr, value => $inputalt->value});
}
}
});
$c->res->redirect($c->uri_for($c->controller('Jobset')->action_for("edit"), [$jobset->project->name, $newJobsetName]));
$c->stash->{template} = 'edit-jobset.tt';
$c->stash->{clone} = 1;
$c->stash->{totalShares} = getTotalShares($c->model('DB')->schema);
}
@ -428,24 +274,7 @@ sub evals_GET {
my $offset = ($page - 1) * $resultsPerPage;
$c->stash->{evals} = getEvals($self, $c, $evals, $offset, $resultsPerPage);
my %entity = (
evals => [ $evals->search({ 'me.hasnewbuilds' => 1 }, {
columns => [
'me.hasnewbuilds',
'me.id',
'jobsetevalinputs.name',
'jobsetevalinputs.altnr',
'jobsetevalinputs.revision',
'jobsetevalinputs.type',
'jobsetevalinputs.uri',
'jobsetevalinputs.dependency',
'jobsetevalmembers.build',
],
join => [ 'jobsetevalinputs', 'jobsetevalmembers' ],
collapse => 1,
rows => $resultsPerPage,
offset => $offset,
order_by => "me.id DESC",
}) ],
evals => [ map { $_->{eval} } @{$c->stash->{evals}} ],
first => "?page=1",
last => "?page=" . POSIX::ceil($c->stash->{total}/$resultsPerPage)
);

View file

@ -26,6 +26,9 @@ sub view : Chained('eval') PathPart('') Args(0) {
my $eval = $c->stash->{eval};
$c->stash->{filter} = $c->request->params->{filter} // "";
my $filter = $c->stash->{filter} eq "" ? {} : { job => { ilike => "%" . $c->stash->{filter} . "%" } };
my $compare = $c->req->params->{compare};
my $eval2;
@ -36,6 +39,11 @@ sub view : Chained('eval') PathPart('') Args(0) {
if ($compare =~ /^\d+$/) {
$eval2 = $c->model('DB::JobsetEvals')->find($compare)
or notFound($c, "Evaluation $compare doesn't exist.");
} elsif ($compare =~ /^-(\d+)$/) {
my $t = int($1);
$eval2 = $c->stash->{jobset}->jobsetevals->find(
{ hasnewbuilds => 1, timestamp => {'<=', $eval->timestamp - $t} },
{ order_by => "timestamp desc", rows => 1});
} elsif (defined $compare && $compare =~ /^($jobsetNameRE)$/) {
my $j = $c->stash->{project}->jobsets->find({name => $compare})
or notFound($c, "Jobset $compare doesn't exist.");
@ -51,10 +59,17 @@ sub view : Chained('eval') PathPart('') Args(0) {
$c->stash->{otherEval} = $eval2 if defined $eval2;
my @builds = $eval->builds->search({}, { order_by => ["job", "system", "id"], columns => [@buildListColumns] });
my @builds2 = defined $eval2
? $eval2->builds->search({}, { order_by => ["job", "system", "id"], columns => [@buildListColumns] })
: ();
sub cmpBuilds {
my ($a, $b) = @_;
return $a->get_column('job') cmp $b->get_column('job')
|| $a->get_column('system') cmp $b->get_column('system')
}
my @builds = $eval->builds->search($filter, { columns => [@buildListColumns] });
my @builds2 = defined $eval2 ? $eval2->builds->search($filter, { columns => [@buildListColumns] }) : ();
@builds = sort { cmpBuilds($a, $b) } @builds;
@builds2 = sort { cmpBuilds($a, $b) } @builds2;
$c->stash->{stillSucceed} = [];
$c->stash->{stillFail} = [];
@ -63,15 +78,19 @@ sub view : Chained('eval') PathPart('') Args(0) {
$c->stash->{new} = [];
$c->stash->{removed} = [];
$c->stash->{unfinished} = [];
$c->stash->{aborted} = [];
my $n = 0;
foreach my $build (@builds) {
if ($build->finished != 0 && ($build->buildstatus == 3 || $build->buildstatus == 4)) {
push @{$c->stash->{aborted}}, $build;
next;
}
my $d;
my $found = 0;
while ($n < scalar(@builds2)) {
my $build2 = $builds2[$n];
my $d = $build->get_column('job') cmp $build2->get_column('job')
|| $build->get_column('system') cmp $build2->get_column('system');
my $d = cmpBuilds($build, $build2);
last if $d == -1;
if ($d == 0) {
$n++;
@ -135,6 +154,25 @@ sub release : Chained('eval') PathPart('release') Args(0) {
}
sub cancel : Chained('eval') PathPart('cancel') Args(0) {
my ($self, $c) = @_;
requireProjectOwner($c, $c->stash->{eval}->project);
my $n = cancelBuilds($c->model('DB')->schema, $c->stash->{eval}->builds);
$c->flash->{successMsg} = "$n builds have been cancelled.";
$c->res->redirect($c->uri_for($c->controller('JobsetEval')->action_for('view'), $c->req->captures));
}
sub restart_aborted : Chained('eval') PathPart('restart-aborted') Args(0) {
my ($self, $c) = @_;
requireProjectOwner($c, $c->stash->{eval}->project);
my $builds = $c->stash->{eval}->builds->search({ finished => 1, buildstatus => { -in => [3, 4] } });
my $n = restartBuilds($c->model('DB')->schema, $builds);
$c->flash->{successMsg} = "$n builds have been restarted.";
$c->res->redirect($c->uri_for($c->controller('JobsetEval')->action_for('view'), $c->req->captures));
}
# Hydra::Base::Controller::NixChannel needs this.
sub nix : Chained('eval') PathPart('channel') CaptureArgs(0) {
my ($self, $c) = @_;
@ -144,8 +182,20 @@ sub nix : Chained('eval') PathPart('channel') CaptureArgs(0) {
->search({ finished => 1, buildstatus => 0 },
{ columns => [@buildListColumns, 'drvpath', 'description', 'homepage']
, join => ["buildoutputs"]
, order_by => ["build.id", "buildoutputs.name"]
, '+select' => ['buildoutputs.path', 'buildoutputs.name'], '+as' => ['outpath', 'outname'] });
}
sub job : Chained('eval') PathPart('job') {
my ($self, $c, $job, @rest) = @_;
my $build = $c->stash->{eval}->builds->find({job => $job});
notFound($c, "This evaluation has no job with the specified name.") unless defined $build;
$c->res->redirect($c->uri_for($c->controller('Build')->action_for("build"), [$build->id], @rest));
}
1;

View file

@ -1,5 +1,6 @@
package Hydra::Controller::Project;
use utf8;
use strict;
use warnings;
use base 'Hydra::Base::Controller::ListBuilds';
@ -9,35 +10,15 @@ use Hydra::Helper::CatalystUtils;
sub projectChain :Chained('/') :PathPart('project') :CaptureArgs(1) {
my ($self, $c, $projectName) = @_;
$c->stash->{params}->{name} //= $projectName;
my $project = $c->model('DB::Projects')->find($projectName, { columns => [
"me.name",
"me.displayName",
"me.description",
"me.enabled",
"me.hidden",
"me.homepage",
"owner.username",
"owner.fullname",
"views.name",
"releases.name",
"releases.timestamp",
"jobsets.name",
], join => [ 'owner', 'views', 'releases', 'jobsets' ], order_by => { -desc => "releases.timestamp" }, collapse => 1 });
$c->stash->{project} = $c->model('DB::Projects')->find($projectName, {
join => [ 'releases' ],
order_by => { -desc => "releases.timestamp" },
});
if ($project) {
$c->stash->{project} = $project;
} else {
if ($c->action->name eq "project" and $c->request->method eq "PUT") {
$c->stash->{projectName} = $projectName;
} else {
$self->status_not_found(
$c,
message => "Project $projectName doesn't exist."
);
$c->detach;
}
}
notFound($c, "Project $projectName doesn't exist.")
if !$c->stash->{project} && !($c->action->name eq "project" and $c->request->method eq "PUT");
}
@ -53,55 +34,27 @@ sub project_GET {
$c->stash->{releases} = [$c->stash->{project}->releases->search({},
{order_by => ["timestamp DESC"]})];
$self->status_ok(
$c,
entity => $c->stash->{project}
);
$self->status_ok($c, entity => $c->stash->{project});
}
sub project_PUT {
my ($self, $c) = @_;
if (defined $c->stash->{project}) {
error($c, "Cannot rename project `$c->stash->{params}->{oldName}' over existing project `$c->stash->{project}->name") if defined $c->stash->{params}->{oldName};
requireProjectOwner($c, $c->stash->{project});
txn_do($c->model('DB')->schema, sub {
updateProject($c, $c->stash->{project});
});
if ($c->req->looks_like_browser) {
$c->res->redirect($c->uri_for($self->action_for("project"), [$c->stash->{project}->name]) . "#tabs-configuration");
} else {
$self->status_no_content($c);
}
} elsif (defined $c->stash->{params}->{oldName}) {
my $project = $c->model('DB::Projects')->find($c->stash->{params}->{oldName});
if (defined $project) {
requireProjectOwner($c, $project);
txn_do($c->model('DB')->schema, sub {
updateProject($c, $project);
});
my $uri = $c->uri_for($self->action_for("project"), [$c->stash->{project}->name]) . "#tabs-configuration";
$self->status_ok($c, entity => { redirect => "$uri" });
my $uri = $c->uri_for($self->action_for("project"), [$project->name]);
$c->flash->{successMsg} = "The project configuration has been updated.";
}
if ($c->req->looks_like_browser) {
$c->res->redirect($uri . "#tabs-configuration");
} else {
$self->status_created(
$c,
location => "$uri",
entity => { name => $project->name, uri => "$uri", type => "project" }
);
}
} else {
$self->status_not_found(
$c,
message => "Project $c->stash->{params}->{oldName} doesn't exist."
);
}
} else {
else {
requireMayCreateProjects($c);
error($c, "Invalid project name: $c->stash->{projectName}") if $c->stash->{projectName} !~ /^$projectNameRE$/;
my $project;
txn_do($c->model('DB')->schema, sub {
@ -110,23 +63,34 @@ sub project_PUT {
# valid. Idem for the owner.
my $owner = $c->user->username;
$project = $c->model('DB::Projects')->create(
{name => $c->stash->{projectName}, displayname => "", owner => $owner});
{ name => ".tmp", displayname => "", owner => $owner });
updateProject($c, $project);
});
my $uri = $c->uri_for($self->action_for("project"), [$project->name]);
if ($c->req->looks_like_browser) {
$c->res->redirect($uri . "#tabs-configuration");
} else {
$self->status_created(
$c,
location => "$uri",
entity => { name => $project->name, uri => "$uri", type => "project" }
);
}
$self->status_created($c,
location => "$uri",
entity => { name => $project->name, uri => "$uri", redirect => "$uri", type => "project" });
}
}
sub project_DELETE {
my ($self, $c) = @_;
requireProjectOwner($c, $c->stash->{project});
txn_do($c->model('DB')->schema, sub {
$c->stash->{project}->jobsetevals->delete;
$c->stash->{project}->builds->delete;
$c->stash->{project}->delete;
});
my $uri = $c->res->redirect($c->uri_for("/"));
$self->status_ok($c, entity => { redirect => "$uri" });
$c->flash->{successMsg} = "The project has been deleted.";
}
sub edit : Chained('projectChain') PathPart Args(0) {
my ($self, $c) = @_;
@ -138,36 +102,10 @@ sub edit : Chained('projectChain') PathPart Args(0) {
}
sub submit : Chained('projectChain') PathPart Args(0) {
my ($self, $c) = @_;
requirePost($c);
if (($c->request->params->{submit} // "") eq "delete") {
txn_do($c->model('DB')->schema, sub {
$c->stash->{project}->jobsetevals->delete_all;
$c->stash->{project}->builds->delete_all;
$c->stash->{project}->delete;
});
return $c->res->redirect($c->uri_for("/"));
}
my $newName = trim $c->stash->{params}->{name};
my $oldName = trim $c->stash->{project}->name;
unless ($oldName eq $newName) {
$c->stash->{params}->{oldName} = $oldName;
$c->stash->{projectName} = $newName;
undef $c->stash->{project};
}
project_PUT($self, $c);
}
sub requireMayCreateProjects {
my ($c) = @_;
requireLogin($c) if !$c->user_exists;
error($c, "Only administrators or authorised users can perform this operation.")
requireUser($c);
accessDenied($c, "Only administrators or authorised users can perform this operation.")
unless $c->check_user_roles('admin') || $c->check_user_roles('create-projects');
}
@ -183,15 +121,6 @@ sub create : Path('/create-project') {
}
sub create_submit : Path('/create-project/submit') {
my ($self, $c) = @_;
$c->stash->{projectName} = trim $c->stash->{params}->{name};
project_PUT($self, $c);
}
sub create_jobset : Chained('projectChain') PathPart('create-jobset') Args(0) {
my ($self, $c) = @_;
@ -200,15 +129,7 @@ sub create_jobset : Chained('projectChain') PathPart('create-jobset') Args(0) {
$c->stash->{template} = 'edit-jobset.tt';
$c->stash->{create} = 1;
$c->stash->{edit} = 1;
}
sub create_jobset_submit : Chained('projectChain') PathPart('create-jobset/submit') Args(0) {
my ($self, $c) = @_;
$c->stash->{jobsetName} = trim $c->stash->{params}->{name};
Hydra::Controller::Jobset::jobset_PUT($self, $c);
$c->stash->{totalShares} = getTotalShares($c->model('DB')->schema);
}
@ -218,15 +139,18 @@ sub updateProject {
my $owner = $project->owner;
if ($c->check_user_roles('admin') and defined $c->stash->{params}->{owner}) {
$owner = trim $c->stash->{params}->{owner};
error($c, "Invalid owner: $owner")
unless defined $c->model('DB::Users')->find({username => $owner});
error($c, "The user name $owner does not exist.")
unless defined $c->model('DB::Users')->find($owner);
}
my $projectName = $c->stash->{projectName} or $project->name;
error($c, "Invalid project name: $projectName") if $projectName !~ /^$projectNameRE$/;
my $projectName = $c->stash->{params}->{name};
error($c, "Invalid project identifier $projectName.") if $projectName !~ /^$projectNameRE$/;
error($c, "Cannot rename project to $projectName since that identifier is already taken.")
if $projectName ne $project->name && defined $c->model('DB::Projects')->find($projectName);
my $displayName = trim $c->stash->{params}->{displayname};
error($c, "Invalid display name: $displayName") if $displayName eq "";
error($c, "You must specify a display name.") if $displayName eq "";
$project->update(
{ name => $projectName
@ -244,10 +168,6 @@ sub updateProject {
sub get_builds : Chained('projectChain') PathPart('') CaptureArgs(0) {
my ($self, $c) = @_;
$c->stash->{allBuilds} = $c->stash->{project}->builds;
$c->stash->{jobStatus} = $c->model('DB')->resultset('JobStatusForProject')
->search({}, {bind => [$c->stash->{project}->name]});
$c->stash->{allJobsets} = $c->stash->{project}->jobsets;
$c->stash->{allJobs} = $c->stash->{project}->jobs;
$c->stash->{latestSucceeded} = $c->model('DB')->resultset('LatestSucceededForProject')
->search({}, {bind => [$c->stash->{project}->name]});
$c->stash->{channelBaseName} = $c->stash->{project}->name;

View file

@ -38,7 +38,7 @@ sub updateRelease {
, description => trim $c->request->params->{description}
});
$release->releasemembers->delete_all;
$release->releasemembers->delete;
foreach my $param (keys %{$c->request->params}) {
next unless $param =~ /^member-(\d+)-description$/;
my $buildId = $1;
@ -72,7 +72,7 @@ sub submit : Chained('release') PathPart('submit') Args(0) {
txn_do($c->model('DB')->schema, sub {
updateRelease($c, $c->stash->{release});
});
$c->res->redirect($c->uri_for($self->action_for("project"),
$c->res->redirect($c->uri_for($self->action_for("view"),
[$c->stash->{project}->name, $c->stash->{release}->name]));
}
}

View file

@ -8,6 +8,7 @@ use Hydra::Helper::CatalystUtils;
use Digest::SHA1 qw(sha1_hex);
use Nix::Store;
use Nix::Config;
use Encode;
# Put this controller at top-level.
__PACKAGE__->config->{namespace} = '';
@ -33,6 +34,7 @@ sub begin :Private {
$c->stash->{inputTypes} = {
'string' => 'String value',
'boolean' => 'Boolean',
'nix' => 'Nix expression',
'build' => 'Build output',
'sysbuild' => 'Build output (same system)'
};
@ -54,12 +56,8 @@ sub index :Path :Args(0) {
$c->stash->{template} = 'overview.tt';
$c->stash->{projects} = [$c->model('DB::Projects')->search(isAdmin($c) ? {} : {hidden => 0}, {order_by => 'name'})];
$c->stash->{newsItems} = [$c->model('DB::NewsItems')->search({}, { order_by => ['createtime DESC'], rows => 5 })];
$self->status_ok(
$c,
entity => [$c->model('DB::Projects')->search(isAdmin($c) ? {} : {hidden => 0}, {
order_by => 'name',
columns => [ 'name', 'displayname' ]
})]
$self->status_ok($c,
entity => $c->stash->{projects}
);
}
@ -72,8 +70,7 @@ sub queue_GET {
$c->stash->{flashMsg} //= $c->flash->{buildMsg};
$self->status_ok(
$c,
entity => [$c->model('DB::Builds')->search(
{finished => 0}, { join => ['project'], order_by => ["priority DESC", "id"], columns => [@buildListColumns], '+select' => ['project.enabled'], '+as' => ['enabled'] })]
entity => [$c->model('DB::Builds')->search({finished => 0}, { order_by => ["priority DESC", "id"]})]
);
}
@ -100,22 +97,7 @@ sub status_GET {
$c,
entity => [ $c->model('DB::BuildSteps')->search(
{ 'me.busy' => 1, 'build.finished' => 0, 'build.busy' => 1 },
{ join => { build => [ 'project', 'job', 'jobset' ] },
columns => [
'me.machine',
'me.system',
'me.stepnr',
'me.drvpath',
'me.starttime',
'build.id',
{
'build.project.name' => 'project.name',
'build.jobset.name' => 'jobset.name',
'build.job.name' => 'job.name'
}
],
order_by => [ 'machine' ]
}
{ order_by => [ 'machine' ], join => [ 'build' ] }
) ]
);
}
@ -150,11 +132,9 @@ sub machines :Local Args(0) {
sub get_builds : Chained('/') PathPart('') CaptureArgs(0) {
my ($self, $c) = @_;
$c->stash->{allBuilds} = $c->model('DB::Builds');
$c->stash->{jobStatus} = $c->model('DB')->resultset('JobStatus');
$c->stash->{allJobsets} = $c->model('DB::Jobsets');
$c->stash->{allJobs} = $c->model('DB::Jobs');
$c->stash->{latestSucceeded} = $c->model('DB')->resultset('LatestSucceeded');
$c->stash->{channelBaseName} = "everything";
$c->stash->{total} = $c->model('DB::NrBuilds')->find('finished')->count;
}
@ -213,35 +193,32 @@ sub default :Path {
sub end : ActionClass('RenderView') {
my ($self, $c) = @_;
my @errors = map { encode_utf8($_); } @{$c->error};
if (defined $c->stash->{json}) {
if (scalar @{$c->error}) {
$c->stash->{json}->{error} = join "\n", @{$c->error};
if (scalar @errors) {
$c->stash->{json}->{error} = join "\n", @errors;
$c->clear_errors;
}
$c->forward('View::JSON');
}
if (scalar @{$c->error}) {
$c->stash->{resource} = { errors => "$c->error" };
elsif (scalar @{$c->error}) {
$c->stash->{resource} = { error => join "\n", @{$c->error} };
$c->stash->{template} = 'error.tt';
$c->stash->{errors} = $c->error;
$c->stash->{errors} = [@errors];
$c->response->status(500) if $c->response->status == 200;
if ($c->response->status >= 300) {
$c->stash->{httpStatus} =
$c->response->status . " " . HTTP::Status::status_message($c->response->status);
}
$c->clear_errors;
} elsif (defined $c->stash->{resource} and
(ref $c->stash->{resource} eq ref {}) and
defined $c->stash->{resource}->{error}) {
$c->stash->{template} = 'error.tt';
$c->stash->{httpStatus} =
$c->response->status . " " . HTTP::Status::status_message($c->response->status);
}
$c->forward('serialize');
$c->forward('serialize') if defined $c->stash->{resource};
}
sub serialize : ActionClass('Serialize') { }
@ -282,6 +259,7 @@ sub narinfo :LocalRegex('^([a-z0-9]+).narinfo$') :Args(0) {
my $path = queryPathFromHashPart($hash);
if (!$path) {
$c->response->status(404);
$c->response->content_type('text/plain');
$c->stash->{plain}->{data} = "does not exist\n";
$c->forward('Hydra::View::Plain');

View file

@ -182,15 +182,11 @@ sub currentUser :Path('/current-user') :ActionClass('REST') { }
sub currentUser_GET {
my ($self, $c) = @_;
requireLogin($c) if !$c->user_exists;
requireUser($c);
$self->status_ok(
$c,
entity => $c->model('DB::Users')->find({ 'me.username' => $c->user->username}, {
columns => [ "me.fullname", "me.emailaddress", "me.username", "userroles.role" ]
, join => [ "userroles" ]
, collapse => 1
})
entity => $c->model("DB::Users")->find($c->user->username)
);
}
@ -198,9 +194,9 @@ sub currentUser_GET {
sub user :Chained('/') PathPart('user') CaptureArgs(1) {
my ($self, $c, $userName) = @_;
requireLogin($c) if !$c->user_exists;
requireUser($c);
error($c, "You do not have permission to edit other users.")
accessDenied($c, "You do not have permission to edit other users.")
if $userName ne $c->user->username && !isAdmin($c);
$c->stash->{user} = $c->model('DB::Users')->find($userName)
@ -287,7 +283,7 @@ sub edit_POST {
}
if (isAdmin($c)) {
$user->userroles->delete_all;
$user->userroles->delete;
$user->userroles->create({ role => $_})
foreach paramToList($c, "roles");
}
@ -303,4 +299,19 @@ sub edit_POST {
}
sub dashboard :Chained('user') :Args(0) {
my ($self, $c) = @_;
$c->stash->{template} = 'dashboard.tt';
# Get the N most recent builds for each starred job.
$c->stash->{starredJobs} = [];
foreach my $j ($c->stash->{user}->starredjobs->search({}, { order_by => ['project', 'jobset', 'job'] })) {
my @builds = $j->job->builds->search(
{ },
{ rows => 20, order_by => "id desc" });
push $c->stash->{starredJobs}, { job => $j->job, builds => [@builds] };
}
}
1;

View file

@ -41,7 +41,7 @@ sub updateView {
{ name => $viewName
, description => trim $c->request->params->{description} });
$view->viewjobs->delete_all;
$view->viewjobs->delete;
foreach my $param (keys %{$c->request->params}) {
next unless $param =~ /^job-(\d+)-name$/;

View file

@ -2,6 +2,7 @@ package Hydra::Helper::AddBuilds;
use strict;
use feature 'switch';
use utf8;
use XML::Simple;
use IPC::Run;
use Nix::Store;
@ -15,6 +16,7 @@ use File::Path;
use File::Temp;
use File::Spec;
use File::Slurp;
use Hydra::Helper::PluginHooks;
our @ISA = qw(Exporter);
our @EXPORT = qw(
@ -86,10 +88,7 @@ sub fetchInputBuild {
{ order_by => "me.id DESC", rows => 1
, where => \ attrsToSQL($attrs, "me.id") });
if (!defined $prevBuild || !isValidPath(getMainOutput($prevBuild)->path)) {
print STDERR "input `", $name, "': no previous build available\n";
return undef;
}
return () if !defined $prevBuild || !isValidPath(getMainOutput($prevBuild)->path);
#print STDERR "input `", $name, "': using build ", $prevBuild->id, "\n";
@ -148,9 +147,8 @@ sub fetchInputSystemBuild {
return @inputs;
}
sub fetchInput {
my ($plugins, $db, $project, $jobset, $name, $type, $value) = @_;
my ($plugins, $db, $project, $jobset, $name, $type, $value, $emailresponsible) = @_;
my @inputs;
if ($type eq "build") {
@ -159,7 +157,7 @@ sub fetchInput {
elsif ($type eq "sysbuild") {
@inputs = fetchInputSystemBuild($db, $project, $jobset, $name, $value);
}
elsif ($type eq "string") {
elsif ($type eq "string" || $type eq "nix") {
die unless defined $value;
@inputs = { value => $value };
}
@ -170,7 +168,7 @@ sub fetchInput {
else {
my $found = 0;
foreach my $plugin (@{$plugins}) {
@inputs = $plugin->fetchInput($type, $name, $value);
@inputs = $plugin->fetchInput($type, $name, $value, $project, $jobset);
if (defined $inputs[0]) {
$found = 1;
last;
@ -179,7 +177,10 @@ sub fetchInput {
die "input `$name' has unknown type `$type'." unless $found;
}
$_->{type} = $type foreach @inputs;
foreach my $input (@inputs) {
$input->{type} = $type;
$input->{emailresponsible} = $emailresponsible;
}
return @inputs;
}
@ -243,6 +244,9 @@ sub inputsToArgs {
when ("boolean") {
push @res, "--arg", $input, booleanToString($exprType, $alt->{value});
}
when ("nix") {
push @res, "--arg", $input, $alt->{value};
}
default {
push @res, "--arg", $input, buildInputToString($exprType, $alt);
}
@ -287,17 +291,25 @@ sub evalJobs {
my $validJob = 1;
foreach my $arg (@{$job->{arg}}) {
my $input = $inputInfo->{$arg->{name}}->[$arg->{altnr}];
if ($input->{type} eq "sysbuild" && $input->{system} ne $job->{system}) {
$validJob = 0;
}
}
if ($validJob) {
push(@filteredJobs, $job);
$validJob = 0 if $input->{type} eq "sysbuild" && $input->{system} ne $job->{system};
}
push(@filteredJobs, $job) if $validJob;
}
$jobs->{job} = \@filteredJobs;
return ($jobs, $nixExprInput);
my %jobNames;
my $errors;
foreach my $job (@{$jobs->{job}}) {
$jobNames{$job->{jobName}}++;
if ($jobNames{$job->{jobName}} == 2) {
$errors .= "warning: there are multiple jobs named $job->{jobName}; support for this will go away soon!\n\n";
}
}
# Handle utf-8 characters in error messages. No idea why this works.
utf8::decode($_->{msg}) foreach @{$jobs->{error}};
return ($jobs, $nixExprInput, $errors);
}
@ -389,7 +401,7 @@ sub getPrevJobsetEval {
# Check whether to add the build described by $buildInfo.
sub checkBuild {
my ($db, $project, $jobset, $inputInfo, $nixExprInput, $buildInfo, $buildIds, $prevEval, $jobOutPathMap) = @_;
my ($db, $jobset, $inputInfo, $nixExprInput, $buildInfo, $buildMap, $prevEval, $jobOutPathMap, $plugins) = @_;
my @outputNames = sort keys %{$buildInfo->{output}};
die unless scalar @outputNames;
@ -410,9 +422,7 @@ sub checkBuild {
my $build;
txn_do($db, sub {
my $job = $jobset->jobs->update_or_create(
{ name => $jobName
});
my $job = $jobset->jobs->update_or_create({ name => $jobName });
# Don't add a build that has already been scheduled for this
# job, or has been built but is still a "current" build for
@ -433,19 +443,19 @@ sub checkBuild {
# semantically unnecessary (because they're implied by
# the eval), but they give a factor 1000 speedup on
# the Nixpkgs jobset with PostgreSQL.
{ project => $project->name, jobset => $jobset->name, job => $job->name,
{ project => $jobset->project->name, jobset => $jobset->name, job => $jobName,
name => $firstOutputName, path => $firstOutputPath },
{ rows => 1, columns => ['id'], join => ['buildoutputs'] });
if (defined $prevBuild) {
print STDERR " already scheduled/built as build ", $prevBuild->id, "\n";
$buildIds->{$prevBuild->id} = 0;
$buildMap->{$prevBuild->id} = { id => $prevBuild->id, jobName => $jobName, new => 0, drvPath => $drvPath };
return;
}
}
# Prevent multiple builds with the same (job, outPath) from
# being added.
my $prev = $$jobOutPathMap{$job->name . "\t" . $firstOutputPath};
my $prev = $$jobOutPathMap{$jobName . "\t" . $firstOutputPath};
if (defined $prev) {
print STDERR " already scheduled as build ", $prev, "\n";
return;
@ -511,12 +521,13 @@ sub checkBuild {
$build->buildoutputs->create({ name => $_, path => $buildInfo->{output}->{$_}->{path} })
foreach @outputNames;
$buildIds->{$build->id} = 1;
$$jobOutPathMap{$job->name . "\t" . $firstOutputPath} = $build->id;
$buildMap->{$build->id} = { id => $build->id, jobName => $jobName, new => 1, drvPath => $drvPath };
$$jobOutPathMap{$jobName . "\t" . $firstOutputPath} = $build->id;
if ($build->iscachedbuild) {
print STDERR " marked as cached build ", $build->id, "\n";
addBuildProducts($db, $build);
notifyBuildFinished($plugins, $build, []);
} else {
print STDERR " added to queue as build ", $build->id, "\n";
}
@ -545,6 +556,7 @@ sub checkBuild {
, uri => $input->{uri}
, revision => $input->{revision}
, value => $input->{value}
, emailresponsible => $input->{emailresponsible}
, dependency => $input->{id}
, path => $input->{storePath} || "" # !!! temporary hack
, sha256hash => $input->{sha256hash}
@ -556,29 +568,4 @@ sub checkBuild {
};
sub restartBuild {
my ($db, $build) = @_;
txn_do($db, sub {
my @paths;
push @paths, $build->drvpath;
push @paths, $_->drvpath foreach $build->buildsteps;
my $r = `nix-store --clear-failed-paths @paths`;
$build->update(
{ finished => 0
, busy => 0
, locker => ""
, iscachedbuild => 0
});
$build->buildproducts->delete_all;
# Reset the stats for the evals to which this build belongs.
# !!! Should do this in a trigger.
foreach my $m ($build->jobsetevalmembers->all) {
$m->eval->update({nrsucceeded => undef});
}
});
}
1;

View file

@ -15,8 +15,8 @@ use feature qw/switch/;
our @ISA = qw(Exporter);
our @EXPORT = qw(
getBuild getPreviousBuild getNextBuild getPreviousSuccessfulBuild
error notFound
requireLogin requireProjectOwner requireAdmin requirePost isAdmin isProjectOwner
error notFound accessDenied
forceLogin requireUser requireProjectOwner requireAdmin requirePost isAdmin isProjectOwner
trim
getLatestFinishedEval
sendEmail
@ -27,6 +27,7 @@ our @EXPORT = qw(
parseJobsetName
showJobName
showStatus
getResponsibleAuthors
);
@ -102,6 +103,12 @@ sub notFound {
}
sub accessDenied {
my ($c, $msg) = @_;
error($c, $msg, 403);
}
sub backToReferer {
my ($c) = @_;
$c->response->redirect($c->session->{referer} || $c->uri_for('/'));
@ -110,26 +117,33 @@ sub backToReferer {
}
sub requireLogin {
sub forceLogin {
my ($c) = @_;
$c->session->{referer} = $c->request->uri;
error($c, "This page requires you to sign in.", 403);
accessDenied($c, "This page requires you to sign in.");
}
sub requireUser {
my ($c) = @_;
forceLogin($c) if !$c->user_exists;
}
sub isProjectOwner {
my ($c, $project) = @_;
return $c->user_exists && ($c->check_user_roles('admin') || $c->user->username eq $project->owner->username || defined $c->model('DB::ProjectMembers')->find({ project => $project, userName => $c->user->username }));
return
$c->user_exists &&
(isAdmin($c) ||
$c->user->username eq $project->owner->username ||
defined $c->model('DB::ProjectMembers')->find({ project => $project, userName => $c->user->username }));
}
sub requireProjectOwner {
my ($c, $project) = @_;
requireLogin($c) if !$c->user_exists;
error($c, "Only the project members or administrators can perform this operation.", 403)
requireUser($c);
accessDenied($c, "Only the project members or administrators can perform this operation.")
unless isProjectOwner($c, $project);
}
@ -142,8 +156,8 @@ sub isAdmin {
sub requireAdmin {
my ($c) = @_;
requireLogin($c) if !$c->user_exists;
error($c, "Only administrators can perform this operation.", 403)
requireUser($c);
accessDenied($c, "Only administrators can perform this operation.")
unless isAdmin($c);
}
@ -206,12 +220,12 @@ sub paramToList {
# Security checking of filenames.
Readonly our $pathCompRE => "(?:[A-Za-z0-9-\+\._\$][A-Za-z0-9-\+\._\$]*)";
Readonly our $pathCompRE => "(?:[A-Za-z0-9-\+\._\$][A-Za-z0-9-\+\._\$:]*)";
Readonly our $relPathRE => "(?:$pathCompRE(?:/$pathCompRE)*)";
Readonly our $relNameRE => "(?:[A-Za-z0-9-_][A-Za-z0-9-\._]*)";
Readonly our $attrNameRE => "(?:[A-Za-z_][A-Za-z0-9-_]*)";
Readonly our $projectNameRE => "(?:[A-Za-z_][A-Za-z0-9-_]*)";
Readonly our $jobsetNameRE => "(?:[A-Za-z_][A-Za-z0-9-_]*)";
Readonly our $jobsetNameRE => "(?:[A-Za-z_][A-Za-z0-9-_\.]*)";
Readonly our $jobNameRE => "(?:$attrNameRE(?:\\.$attrNameRE)*)";
Readonly our $systemRE => "(?:[a-z0-9_]+-[a-z0-9_]+)";
Readonly our $userNameRE => "(?:[a-z][a-z0-9_\.]*)";
@ -246,4 +260,42 @@ sub showStatus {
}
# Determine who broke/fixed the build.
sub getResponsibleAuthors {
my ($build, $plugins) = @_;
my $prevBuild = getPreviousBuild($build);
my $nrCommits = 0;
my %authors;
my @emailable_authors;
if ($prevBuild) {
foreach my $curInput ($build->buildinputs_builds) {
next unless ($curInput->type eq "git" || $curInput->type eq "hg");
my $prevInput = $prevBuild->buildinputs_builds->find({ name => $curInput->name });
next unless defined $prevInput;
next if $curInput->type ne $prevInput->type;
next if $curInput->uri ne $prevInput->uri;
next if $curInput->revision eq $prevInput->revision;
my @commits;
foreach my $plugin (@{$plugins}) {
push @commits, @{$plugin->getCommits($curInput->type, $curInput->uri, $prevInput->revision, $curInput->revision)};
}
foreach my $commit (@commits) {
#print STDERR "$commit->{revision} by $commit->{author}\n";
$authors{$commit->{author}} = $commit->{email};
push @emailable_authors, $commit->{email} if $curInput->emailresponsible;
$nrCommits++;
}
}
}
return (\%authors, $nrCommits, \@emailable_authors);
}
1;

View file

@ -7,6 +7,7 @@ use File::Basename;
use Config::General;
use Hydra::Helper::CatalystUtils;
use Hydra::Model::DB;
use Nix::Store;
our @ISA = qw(Exporter);
our @EXPORT = qw(
@ -16,11 +17,13 @@ our @EXPORT = qw(
getPrimaryBuildsForView
getPrimaryBuildTotal
getViewResult getLatestSuccessfulViewResult
jobsetOverview removeAsciiEscapes getDrvLogPath logContents
jobsetOverview removeAsciiEscapes getDrvLogPath findLog logContents
getMainOutput
getEvals getMachines
pathIsInsidePrefix
captureStdoutStderr);
captureStdoutStderr run grab
getTotalShares
cancelBuilds restartBuilds);
sub getHydraHome {
@ -42,11 +45,12 @@ sub getHydraConfig {
# doesn't work.
sub txn_do {
my ($db, $coderef) = @_;
my $res;
while (1) {
eval {
$db->txn_do($coderef);
$res = $db->txn_do($coderef);
};
last if !$@;
return $res if !$@;
die $@ unless $@ =~ "database is locked";
}
}
@ -253,21 +257,46 @@ sub getLatestSuccessfulViewResult {
sub getDrvLogPath {
my ($drvPath) = @_;
my $base = basename $drvPath;
my $fn =
($ENV{NIX_LOG_DIR} || "/nix/var/log/nix") . "/drvs/"
. substr($base, 0, 2) . "/"
. substr($base, 2);
return $fn if -f $fn;
$fn .= ".bz2";
return $fn if -f $fn;
my $bucketed = substr($base, 0, 2) . "/" . substr($base, 2);
my $fn = ($ENV{NIX_LOG_DIR} || "/nix/var/log/nix") . "/drvs/";
for ($fn . $bucketed . ".bz2", $fn . $bucketed, $fn . $base . ".bz2", $fn . $base) {
return $_ if (-f $_);
}
return undef;
}
# Find the log of the derivation denoted by $drvPath. It it doesn't
# exist, try other derivations that produced its outputs (@outPaths).
sub findLog {
my ($c, $drvPath, @outPaths) = @_;
if (defined $drvPath) {
my $logPath = getDrvLogPath($drvPath);
return $logPath if defined $logPath;
}
return undef if scalar @outPaths == 0;
my @steps = $c->model('DB::BuildSteps')->search(
{ path => { -in => [@outPaths] } },
{ select => ["drvpath"]
, distinct => 1
, join => "buildstepoutputs"
});
foreach my $step (@steps) {
next unless defined $step->drvpath;
my $logPath = getDrvLogPath($step->drvpath);
return $logPath if defined $logPath;
}
return undef;
}
sub logContents {
my ($drvPath, $tail) = @_;
my $logPath = getDrvLogPath($drvPath);
die unless defined $logPath;
my ($logPath, $tail) = @_;
my $cmd;
if ($logPath =~ /.bz2$/) {
$cmd = "bzip2 -d < $logPath";
@ -381,7 +410,7 @@ sub getEvals {
}
sub getMachines {
my $machinesConf = $ENV{"NIX_REMOTE_SYSTEMS"} || "/etc/nix.machines";
my $machinesConf = $ENV{"NIX_REMOTE_SYSTEMS"} || "/etc/nix/machines";
# Read the list of machines.
my %machines = ();
@ -472,4 +501,102 @@ sub captureStdoutStderr {
}
sub run {
my (%args) = @_;
my $res = { stdout => "", stderr => "" };
my $stdin = "";
eval {
local $SIG{ALRM} = sub { die "timeout\n" }; # NB: \n required
alarm $args{timeout} if defined $args{timeout};
my @x = ($args{cmd}, \$stdin, \$res->{stdout});
push @x, \$res->{stderr} if $args{grabStderr} // 1;
IPC::Run::run(@x,
init => sub { chdir $args{dir} or die "changing to $args{dir}" if defined $args{dir}; });
alarm 0;
};
if ($@) {
die unless $@ eq "timeout\n"; # propagate unexpected errors
$res->{status} = -1;
$res->{stderr} = "timeout\n";
} else {
$res->{status} = $?;
chomp $res->{stdout} if $args{chomp} // 0;
}
return $res;
}
sub grab {
my (%args) = @_;
my $res = run(%args, grabStderr => 0);
die "command `@{$args{cmd}}' failed with exit status $res->{status}" if $res->{status};
return $res->{stdout};
}
sub getTotalShares {
my ($db) = @_;
return $db->resultset('Jobsets')->search(
{ 'project.enabled' => 1, 'me.enabled' => { '!=' => 0 } },
{ join => 'project', select => { sum => 'schedulingshares' }, as => 'sum' })->single->get_column('sum');
}
sub cancelBuilds($$) {
my ($db, $builds) = @_;
return txn_do($db, sub {
$builds = $builds->search({ finished => 0, busy => 0 });
my $n = $builds->count;
my $time = time();
$builds->update(
{ finished => 1,
, iscachedbuild => 0, buildstatus => 4 # = cancelled
, starttime => $time
, stoptime => $time
});
return $n;
});
}
sub restartBuilds($$) {
my ($db, $builds) = @_;
my $n = 0;
txn_do($db, sub {
my @paths;
$builds = $builds->search({ finished => 1 });
foreach my $build ($builds->all) {
next if !isValidPath($build->drvpath);
push @paths, $build->drvpath;
push @paths, $_->drvpath foreach $build->buildsteps;
registerRoot $build->drvpath;
$build->update(
{ finished => 0
, busy => 0
, locker => ""
, iscachedbuild => 0
});
$n++;
# Reset the stats for the evals to which this build belongs.
# !!! Should do this in a trigger.
$build->jobsetevals->update({nrsucceeded => undef});
}
# Clear Nix's negative failure cache.
# FIXME: Add this to the API.
system("nix-store", "--clear-failed-paths", @paths);
});
return $n;
}
1;

View file

@ -0,0 +1,22 @@
package Hydra::Helper::PluginHooks;
use strict;
use Exporter;
our @ISA = qw(Exporter);
our @EXPORT = qw(
notifyBuildFinished);
sub notifyBuildFinished {
my ($plugins, $build, $dependents) = @_;
foreach my $plugin (@{$plugins}) {
eval {
$plugin->buildFinished($build, $dependents);
};
if ($@) {
print STDERR "$plugin->buildFinished: $@\n";
}
}
}
1;

View file

@ -38,7 +38,7 @@ sub supportedInputTypes {
# Called to fetch an input of type $type. $value is the input
# location, typically the repository URL.
sub fetchInput {
my ($self, $type, $name, $value) = @_;
my ($self, $type, $name, $value, $project, $jobset) = @_;
return undef;
}

View file

@ -25,21 +25,8 @@ sub fetchInput {
my $stdout; my $stderr;
my $cacheDir = getSCMCacheDir . "/bzr";
mkpath($cacheDir);
my $clonePath = $cacheDir . "/" . sha256_hex($uri);
if (! -d $clonePath) {
(my $res, $stdout, $stderr) = captureStdoutStderr(600, "bzr", "branch", $uri, $clonePath);
die "error cloning bazaar branch at `$uri':\n$stderr" if $res;
}
chdir $clonePath or die $!;
(my $res, $stdout, $stderr) = captureStdoutStderr(600, "bzr", "pull");
die "error pulling latest change bazaar branch at `$uri':\n$stderr" if $res;
# First figure out the last-modified revision of the URI.
my @cmd = (["bzr", "revno"], "|", ["sed", 's/^ *\([0-9]*\).*/\1/']);
my @cmd = (["bzr", "revno", $uri], "|", ["sed", 's/^ *\([0-9]*\).*/\1/']);
IPC::Run::run(@cmd, \$stdout, \$stderr);
die "cannot get head revision of Bazaar branch at `$uri':\n$stderr" if $?;
@ -61,7 +48,7 @@ sub fetchInput {
$ENV{"NIX_PREFETCH_BZR_LEAVE_DOT_BZR"} = $type eq "bzr-checkout" ? "1" : "0";
(my $res, $stdout, $stderr) = captureStdoutStderr(600,
"nix-prefetch-bzr", $clonePath, $revision);
"nix-prefetch-bzr", $uri, $revision);
die "cannot check out Bazaar branch `$uri':\n$stderr" if $res;
($sha256, $storePath) = split ' ', $stdout;

View file

@ -0,0 +1,104 @@
package Hydra::Plugin::DarcsInput;
use strict;
use parent 'Hydra::Plugin';
use Digest::SHA qw(sha256_hex);
use File::Path;
use Hydra::Helper::Nix;
use Nix::Store;
sub supportedInputTypes {
my ($self, $inputTypes) = @_;
$inputTypes->{'darcs'} = 'Darcs checkout';
}
sub fetchInput {
my ($self, $type, $name, $uri) = @_;
return undef if $type ne "darcs";
my $timestamp = time;
my $sha256;
my $storePath;
my $revCount;
my $cacheDir = getSCMCacheDir . "/git";
mkpath($cacheDir);
my $clonePath = $cacheDir . "/" . sha256_hex($uri);
$uri =~ s|^file://||; # darcs wants paths, not file:// uris
my $stdout = ""; my $stderr = ""; my $res;
if (! -d $clonePath) {
# Clone the repository.
$res = run(timeout => 600,
cmd => ["darcs", "get", "--lazy", $uri, $clonePath],
dir => $ENV{"TMPDIR"});
die "Error getting darcs repo at `$uri':\n$stderr" if $res->{status};
}
# Update the repository to match $uri.
($res, $stdout, $stderr) = captureStdoutStderr(600,
("darcs", "pull", "-a", "--repodir", $clonePath, "$uri"));
die "Error fetching latest change from darcs repo at `$uri':\n$stderr" if $res;
($res, $stdout, $stderr) = captureStdoutStderr(600,
("darcs", "changes", "--last", "1", "--xml", "--repodir", $clonePath));
die "Error getting revision ID of darcs repo at `$uri':\n$stderr" if $res;
$stdout =~ /^<patch.*hash='([0-9a-fA-F-]+)'/sm; # sigh.
my $revision = $1;
die "Error obtaining revision from output: $stdout\nstderr = $stderr)" unless $revision =~ /^[0-9a-fA-F-]+$/;
die "Error getting a revision identifier at `$uri':\n$stderr" if $res;
# Some simple caching: don't check a uri/revision more than once.
my $cachedInput ;
($cachedInput) = $self->{db}->resultset('CachedDarcsInputs')->search(
{uri => $uri, revision => $revision},
{rows => 1});
if (defined $cachedInput && isValidPath($cachedInput->storepath)) {
$storePath = $cachedInput->storepath;
$sha256 = $cachedInput->sha256hash;
$revision = $cachedInput->revision;
$revCount = $cachedInput->revcount;
} else {
# Then download this revision into the store.
print STDERR "checking out darcs repo $uri\n";
my $tmpDir = File::Temp->newdir("hydra-darcs-export.XXXXXX", CLEANUP => 1, TMPDIR => 1) or die;
(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;
system "rm", "-rf", "$tmpDir/export/_darcs";
$storePath = addToStore("$tmpDir/export", 1, "sha256");
$sha256 = queryPathHash($storePath);
$sha256 =~ s/sha256://;
txn_do($self->{db}, sub {
$self->{db}->resultset('CachedDarcsInputs')->update_or_create(
{ uri => $uri
, revision => $revision
, revcount => $revCount
, sha256hash => $sha256
, storepath => $storePath
});
});
}
$revision =~ /^([0-9]+)/;
my $shortRev = $1;
return
{ uri => $uri
, storePath => $storePath
, sha256hash => $sha256
, revision => $revision
, revCount => int($revCount)
, shortRev => $shortRev
};
}
1;

View file

@ -28,6 +28,10 @@ The following dependent jobs also failed:
* [% showJobName(b) %] ([% baseurl %]/build/[% b.id %])
[% END -%]
[% END -%]
[% IF nrCommits > 0 -%]
This is likely due to [% IF nrCommits > 1 -%][% nrCommits %] commits by [% END -%][% authorList %].
[% END -%]
[% IF build.buildstatus == 0 -%]
Yay!
@ -66,7 +70,7 @@ sub buildFinished {
my $to = $b->jobset->emailoverride ne "" ? $b->jobset->emailoverride : $b->maintainers;
foreach my $address (split ",", $to) {
foreach my $address (split ",", ($to // "")) {
$address = trim $address;
$addresses{$address} //= { builds => [] };
@ -74,6 +78,14 @@ sub buildFinished {
}
}
my ($authors, $nrCommits, $emailable_authors) = getResponsibleAuthors($build, $self->{plugins});
my $authorList;
if (scalar keys %{$authors} > 0) {
my @x = map { "$_ <$authors->{$_}>" } (sort keys %{$authors});
$authorList = join(" or ", scalar @x > 1 ? join(", ", @x[0..scalar @x - 2]): (), $x[-1]);
$addresses{$_} = { builds => [ $build ] } foreach (@{$emailable_authors});
}
# Send an email to each interested address.
# !!! should use the Template Toolkit here.
@ -89,6 +101,8 @@ sub buildFinished {
, baseurl => $self->{config}->{'base_uri'} || "http://localhost:3000"
, showJobName => \&showJobName, showStatus => \&showStatus
, showSystem => index($build->job->name, $build->system) == -1
, nrCommits => $nrCommits
, authorList => $authorList
};
my $body;

View file

@ -20,39 +20,34 @@ sub _cloneRepo {
mkpath($cacheDir);
my $clonePath = $cacheDir . "/" . sha256_hex($uri);
my $stdout = ""; my $stderr = ""; my $res;
my $res;
if (! -d $clonePath) {
# Clone everything and fetch the branch.
# TODO: Optimize the first clone by using "git init $clonePath" and "git remote add origin $uri".
($res, $stdout, $stderr) = captureStdoutStderr(600, "git", "clone", "--branch", $branch, $uri, $clonePath);
die "error cloning git repo at `$uri':\n$stderr" if $res;
$res = run(cmd => ["git", "clone", "--branch", $branch, $uri, $clonePath], timeout => 600);
die "error cloning git repo at `$uri':\n$res->{stderr}" if $res->{status};
}
chdir $clonePath or die $!; # !!! urgh, shouldn't do a chdir
# This command forces the update of the local branch to be in the same as
# the remote branch for whatever the repository state is. This command mirrors
# only one branch of the remote repository.
($res, $stdout, $stderr) = captureStdoutStderr(600,
"git", "fetch", "-fu", "origin", "+$branch:$branch");
($res, $stdout, $stderr) = captureStdoutStderr(600,
"git", "fetch", "-fu", "origin") if $res;
die "error fetching latest change from git repo at `$uri':\n$stderr" if $res;
$res = run(cmd => ["git", "fetch", "-fu", "origin", "+$branch:$branch"], dir => $clonePath, timeout => 600);
$res = run(cmd => ["git", "fetch", "-fu", "origin"], dir => $clonePath, timeout => 600) 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
# to determine if this is a top-git branch.
if (defined $deepClone) {
# Checkout the branch to look at its content.
($res, $stdout, $stderr) = captureStdoutStderr(600, "git", "checkout", "$branch");
die "error checking out Git branch '$branch' at `$uri':\n$stderr" if $res;
$res = run(cmd => ["git", "checkout", "$branch"], dir => $clonePath);
die "error checking out Git branch '$branch' at `$uri':\n$res->{stderr}" if $res->{status};
if (-f ".topdeps") {
# This is a TopGit branch. Fetch all the topic branches so
# that builders can run "tg patch" and similar.
($res, $stdout, $stderr) = captureStdoutStderr(600,
"tg", "remote", "--populate", "origin");
print STDERR "warning: `tg remote --populate origin' failed:\n$stderr" if $res;
$res = run(cmd => ["tg", "remote", "--populate", "origin"], dir => $clonePath, timeout => 600);
print STDERR "warning: `tg remote --populate origin' failed:\n$res->{stderr}" if $res->{status};
}
}
@ -64,7 +59,6 @@ sub _parseValue {
(my $uri, my $branch, my $deepClone) = split ' ', $value;
$branch = defined $branch ? $branch : "master";
return ($uri, $branch, $deepClone);
}
sub fetchInput {
@ -80,19 +74,13 @@ sub fetchInput {
my $sha256;
my $storePath;
my ($res, $stdout, $stderr) = captureStdoutStderr(600,
("git", "rev-parse", "$branch"));
die "error getting revision number of Git branch '$branch' at `$uri':\n$stderr" if $res;
my ($revision) = split /\n/, $stdout;
die "error getting a well-formated revision number of Git branch '$branch' at `$uri':\n$stdout"
my $revision = grab(cmd => ["git", "rev-parse", "$branch"], dir => $clonePath, chomp => 1);
die "did not get a well-formated revision number of Git branch '$branch' at `$uri'"
unless $revision =~ /^[0-9a-fA-F]+$/;
my $ref = "refs/heads/$branch";
# Some simple caching: don't check a uri/branch/revision more than once.
# TODO: Fix case where the branch is reset to a previous commit.
my $cachedInput ;
my $cachedInput;
($cachedInput) = $self->{db}->resultset('CachedGitInputs')->search(
{uri => $uri, branch => $branch, revision => $revision},
{rows => 1});
@ -123,10 +111,7 @@ sub fetchInput {
$ENV{"NIX_PREFETCH_GIT_DEEP_CLONE"} = "1";
}
($res, $stdout, $stderr) = captureStdoutStderr(600, "nix-prefetch-git", $clonePath, $revision);
die "cannot check out Git repository branch '$branch' at `$uri':\n$stderr" if $res;
($sha256, $storePath) = split ' ', $stdout;
($sha256, $storePath) = split ' ', grab(cmd => ["nix-prefetch-git", $clonePath, $revision], chomp => 1);
txn_do($self->{db}, sub {
$self->{db}->resultset('CachedGitInputs')->update_or_create(
@ -143,12 +128,9 @@ sub fetchInput {
# number of commits in the history of this revision (revCount)
# the output of git-describe (gitTag), and the abbreviated
# revision (shortRev).
my $revCount = `git rev-list $revision | wc -l`; chomp $revCount;
die "git rev-list failed" if $? != 0;
my $gitTag = `git describe --always $revision`; chomp $gitTag;
die "git describe failed" if $? != 0;
my $shortRev = `git rev-parse --short $revision`; chomp $shortRev;
die "git rev-parse failed" if $? != 0;
my $revCount = scalar(split '\n', grab(cmd => ["git", "rev-list", "$revision"], dir => $clonePath));
my $gitTag = grab(cmd => ["git", "describe", "--always", "$revision"], dir => $clonePath, chomp => 1);
my $shortRev = grab(cmd => ["git", "rev-parse", "--short", "$revision"], dir => $clonePath, chomp => 1);
return
{ uri => $uri
@ -172,9 +154,7 @@ sub getCommits {
my $clonePath = $self->_cloneRepo($uri, $branch, $deepClone);
my $out;
IPC::Run::run(["git", "log", "--pretty=format:%H%x09%an%x09%ae%x09%at", "$rev1..$rev2"], \undef, \$out)
or die "cannot get git logs: $?";
my $out = grab(cmd => ["git", "log", "--pretty=format:%H%x09%an%x09%ae%x09%at", "$rev1..$rev2"], dir => $clonePath);
my $res = [];
foreach my $line (split /\n/, $out) {

View file

@ -9,7 +9,7 @@ sub buildFinished {
my ($self, $build, $dependents) = @_;
my $cfg = $self->{config}->{hipchat};
my @config = ref $cfg eq "ARRAY" ? @$cfg : ($cfg);
my @config = defined $cfg ? ref $cfg eq "ARRAY" ? @$cfg : ($cfg) : ();
my $baseurl = $self->{config}->{'base_uri'} || "http://localhost:3000";
@ -37,33 +37,7 @@ sub buildFinished {
return if scalar keys %rooms == 0;
# Determine who broke/fixed the build.
my $prevBuild = getPreviousBuild($build);
my $nrCommits = 0;
my %authors;
if ($prevBuild) {
foreach my $curInput ($build->buildinputs_builds) {
next unless $curInput->type eq "git";
my $prevInput = $prevBuild->buildinputs_builds->find({ name => $curInput->name });
next unless defined $prevInput;
next if $curInput->type ne $prevInput->type;
next if $curInput->uri ne $prevInput->uri;
my @commits;
foreach my $plugin (@{$self->{plugins}}) {
push @commits, @{$plugin->getCommits($curInput->type, $curInput->uri, $prevInput->revision, $curInput->revision)};
}
foreach my $commit (@commits) {
print STDERR "$commit->{revision} by $commit->{author}\n";
$authors{$commit->{author}} = $commit->{email};
$nrCommits++;
}
}
}
my ($authors, $nrCommits) = getResponsibleAuthors($build, $self->{plugins});
# Send a message to each room.
foreach my $roomId (keys %rooms) {
@ -83,16 +57,15 @@ sub buildFinished {
$msg .= " (and ${\scalar @deps} others)" if scalar @deps > 0;
$msg .= ": <a href='$baseurl/build/${\$build->id}'>" . showStatus($build) . "</a>";
if (scalar keys %authors > 0) {
if (scalar keys %{$authors} > 0) {
# FIXME: HTML escaping
my @x = map { "<a href='mailto:$authors{$_}'>$_</a>" } (sort keys %authors);
my @x = map { "<a href='mailto:$authors->{$_}'>$_</a>" } (sort keys %{$authors});
$msg .= ", likely due to ";
$msg .= "$nrCommits commits by " if $nrCommits > 1;
$msg .= join(" or ", scalar @x > 1 ? join(", ", @x[0..scalar @x - 2]) : (), $x[-1]);
}
print STDERR "sending hipchat notification to room $roomId: $msg\n";
next;
my $ua = LWP::UserAgent->new();
my $resp = $ua->post('https://api.hipchat.com/v1/rooms/message?format=json&auth_token=' . $room->{room}->{token}, {

View file

@ -12,21 +12,33 @@ sub supportedInputTypes {
$inputTypes->{'hg'} = 'Mercurial checkout';
}
sub _parseValue {
my ($value) = @_;
(my $uri, my $id) = split ' ', $value;
$id = defined $id ? $id : "default";
return ($uri, $id);
}
sub _clonePath {
my ($uri) = @_;
my $cacheDir = getSCMCacheDir . "/hg";
mkpath($cacheDir);
return $cacheDir . "/" . sha256_hex($uri);
}
sub fetchInput {
my ($self, $type, $name, $value) = @_;
return undef if $type ne "hg";
(my $uri, my $id) = split ' ', $value;
(my $uri, my $id) = _parseValue($value);
$id = defined $id ? $id : "default";
# init local hg clone
my $stdout = ""; my $stderr = "";
my $cacheDir = getSCMCacheDir . "/hg";
mkpath($cacheDir);
my $clonePath = $cacheDir . "/" . sha256_hex($uri);
my $clonePath = _clonePath($uri);
if (! -d $clonePath) {
(my $res, $stdout, $stderr) = captureStdoutStderr(600,
@ -85,4 +97,32 @@ sub fetchInput {
};
}
sub getCommits {
my ($self, $type, $value, $rev1, $rev2) = @_;
return [] if $type ne "hg";
return [] unless $rev1 =~ /^[0-9a-f]+$/;
return [] unless $rev2 =~ /^[0-9a-f]+$/;
my ($uri, $id) = _parseValue($value);
my $clonePath = _clonePath($uri);
chdir $clonePath or die $!;
my $out;
IPC::Run::run(["hg", "log", "--template", "{node|short}\t{author|person}\t{author|email}\n", "-r", "$rev1:$rev2", $clonePath], \undef, \$out)
or die "cannot get mercurial logs: $?";
my $res = [];
foreach my $line (split /\n/, $out) {
if ($line ne "") {
my ($revision, $author, $email) = split "\t", $line;
push @$res, { revision => $revision, author => $author, email => $email };
}
}
return $res;
}
1;

View file

@ -34,8 +34,13 @@ sub fetchInput {
} else {
print STDERR "copying input ", $name, " from $uri\n";
$storePath = `nix-store --add "$uri"`
or die "cannot copy path $uri to the Nix store.\n";
if ( $uri =~ /^\// ) {
$storePath = `nix-store --add "$uri"`
or die "cannot copy path $uri to the Nix store.\n";
} else {
$storePath = `PRINT_PATH=1 nix-prefetch-url "$uri" | tail -n 1`
or die "cannot fetch $uri to the Nix store.\n";
}
chomp $storePath;
$sha256 = (queryPathInfo($storePath, 0))[1] or die;

View file

@ -0,0 +1,149 @@
package Hydra::Plugin::S3Backup;
use strict;
use parent 'Hydra::Plugin';
use File::Temp;
use File::Basename;
use Fcntl;
use IO::File;
use Net::Amazon::S3;
use Net::Amazon::S3::Client;
use Digest::SHA;
use Nix::Config;
use Nix::Store;
use Hydra::Model::DB;
use Hydra::Helper::CatalystUtils;
my $client;
my %compressors = (
xz => "| $Nix::Config::xz",
bzip2 => "| $Nix::Config::bzip2",
none => ""
);
my $lockfile = Hydra::Model::DB::getHydraPath . "/.hydra-s3backup.lock";
sub buildFinished {
my ($self, $build, $dependents) = @_;
return unless $build->buildstatus == 0 or $build->buildstatus == 6;
my $jobName = showJobName $build;
my $job = $build->job;
my $cfg = $self->{config}->{s3backup};
my @config = defined $cfg ? ref $cfg eq "ARRAY" ? @$cfg : ($cfg) : ();
my @matching_configs = ();
foreach my $bucket_config (@config) {
push @matching_configs, $bucket_config if $jobName =~ /^$bucket_config->{jobs}$/;
}
return unless @matching_configs;
unless (defined $client) {
$client = Net::Amazon::S3::Client->new( s3 => Net::Amazon::S3->new( retry => 1 ) );
}
# !!! Maybe should do per-bucket locking?
my $lockhandle = IO::File->new;
open($lockhandle, "+>", $lockfile) or die "Opening $lockfile: $!";
flock($lockhandle, Fcntl::LOCK_SH) or die "Read-locking $lockfile: $!";
my @needed_paths = ();
foreach my $output ($build->buildoutputs) {
push @needed_paths, $output->path;
}
my %narinfos = ();
my %compression_types = ();
foreach my $bucket_config (@matching_configs) {
my $compression_type =
exists $bucket_config->{compression_type} ? $bucket_config->{compression_type} : "bzip2";
die "Unsupported compression type $compression_type" unless exists $compressors{$compression_type};
if (exists $compression_types{$compression_type}) {
push @{$compression_types{$compression_type}}, $bucket_config;
} else {
$compression_types{$compression_type} = [ $bucket_config ];
$narinfos{$compression_type} = [];
}
}
my $build_id = $build->id;
my $tempdir = File::Temp->newdir("s3-backup-nars-$build_id" . "XXXXX");
my %seen = ();
# Upload nars and build narinfos
while (@needed_paths) {
my $path = shift @needed_paths;
next if exists $seen{$path};
$seen{$path} = undef;
my $hash = substr basename($path), 0, 32;
my ($deriver, $narHash, $time, $narSize, $refs) = queryPathInfo($path, 0);
my $system;
if (defined $deriver and isValidPath($deriver)) {
$system = derivationFromPath($deriver)->{platform};
}
foreach my $reference (@{$refs}) {
push @needed_paths, $reference;
}
while (my ($compression_type, $configs) = each %compression_types) {
my @incomplete_buckets = ();
# Don't do any work if all the buckets have this path
foreach my $bucket_config (@{$configs}) {
my $bucket = $client->bucket( name => $bucket_config->{name} );
my $prefix = exists $bucket_config->{prefix} ? $bucket_config->{prefix} : "";
push @incomplete_buckets, $bucket_config
unless $bucket->object( key => $prefix . "$hash.narinfo" )->exists;
}
next unless @incomplete_buckets;
my $compressor = $compressors{$compression_type};
system("$Nix::Config::binDir/nix-store --export $path $compressor > $tempdir/nar") == 0 or die;
my $digest = Digest::SHA->new(256);
$digest->addfile("$tempdir/nar");
my $file_hash = $digest->hexdigest;
my @stats = stat "$tempdir/nar" or die "Couldn't stat $tempdir/nar";
my $file_size = $stats[7];
my $narinfo = "";
$narinfo .= "StorePath: $path\n";
$narinfo .= "URL: $hash.nar\n";
$narinfo .= "Compression: $compression_type\n";
$narinfo .= "FileHash: sha256:$file_hash\n";
$narinfo .= "FileSize: $file_size\n";
$narinfo .= "NarHash: $narHash\n";
$narinfo .= "NarSize: $narSize\n";
$narinfo .= "References: " . join(" ", map { basename $_ } @{$refs}) . "\n";
if (defined $deriver) {
$narinfo .= "Deriver: " . basename $deriver . "\n";
if (defined $system) {
$narinfo .= "System: $system\n";
}
}
push @{$narinfos{$compression_type}}, { hash => $hash, info => $narinfo };
foreach my $bucket_config (@incomplete_buckets) {
my $bucket = $client->bucket( name => $bucket_config->{name} );
my $prefix = exists $bucket_config->{prefix} ? $bucket_config->{prefix} : "";
my $nar_object = $bucket->object(
key => $prefix . "$hash.nar",
content_type => "application/x-nix-archive"
);
$nar_object->put_filename("$tempdir/nar");
}
}
}
# Upload narinfos
while (my ($compression_type, $infos) = each %narinfos) {
foreach my $bucket_config (@{$compression_types{$compression_type}}) {
foreach my $info (@{$infos}) {
my $bucket = $client->bucket( name => $bucket_config->{name} );
my $prefix = exists $bucket_config->{prefix} ? $bucket_config->{prefix} : "";
my $narinfo_object = $bucket->object(
key => $prefix . $info->{hash} . ".narinfo",
content_type => "text/x-nix-narinfo"
);
$narinfo_object->put($info->{info}) unless $narinfo_object->exists;
}
}
}
}
1;

View file

@ -0,0 +1,111 @@
use utf8;
package Hydra::Schema::AggregateConstituents;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
Hydra::Schema::AggregateConstituents
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 COMPONENTS LOADED
=over 4
=item * L<Hydra::Component::ToJSON>
=back
=cut
__PACKAGE__->load_components("+Hydra::Component::ToJSON");
=head1 TABLE: C<AggregateConstituents>
=cut
__PACKAGE__->table("AggregateConstituents");
=head1 ACCESSORS
=head2 aggregate
data_type: 'integer'
is_foreign_key: 1
is_nullable: 0
=head2 constituent
data_type: 'integer'
is_foreign_key: 1
is_nullable: 0
=cut
__PACKAGE__->add_columns(
"aggregate",
{ data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
"constituent",
{ data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
);
=head1 PRIMARY KEY
=over 4
=item * L</aggregate>
=item * L</constituent>
=back
=cut
__PACKAGE__->set_primary_key("aggregate", "constituent");
=head1 RELATIONS
=head2 aggregate
Type: belongs_to
Related object: L<Hydra::Schema::Builds>
=cut
__PACKAGE__->belongs_to(
"aggregate",
"Hydra::Schema::Builds",
{ id => "aggregate" },
{ is_deferrable => 0, on_delete => "CASCADE", on_update => "NO ACTION" },
);
=head2 constituent
Type: belongs_to
Related object: L<Hydra::Schema::Builds>
=cut
__PACKAGE__->belongs_to(
"constituent",
"Hydra::Schema::Builds",
{ id => "constituent" },
{ is_deferrable => 0, on_delete => "CASCADE", on_update => "NO ACTION" },
);
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-08-15 00:20:01
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:TLNenyPLIWw2gWsOVhplZw
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -72,6 +72,12 @@ __PACKAGE__->table("BuildInputs");
data_type: 'text'
is_nullable: 1
=head2 emailresponsible
data_type: 'integer'
default_value: 0
is_nullable: 0
=head2 dependency
data_type: 'integer'
@ -105,6 +111,8 @@ __PACKAGE__->add_columns(
{ data_type => "text", is_nullable => 1 },
"value",
{ data_type => "text", is_nullable => 1 },
"emailresponsible",
{ data_type => "integer", default_value => 0, is_nullable => 0 },
"dependency",
{ data_type => "integer", is_foreign_key => 1, is_nullable => 1 },
"path",
@ -168,7 +176,7 @@ __PACKAGE__->belongs_to(
);
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-06-13 01:54:50
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:tKZAybbNaRIMs9n5tHkqPw
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-10-08 13:08:15
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:OaJPzRM+8XGsu3eIkqeYEw
1;

View file

@ -97,6 +97,14 @@ __PACKAGE__->belongs_to(
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-06-13 01:54:50
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:V8MbzKvZNEaeHBJV67+ZMQ
my %hint = (
columns => [
'path'
],
);
sub json_hint {
return \%hint;
}
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -169,4 +169,21 @@ __PACKAGE__->has_many(
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-06-13 01:54:50
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:OZsXJniZ/7EB2iSz7p5y4A
my %hint = (
columns => [
"machine",
"system",
"stepnr",
"drvpath",
"starttime",
],
eager_relations => {
build => 'id'
}
);
sub json_hint {
return \%hint;
}
1;

View file

@ -288,6 +288,36 @@ __PACKAGE__->set_primary_key("id");
=head1 RELATIONS
=head2 aggregateconstituents_aggregates
Type: has_many
Related object: L<Hydra::Schema::AggregateConstituents>
=cut
__PACKAGE__->has_many(
"aggregateconstituents_aggregates",
"Hydra::Schema::AggregateConstituents",
{ "foreign.aggregate" => "self.id" },
undef,
);
=head2 aggregateconstituents_constituents
Type: has_many
Related object: L<Hydra::Schema::AggregateConstituents>
=cut
__PACKAGE__->has_many(
"aggregateconstituents_constituents",
"Hydra::Schema::AggregateConstituents",
{ "foreign.constituent" => "self.id" },
undef,
);
=head2 buildinputs_builds
Type: has_many
@ -468,9 +498,37 @@ __PACKAGE__->has_many(
undef,
);
=head2 aggregates
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-06-13 01:54:50
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:isCEXACY/PwkvgKHcXvAIg
Type: many_to_many
Composing rels: L</aggregateconstituents_constituents> -> aggregate
=cut
__PACKAGE__->many_to_many(
"aggregates",
"aggregateconstituents_constituents",
"aggregate",
);
=head2 constituents
Type: many_to_many
Composing rels: L</aggregateconstituents_constituents> -> constituent
=cut
__PACKAGE__->many_to_many(
"constituents",
"aggregateconstituents_constituents",
"constituent",
);
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-08-15 00:20:01
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:U1j/qm0vslb6Jvgu5mGMtw
__PACKAGE__->has_many(
"dependents",
@ -502,6 +560,8 @@ __PACKAGE__->has_many(
__PACKAGE__->many_to_many("jobsetevals", "jobsetevalmembers", "eval");
__PACKAGE__->many_to_many("constituents_", "aggregateconstituents_aggregates", "constituent");
sub makeSource {
my ($name, $query) = @_;
my $source = __PACKAGE__->result_source_instance();
@ -516,36 +576,6 @@ sub makeQueries {
my $activeJobs = "(select distinct project, jobset, job, system from Builds where isCurrent = 1 $constraint)";
makeSource(
"JobStatus$name",
# Urgh, can't use "*" in the "select" here because of the status change join.
<<QUERY
select x.*, b.id as statusChangeId, b.timestamp as statusChangeTime
from
(select
(select max(b.id) from Builds b
where
project = activeJobs.project and jobset = activeJobs.jobset
and job = activeJobs.job and system = activeJobs.system
and finished = 1
) as id
from $activeJobs as activeJobs
) as latest
join Builds x using (id)
left join Builds b on
b.id =
(select max(c.id) from Builds c
where
c.finished = 1 and
x.project = c.project and x.jobset = c.jobset and x.job = c.job and x.system = c.system and
x.id > c.id and
((x.buildStatus = 0 and c.buildStatus != 0) or
(x.buildStatus != 0 and c.buildStatus = 0)))
QUERY
);
makeSource("ActiveJobs$name", "select distinct project, jobset, job from Builds where isCurrent = 1 $constraint");
makeSource(
"LatestSucceeded$name",
<<QUERY
@ -571,4 +601,30 @@ makeQueries('ForJobset', "and project = ? and jobset = ?");
makeQueries('ForJob', "and project = ? and jobset = ? and job = ?");
my %hint = (
columns => [
'id',
'finished',
'timestamp',
'starttime',
'stoptime',
'project',
'jobset',
'job',
'nixname',
'system',
'priority',
'busy',
'buildstatus',
'releasename'
],
eager_relations => {
buildoutputs => 'name'
}
);
sub json_hint {
return \%hint;
}
1;

View file

@ -0,0 +1,98 @@
use utf8;
package Hydra::Schema::CachedDarcsInputs;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
Hydra::Schema::CachedDarcsInputs
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 COMPONENTS LOADED
=over 4
=item * L<Hydra::Component::ToJSON>
=back
=cut
__PACKAGE__->load_components("+Hydra::Component::ToJSON");
=head1 TABLE: C<CachedDarcsInputs>
=cut
__PACKAGE__->table("CachedDarcsInputs");
=head1 ACCESSORS
=head2 uri
data_type: 'text'
is_nullable: 0
=head2 revision
data_type: 'text'
is_nullable: 0
=head2 sha256hash
data_type: 'text'
is_nullable: 0
=head2 storepath
data_type: 'text'
is_nullable: 0
=head2 revcount
data_type: 'integer'
is_nullable: 0
=cut
__PACKAGE__->add_columns(
"uri",
{ data_type => "text", is_nullable => 0 },
"revision",
{ data_type => "text", is_nullable => 0 },
"sha256hash",
{ data_type => "text", is_nullable => 0 },
"storepath",
{ data_type => "text", is_nullable => 0 },
"revcount",
{ data_type => "integer", is_nullable => 0 },
);
=head1 PRIMARY KEY
=over 4
=item * L</uri>
=item * L</revision>
=back
=cut
__PACKAGE__->set_primary_key("uri", "revision");
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-09-20 11:08:50
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Yl1slt3SAizijgu0KUTn0A
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -137,8 +137,27 @@ __PACKAGE__->belongs_to(
{ is_deferrable => 0, on_delete => "CASCADE", on_update => "CASCADE" },
);
=head2 starredjobs
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-06-13 01:54:50
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:t2CCfUjFEz/lO4szROz1AQ
Type: has_many
Related object: L<Hydra::Schema::StarredJobs>
=cut
__PACKAGE__->has_many(
"starredjobs",
"Hydra::Schema::StarredJobs",
{
"foreign.job" => "self.name",
"foreign.jobset" => "self.jobset",
"foreign.project" => "self.project",
},
undef,
);
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-10-14 15:46:29
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:uYKWjewvKBEAuK53u7vKuw
1;

View file

@ -169,6 +169,16 @@ __PACKAGE__->belongs_to(
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-06-13 01:54:50
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:1Dp8B58leBLh4GK0GPw2zg
my %hint = (
columns => [
"revision",
"type",
"uri"
],
);
sub json_hint {
return \%hint;
}
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -199,4 +199,22 @@ __PACKAGE__->has_many(
__PACKAGE__->many_to_many(builds => 'buildIds', 'build');
my %hint = (
columns => [
"hasnewbuilds",
"id"
],
relations => {
"builds" => "id"
},
eager_relations => {
# altnr? Does anyone care?
jobsetevalinputs => "name"
}
);
sub json_hint {
return \%hint;
}
1;

View file

@ -57,6 +57,12 @@ __PACKAGE__->table("JobsetInputs");
data_type: 'text'
is_nullable: 0
=head2 emailresponsible
data_type: 'integer'
default_value: 0
is_nullable: 0
=cut
__PACKAGE__->add_columns(
@ -68,6 +74,8 @@ __PACKAGE__->add_columns(
{ data_type => "text", is_nullable => 0 },
"type",
{ data_type => "text", is_nullable => 0 },
"emailresponsible",
{ data_type => "integer", default_value => 0, is_nullable => 0 },
);
=head1 PRIMARY KEY
@ -142,7 +150,17 @@ __PACKAGE__->has_many(
);
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-06-13 01:54:50
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:UXBzqO0vHPql4LYyXpgEQg
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-10-08 13:06:15
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:+mZZqLjQNwblb/EWW1alLQ
my %hint = (
relations => {
"jobsetinputalts" => "value"
}
);
sub json_hint {
return \%hint;
}
1;

View file

@ -118,6 +118,17 @@ __PACKAGE__->table("Jobsets");
default_value: 300
is_nullable: 0
=head2 schedulingshares
data_type: 'integer'
default_value: 100
is_nullable: 0
=head2 fetcherrormsg
data_type: 'text'
is_nullable: 1
=cut
__PACKAGE__->add_columns(
@ -151,6 +162,10 @@ __PACKAGE__->add_columns(
{ data_type => "integer", default_value => 3, is_nullable => 0 },
"checkinterval",
{ data_type => "integer", default_value => 300, is_nullable => 0 },
"schedulingshares",
{ data_type => "integer", default_value => 100, is_nullable => 0 },
"fetcherrormsg",
{ data_type => "text", is_nullable => 1 },
);
=head1 PRIMARY KEY
@ -271,8 +286,42 @@ __PACKAGE__->belongs_to(
{ is_deferrable => 0, on_delete => "CASCADE", on_update => "CASCADE" },
);
=head2 starredjobs
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-06-13 01:54:50
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:tsGR8MhZRIUeNwpcVczMUw
Type: has_many
Related object: L<Hydra::Schema::StarredJobs>
=cut
__PACKAGE__->has_many(
"starredjobs",
"Hydra::Schema::StarredJobs",
{
"foreign.jobset" => "self.name",
"foreign.project" => "self.project",
},
undef,
);
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-10-14 15:46:29
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:DTAGxP5RFvcNxP/ciJGo4Q
my %hint = (
columns => [
"enabled",
"errormsg",
"fetcherrormsg",
"emailoverride"
],
eager_relations => {
jobsetinputs => "name"
}
);
sub json_hint {
return \%hint;
}
1;

View file

@ -0,0 +1,75 @@
use utf8;
package Hydra::Schema::NrBuilds;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
Hydra::Schema::NrBuilds
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 COMPONENTS LOADED
=over 4
=item * L<Hydra::Component::ToJSON>
=back
=cut
__PACKAGE__->load_components("+Hydra::Component::ToJSON");
=head1 TABLE: C<NrBuilds>
=cut
__PACKAGE__->table("NrBuilds");
=head1 ACCESSORS
=head2 what
data_type: 'text'
is_nullable: 0
=head2 count
data_type: 'integer'
is_nullable: 0
=cut
__PACKAGE__->add_columns(
"what",
{ data_type => "text", is_nullable => 0 },
"count",
{ data_type => "integer", is_nullable => 0 },
);
=head1 PRIMARY KEY
=over 4
=item * L</what>
=back
=cut
__PACKAGE__->set_primary_key("what");
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-08-12 17:59:18
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:CK8eJGC803nGj0wnete9xg
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -226,6 +226,21 @@ __PACKAGE__->has_many(
undef,
);
=head2 starredjobs
Type: has_many
Related object: L<Hydra::Schema::StarredJobs>
=cut
__PACKAGE__->has_many(
"starredjobs",
"Hydra::Schema::StarredJobs",
{ "foreign.project" => "self.name" },
undef,
);
=head2 viewjobs
Type: has_many
@ -267,15 +282,26 @@ Composing rels: L</projectmembers> -> username
__PACKAGE__->many_to_many("usernames", "projectmembers", "username");
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-06-13 01:54:50
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:RffghAo9jAaqYk41y1Sdqw
# These lines were loaded from '/home/rbvermaa/src/hydra/src/lib/Hydra/Schema/Projects.pm' found in @INC.
# They are now part of the custom portion of this file
# for you to hand-edit. If you do not either delete
# this section or remove that file from @INC, this section
# will be repeated redundantly when you re-create this
# file again via Loader! See skip_load_external to disable
# this feature.
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-10-14 15:46:29
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:PdNQ2mf5azBB6nI+iAm8fQ
my %hint = (
columns => [
"name",
"displayname",
"description",
"enabled",
"hidden",
"owner"
],
relations => {
releases => "name",
jobsets => "name"
}
);
sub json_hint {
return \%hint;
}
# You can replace this text with custom content, and it will be preserved on regeneration
1;

View file

@ -0,0 +1,161 @@
use utf8;
package Hydra::Schema::StarredJobs;
# Created by DBIx::Class::Schema::Loader
# DO NOT MODIFY THE FIRST PART OF THIS FILE
=head1 NAME
Hydra::Schema::StarredJobs
=cut
use strict;
use warnings;
use base 'DBIx::Class::Core';
=head1 COMPONENTS LOADED
=over 4
=item * L<Hydra::Component::ToJSON>
=back
=cut
__PACKAGE__->load_components("+Hydra::Component::ToJSON");
=head1 TABLE: C<StarredJobs>
=cut
__PACKAGE__->table("StarredJobs");
=head1 ACCESSORS
=head2 username
data_type: 'text'
is_foreign_key: 1
is_nullable: 0
=head2 project
data_type: 'text'
is_foreign_key: 1
is_nullable: 0
=head2 jobset
data_type: 'text'
is_foreign_key: 1
is_nullable: 0
=head2 job
data_type: 'text'
is_foreign_key: 1
is_nullable: 0
=cut
__PACKAGE__->add_columns(
"username",
{ data_type => "text", is_foreign_key => 1, is_nullable => 0 },
"project",
{ data_type => "text", is_foreign_key => 1, is_nullable => 0 },
"jobset",
{ data_type => "text", is_foreign_key => 1, is_nullable => 0 },
"job",
{ data_type => "text", is_foreign_key => 1, is_nullable => 0 },
);
=head1 PRIMARY KEY
=over 4
=item * L</username>
=item * L</project>
=item * L</jobset>
=item * L</job>
=back
=cut
__PACKAGE__->set_primary_key("username", "project", "jobset", "job");
=head1 RELATIONS
=head2 job
Type: belongs_to
Related object: L<Hydra::Schema::Jobs>
=cut
__PACKAGE__->belongs_to(
"job",
"Hydra::Schema::Jobs",
{ jobset => "jobset", name => "job", project => "project" },
{ is_deferrable => 0, on_delete => "CASCADE", on_update => "CASCADE" },
);
=head2 jobset
Type: belongs_to
Related object: L<Hydra::Schema::Jobsets>
=cut
__PACKAGE__->belongs_to(
"jobset",
"Hydra::Schema::Jobsets",
{ name => "jobset", project => "project" },
{ is_deferrable => 0, on_delete => "CASCADE", on_update => "CASCADE" },
);
=head2 project
Type: belongs_to
Related object: L<Hydra::Schema::Projects>
=cut
__PACKAGE__->belongs_to(
"project",
"Hydra::Schema::Projects",
{ name => "project" },
{ is_deferrable => 0, on_delete => "CASCADE", on_update => "CASCADE" },
);
=head2 username
Type: belongs_to
Related object: L<Hydra::Schema::Users>
=cut
__PACKAGE__->belongs_to(
"username",
"Hydra::Schema::Users",
{ username => "username" },
{ is_deferrable => 0, on_delete => "CASCADE", on_update => "CASCADE" },
);
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-10-14 15:46:29
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:naj5aKWuw8hLE6klmvW9Eg
# You can replace this text with custom code or comments, and it will be preserved on regeneration
1;

View file

@ -135,6 +135,21 @@ __PACKAGE__->has_many(
undef,
);
=head2 starredjobs
Type: has_many
Related object: L<Hydra::Schema::StarredJobs>
=cut
__PACKAGE__->has_many(
"starredjobs",
"Hydra::Schema::StarredJobs",
{ "foreign.username" => "self.username" },
undef,
);
=head2 userroles
Type: has_many
@ -161,14 +176,22 @@ Composing rels: L</projectmembers> -> project
__PACKAGE__->many_to_many("projects", "projectmembers", "project");
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-06-13 01:54:50
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:hy3MKvFxfL+1bTc7Hcb1zA
# These lines were loaded from '/home/rbvermaa/src/hydra/src/lib/Hydra/Schema/Users.pm' found in @INC.
# They are now part of the custom portion of this file
# for you to hand-edit. If you do not either delete
# this section or remove that file from @INC, this section
# will be repeated redundantly when you re-create this
# file again via Loader! See skip_load_external to disable
# this feature.
# Created by DBIx::Class::Schema::Loader v0.07033 @ 2013-10-14 15:46:29
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Hv9Ukqud0d3uIUot0ErKeg
my %hint = (
columns => [
"fullname",
"emailaddress",
"username"
],
relations => {
userroles => "role"
}
);
sub json_hint {
return \%hint;
}
1;

View file

@ -19,31 +19,83 @@ sub escape {
sub process {
my ($self, $c) = @_;
my $res = "[\n";
my %perSystem;
foreach my $pkg (@{$c->stash->{nixPkgs}}) {
my $build = $pkg->{build};
$res .= " # $pkg->{name}\n";
$res .= " { type = \"derivation\";\n";
$res .= " name = " . escape ($build->get_column("releasename") or $build->nixname) . ";\n";
$res .= " system = " . (escape $build->system) . ";\n";
$res .= " outPath = " . (escape $pkg->{outPath}) . ";\n";
$res .= " meta = {\n";
$res .= " description = " . (escape $build->description) . ";\n"
if $build->description;
$res .= " longDescription = " . (escape $build->longdescription) . ";\n"
if $build->longdescription;
$res .= " license = " . (escape $build->license) . ";\n"
if $build->license;
$res .= " };\n";
$res .= " }\n";
$perSystem{$build->system}->{$build->get_column('job')} = $pkg;
}
$res .= "]\n";
my $res = <<EOF;
{ system ? builtins.currentSystem }:
let
mkFakeDerivation = attrs: outputs:
let
outputNames = builtins.attrNames outputs;
common = attrs // outputsSet //
{ type = "derivation";
outputs = outputNames;
all = outputsList;
};
outputToAttrListElement = outputName:
{ name = outputName;
value = common // {
inherit outputName;
outPath = builtins.getAttr outputName outputs;
};
};
outputsList = map outputToAttrListElement outputNames;
outputsSet = builtins.listToAttrs outputsList;
in outputsSet;
in
EOF
my $first = 1;
foreach my $system (keys %perSystem) {
$res .= "else " if !$first;
$res .= "if system == ${\escape $system} then {\n\n";
foreach my $job (keys $perSystem{$system}) {
my $pkg = $perSystem{$system}->{$job};
my $build = $pkg->{build};
$res .= " # Hydra build ${\$build->id}\n";
my $attr = $build->get_column('job');
$attr =~ s/\./-/g;
$res .= " ${\escape $attr} = (mkFakeDerivation {\n";
$res .= " type = \"derivation\";\n";
$res .= " name = ${\escape ($build->get_column('releasename') or $build->nixname)};\n";
$res .= " system = ${\escape $build->system};\n";
$res .= " meta = {\n";
$res .= " description = ${\escape $build->description};\n"
if $build->description;
$res .= " longDescription = ${\escape $build->longdescription};\n"
if $build->longdescription;
$res .= " license = ${\escape $build->license};\n"
if $build->license;
$res .= " maintainers = ${\escape $build->maintainers};\n"
if $build->maintainers;
$res .= " };\n";
$res .= " } {\n";
my @outputNames = sort (keys $pkg->{outputs});
$res .= " ${\escape $_} = ${\escape $pkg->{outputs}->{$_}};\n" foreach @outputNames;
my $out = defined $pkg->{outputs}->{"out"} ? "out" : $outputNames[0];
$res .= " }).$out;\n\n";
}
$res .= "}\n\n";
$first = 0;
}
$res .= "else " if !$first;
$res .= "{}\n";
my $tar = Archive::Tar->new;
$tar->add_data("channel/channel-name", ($c->stash->{channelName} or "unnamed-channel"), {mtime => 0});
$tar->add_data("channel/default.nix", $res, {mtime => 0});
$tar->add_data("channel/channel-name", ($c->stash->{channelName} or "unnamed-channel"), {mtime => 1});
$tar->add_data("channel/default.nix", $res, {mtime => 1});
my $tardata = $tar->write;
my $bzip2data;

View file

@ -8,7 +8,7 @@ sub process {
my ($self, $c) = @_;
$c->response->content_encoding("utf-8");
$c->response->content_type('text/plain') unless $c->response->content_type() ne "";
$self->SUPER::process($c);
$c->response->body($c->stash->{plain}->{data});
}
1;

View file

@ -8,17 +8,18 @@ __PACKAGE__->config(
TEMPLATE_EXTENSION => '.tt',
PRE_CHOMP => 1,
POST_CHOMP => 1,
expose_methods => [qw/log_exists ellipsize/]);
expose_methods => [qw/buildLogExists buildStepLogExists/]);
sub log_exists {
my ($self, $c, $drvPath) = @_;
my $x = getDrvLogPath($drvPath);
return defined $x;
sub buildLogExists {
my ($self, $c, $build) = @_;
my @outPaths = map { $_->path } $build->buildoutputs->all;
return defined findLog($c, $build->drvpath, @outPaths);
}
sub ellipsize {
my ($self, $c, $s, $n) = @_;
return length $s <= $n ? $s : substr($s, 0, $n - 3) . "...";
sub buildStepLogExists {
my ($self, $c, $step) = @_;
my @outPaths = map { $_->path } $step->buildstepoutputs->all;
return defined findLog($c, $step->drvpath, @outPaths);
}
1;

View file

@ -7,6 +7,7 @@
[% project = build.project %]
[% jobset = build.jobset %]
[% job = build.job %]
[% isAggregate = constituents.size > 0 %]
[% BLOCK renderOutputs %]
[% start=1; FOREACH output IN outputs %]
@ -22,7 +23,7 @@
<tbody>
[% FOREACH step IN build.buildsteps %]
[% IF ( type == "All" ) || ( type == "Failed" && step.status != 0 ) || ( type == "Running" && step.busy == 1 ) %]
[% has_log = log_exists(step.drvpath);
[% has_log = buildStepLogExists(step);
log = c.uri_for('/build' build.id 'nixlog' step.stepnr); %]
<tr>
<td>[% step.stepnr %]</td>
@ -67,7 +68,40 @@
[% END %]
<ul class="nav nav-tabs">
<li class="dropdown">
<a class="dropdown-toggle actions" data-toggle="dropdown" href="#">
Actions
<b class="caret"></b>
</a>
<ul class="dropdown-menu">
[% IF build.nixexprinput %]
<li><a href="#reproduce" data-toggle="modal">Reproduce locally</a></li>
[% END %]
[% IF c.user_exists %]
[% IF available %]
[% IF build.keep %]
<li><a href="[% c.uri_for('/build' build.id 'keep' 0) %]">Unkeep</a></li>
[% ELSE %]
<li><a href="[% c.uri_for('/build' build.id 'keep' 1) %]">Keep</a></li>
[% END %]
[% END %]
[% IF build.finished %]
<li><a href="[% c.uri_for('/build' build.id 'restart') %]">Restart</a></li>
[% ELSE %]
<li><a href="[% c.uri_for('/build' build.id 'cancel') %]">Cancel</a></li>
[% END %]
[% IF available && project.releases %]
[% INCLUDE menuItem
uri = "#add-to-release"
title = "Add to release"
modal = 1 %]
[% END %]
[% END %]
</ul>
</li>
<li class="active"><a href="#tabs-summary" data-toggle="tab">Summary</a></li>
[% IF isAggregate %]<li><a href="#tabs-constituents" data-toggle="tab">Constituents</a></li>[% END %]
<li><a href="#tabs-details" data-toggle="tab">Details</a></li>
<li><a href="#tabs-buildinputs" data-toggle="tab">Inputs</a></li>
[% IF build.buildsteps %]<li><a href="#tabs-buildsteps" data-toggle="tab">Build steps</a></li>[% END %]
@ -81,26 +115,6 @@
<div id="tabs-summary" class="tab-pane active">
[% IF build.nixexprinput %]
[% WRAPPER makePopover title="Reproduce locally" classes="btn-info pull-right" placement="left" %]
[% url = c.uri_for('/build' build.id 'reproduce') %]
<p>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 perform the build. This script requires that you have Nix
on your system.</p>
<p>To download and execute the script from the command line,
run the following command:</p>
<pre>
<span class="shell-prompt">$ </span>bash <(curl <a [% HTML.attributes(href => url) %]>[% HTML.escape(url) %]</a>)
</pre>
[% END %]
[% END %]
<table>
<tr>
<td>
@ -114,7 +128,28 @@
</tr>
<tr>
<th>Status:</th>
<td>[% INCLUDE renderStatus build=build icon=0 %]</td>
<td>
[% INCLUDE renderStatus build=build icon=0 %]
[% IF isAggregate;
nrConstituents = 0;
nrFinished = 0;
nrFailedConstituents = 0;
FOREACH b IN constituents;
nrConstituents = nrConstituents + 1;
IF b.finished; nrFinished = nrFinished + 1; END;
IF b.finished && b.buildstatus != 0; nrFailedConstituents = nrFailedConstituents + 1; END;
END;
%];
[%+ IF nrFinished == nrMembers && nrFailedConstituents == 0 %]
all [% nrConstituents %] constituent builds succeeded
[% ELSE %]
[% nrFailedConstituents %] out of [% nrConstituents %] constituent builds failed
[% IF nrFinished < nrConstituents %]
([% nrConstituents - nrFinished %] still pending)
[% END %]
[% END %]
[% END %]
</td>
</tr>
<tr>
<th>System:</th>
@ -146,7 +181,7 @@
<td>[% IF cachedBuild; INCLUDE renderFullBuildLink build=cachedBuild; ELSE %]<em>unknown</em>[% END %]</td>
</tr>
[% END %]
[% IF build.finished %]
[% IF !isAggregate && build.finished %]
<tr>
<th>Duration:</th>
<td>[% actualBuild = build.iscachedbuild ? cachedBuild : build;
@ -154,7 +189,7 @@
finished at [% INCLUDE renderDateTime timestamp = actualBuild.stoptime %]</td>
</tr>
[% END %]
[% IF log_exists(build.drvpath) %]
[% IF !isAggregate && buildLogExists(build) %]
<tr>
<th>Logfile:</th>
<td>
@ -169,20 +204,7 @@
</tr>
</table>
[% IF c.user_exists && available %]
<br/>
<form class="form-horizontal" action="[% c.uri_for('/build' build.id 'add-to-release') %]" method="post">
<div class="control-group">
<label class="control-label">Add to release</label>
<div class="controls">
<input type="text" class="input" name="name"></input>
<button type="submit" class="btn btn-success">Apply</button>
</div>
</div>
</form>
[% END %]
[% IF build.buildproducts %]
[% IF build.buildproducts && !isAggregate %]
<h3>Build products</h3>
@ -251,6 +273,18 @@
</div>
[% IF isAggregate %]
<div id="tabs-constituents" class="tab-pane">
<p>This build is an aggregate of the following builds:</p>
[% INCLUDE renderBuildList builds=constituents hideProjectName=1 hideJobsetName=1 %]
</div>
[% END %]
<div id="tabs-details" class="tab-pane">
<table class="info-table">
@ -380,8 +414,8 @@
<div id="placeholder" style="width:800px;height:400px;"></div>
<div id="overview" style="margin-left:50px;margin-top:20px;width:600px;height:50px"></div>
<script src="/static/js/flot/jquery.flot.js" type="text/javascript"></script>
<script src="/static/js/flot/jquery.flot.selection.js" type="text/javascript"></script>
<script src="[% c.uri_for("/static/js/flot/jquery.flot.js") %]" type="text/javascript"></script>
<script src="[% c.uri_for("/static/js/flot/jquery.flot.selection.js") %]" type="text/javascript"></script>
<script type="text/javascript">
$(function() {
var d = [];
@ -524,4 +558,58 @@
</div>
[% IF c.user_exists && available && project.releases %]
<div id="add-to-release" class="modal hide fade" tabindex="-1" role="dialog" aria-hidden="true">
<form class="form-horizontal" action="[% c.uri_for('/build' build.id 'add-to-release') %]" method="post">
<div class="modal-body">
<div class="control-group">
<label class="control-label">Add to release</label>
<div class="controls">
<select class="span2" name="name">
[% FOREACH r IN project.releases %]
<option>[% HTML.escape(r.name) %]</option>
[% END %]
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Add</button>
<button class="btn" data-dismiss="modal" aria-hidden="true">Cancel</button>
</div>
</form>
</div>
[% END %]
<div id="reproduce" class="modal hide fade" tabindex="-1" role="dialog" aria-hidden="true">
[% url = c.uri_for('/build' build.id 'reproduce') %]
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h3>Reproduce this build</h3>
</div>
<div class="modal-body">
<p>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 perform the build.
This script requires that you have Nix on your system.</p>
<p>To download and execute the script from the command line, run the
following command:</p>
<pre>
<span class="shell-prompt">$ </span>bash <(curl <a [% HTML.attributes(href => url) %]>[% HTML.escape(url) %]</a>)
</pre>
</div>
<div class="modal-footer">
<a href="#" class="btn btn-primary" data-dismiss="modal">Close</a>
</div>
</div>
[% END %]

View file

@ -60,7 +60,6 @@ install the package simply by clicking on the packages below.</p>
[% ELSE %]
[% HTML.escape(b.description) %]
[% END %]
[% IF pkg.outName != 'out' %] [[% pkg.outName %]][% END %]
</td>
</tr>

View file

@ -1,51 +0,0 @@
[% WRAPPER layout.tt title="Clone build ${build.id}" %]
[% PROCESS common.tt %]
[% USE HTML %]
[% edit=1 %]
<p>Cloning allows you to perform a build with modified inputs.</p>
<form action="[% c.uri_for('/build' build.id 'clone' 'submit') %]" method="post">
<h2>Nix expression</h2>
<p>Evaluate job <tt><input type="text" class="string"
name="jobname" [% HTML.attributes(value => build.job.name) %]
/></tt> in Nix expression <tt><input type="text" class="string"
name="nixexprpath" [% HTML.attributes(value => build.nixexprpath) %]
/></tt> in input <tt><input type="text" class="string"
name="nixexprinput" [% HTML.attributes(value => build.nixexprinput)
%] /></tt>.</p>
<h2>Build inputs</h2>
<table class="table table-condensed table-striped">
<thead>
<tr><th>Name</th><th>Type</th><th>Value</th></tr>
</thead>
<tbody>
[% FOREACH input IN build.inputs %]
<tr>
<td><tt>[% input.name %]<input type="hidden" [% HTML.attributes(name => "input-$input.name-name" value => input.name) %] /></tt></td>
<td>
[% INCLUDE renderSelection curValue=input.type param="input-$input.name-type" options=inputTypes %]
</td>
<td>
<tt><input type="text" class="string" name="input-[% input.name %]-value"
[% IF input.type == "build" || input.type == "sysbuild" %]
[% build = input.dependency %]
[% HTML.attributes(value => build.project.name _ ':' _ build.jobset.name _ ':' _ build.job.name _ '[id="'_ build.id _ '"]' ) %]
[% ELSE %]
[% HTML.attributes(value => input.value || input.uri) %]
[% END %] /></tt>
</td>
</tr>
[% END %]
</tbody>
</table>
<p><button type="submit"><img alt="Add" src="/static/images/success.gif" />Add to queue</button></p>
</form>
[% END %]

View file

@ -1,24 +0,0 @@
[% WRAPPER layout.tt title="Clone jobset $jobset.project.name:$jobset.name" %]
[% PROCESS common.tt %]
[% USE HTML %]
[% edit=1 %]
<form class="form-horizontal" action="[% c.uri_for('/jobset' jobset.project.name jobset.name 'clone' 'submit') %]" method="post">
<fieldset>
<div class="control-group">
<label class="control-label">New name</label>
<div class="controls">
<input type="text" class="span3" name="newjobset" value=""></input>
</div>
</div>
<div class="form-actions">
<input type="submit" value="Submit" class="btn btn-primary" />
</div>
</fieldset>
</form>
[% END %]

View file

@ -20,7 +20,7 @@ BLOCK renderJobsetName %]
BLOCK renderJobName %]
<a [% IF inRow %]class="row-link"[% END %] href="[% c.uri_for('/job' project jobset job) %]"><tt>[% job %]</tt></a>
<a [% IF inRow %]class="row-link"[% END %] href="[% c.uri_for('/job' project jobset job) %]">[% job %]</a>
[% END;
@ -40,9 +40,9 @@ END;
BLOCK renderDuration;
IF duration >= 24 * 60 * 60; duration div (24 * 60 * 60) %]d [% END;
IF duration >= 60 * 60; duration div (60 * 60) % 24 %]h [% END;
IF duration >= 60; duration div 60 % 60 %]m [% END;
IF duration >= 24 * 60 * 60; duration div (24 * 60 * 60) %]d&nbsp;[% END;
IF duration >= 60 * 60; duration div (60 * 60) % 24 %]h&nbsp;[% END;
IF duration >= 60; duration div 60 % 60 %]m&nbsp;[% END;
duration % 60 %]s[%
END;
@ -64,12 +64,9 @@ BLOCK renderBuildListHeader %]
[% IF !hideJobName %]
<th>Job</th>
[% END %]
<th>Release Name</th>
<th>Release name</th>
<th>System</th>
<th>[% IF showSchedulingInfo %]Queued at[% ELSE %]Finished at[% END %]</th>
[% IF showStatusChange %]
<th class="headerSortUp">Last status change</th>
[% END %]
[% IF showDescription %]
<th>Description</th>
[% END %]
@ -99,25 +96,14 @@ BLOCK renderBuildListBody;
[% END %]
<td>[% !showSchedulingInfo and build.get_column('releasename') ? build.get_column('releasename') : build.nixname %]</td>
<td class="nowrap"><tt>[% build.system %]</tt></td>
<td class="nowrap">[% date.format(showSchedulingInfo ? build.timestamp : build.stoptime, '%Y-%m-%d %H:%M:%S') %]</td>
[% IF showStatusChange %]
<td>
[% IF build.get_column('statusChangeTime') %]
<a href="[% c.uri_for('/build' build.get_column('statusChangeId')) %]">
[% date.format(build.get_column('statusChangeTime'), '%Y-%m-%d %H:%M:%S') %]
</a>
[% ELSE %]
<em>never</em>
[% END %]
</td>
[% END %]
<td class="nowrap">[% t = showSchedulingInfo ? build.timestamp : build.stoptime; IF t; date.format(showSchedulingInfo ? build.timestamp : build.stoptime, '%Y-%m-%d %H:%M:%S'); ELSE; "-"; END %]</td>
[% IF showDescription %]
<td>[% build.description %]</td>
[% END %]
</tr>
[% END;
IF linkToAll %]
<td class="centered" colspan="5"><a href="[% linkToAll %]"><em>More...</em></a></td></tr>
<tr><td class="centered" colspan="5"><a href="[% linkToAll %]"><em>More...</em></a></td></tr>
[% END;
END;
@ -144,7 +130,7 @@ 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 %]>[% content %]</a>[% ELSE; content; END;
END;
@ -164,7 +150,7 @@ BLOCK renderSelection;
[% ELSE %]
<select [% HTML.attributes(id => param, name => param) %]>
[% FOREACH name IN options.keys.sort %]
<option [% HTML.attributes(value => name) %] [% IF name == curValue; "selected='selected'"; END %]>[% options.$name %]</option>
<option [% IF name == curValue; "selected='selected'"; END; " "; HTML.attributes(value => name) %]>[% options.$name %]</option>
[% END %]
</select>
[% END;
@ -195,24 +181,24 @@ BLOCK renderBuildStatusIcon;
buildstatus = buildstatus != undef ? buildstatus : build.buildstatus;
IF finished;
IF buildstatus == 0 %]
<img src="/static/images/checkmark_[% size %].png" alt="Succeeded" />
<img src="[% c.uri_for("/static/images/checkmark_${size}.png") %]" alt="Succeeded" />
[% ELSIF buildstatus == 1 %]
<img src="/static/images/error_[% size %].png" alt="Failed" />
[% ELSIF buildstatus == 2 %]
<img src="/static/images/dependency_[% size %].png" alt="Dependency failed" />
<img src="[% c.uri_for("/static/images/error_${size}.png") %]" alt="Failed" />
[% ELSIF buildstatus == 2 || buildstatus == 5 %]
<img src="[% c.uri_for("/static/images/dependency_${size}.png") %]" alt="Dependency failed" />
[% ELSIF buildstatus == 3 %]
<img src="[% c.uri_for("/static/images/warning_${size}.png") %]" alt="Aborted" />
[% ELSIF buildstatus == 4 %]
<img src="/static/images/cancelled_[% size %].png" alt="Cancelled" />
[% ELSIF buildstatus == 5 %]
<img src="/static/images/error_[% size %].png" alt="Failed" />
<img src="[% c.uri_for("/static/images/forbidden_${size}.png") %]" alt="Cancelled" />
[% ELSIF buildstatus == 6 %]
<img src="/static/images/error_[% size %].png" alt="Failed (with result)" />
<img src="[% c.uri_for("/static/images/error_${size}.png") %]" alt="Failed (with result)" />
[% ELSE %]
<img src="/static/images/error_[% size %].png" alt="Failed" />
<img src="[% c.uri_for("/static/images/error_${size}.png") %]" alt="Failed" />
[% END;
ELSIF busy %]
<img src="/static/images/help_[% size %].png" alt="Busy" />
<img src="[% c.uri_for("/static/images/help_${size}.png") %]" alt="Busy" />
[% ELSE %]
<img src="/static/images/help_[% size %].png" alt="Scheduled" />
<img src="[% c.uri_for("/static/images/help_${size}.png") %]" alt="Scheduled" />
[% END;
END;
@ -225,17 +211,15 @@ BLOCK renderStatus;
<strong>Success</strong>
[% ELSIF buildstatus == 1 %]
<span class="error">Build returned a non-zero exit code</span>
[% ELSIF buildstatus == 2 %]
[% ELSIF buildstatus == 2 || buildstatus == 5 %]
<span class="error">A dependency of the build failed</span>
[% ELSIF buildstatus == 4 %]
<span class="error">Cancelled by user</span>
[% ELSIF buildstatus == 5 %]
<span class="error">Build inhibited because a dependency previously failed to build</span>
[% ELSIF buildstatus == 6 %]
<span class="error">Build failed (with result)</span>
[% ELSE %]
<span class="error">Build failed</span>
(see <a href="#nix-error">below</a>)
<span class="error">Aborted</span>
(Hydra failure; see <a href="#nix-error">below</a>)
[% END;
ELSIF build.busy %]
<strong>Build in progress</strong>
@ -246,24 +230,15 @@ BLOCK renderStatus;
END;
BLOCK renderInputValue;
IF input.type == "build" || input.type == "sysbuild";
INCLUDE renderFullBuildLink build=input.dependency;
ELSIF input.type == "string" || input.type == "boolean" %]
<tt>"[% input.value %]"</tt>
[% ELSE %]
<tt>[% input.uri %][% IF input.revision %] (r[% input.revision %])[% END %]</tt>
[% END;
END;
BLOCK renderShortInputValue;
IF input.type == "build" || input.type == "sysbuild" %]
<a href="[% c.uri_for('/build' input.dependency.id) %]">[% input.dependency.id %]</a>
[% ELSIF input.type == "string" || input.type == "boolean" %]
<tt>"[% input.value %]"</tt>
[% ELSIF input.type == "string" %]
<tt>"[% HTML.escape(input.value) %]"</tt>
[% ELSIF input.type == "nix" || input.type == "boolean" %]
<tt>[% HTML.escape(input.value) %]</tt>
[% ELSE %]
<tt>[% input.uri %][% IF input.revision %] (r[% input.revision %])[% END %]</tt>
<tt>[% HTML.escape(input.uri) %][% IF input.revision %] (r[% HTML.escape(input.revision) %])[% END %]</tt>
[% END %]
[% END;
@ -275,7 +250,7 @@ BLOCK renderDiffUri;
url = bi1.uri;
path = url.replace(base, '');
IF url.match(base) %]
<a target="_new" href="[% m.uri.replace('_path_', path).replace('_1_', bi1.revision).replace('_2_', bi2.revision) %]">[% contents %]</a>
<a target="_blank" href="[% m.uri.replace('_path_', path).replace('_1_', bi1.revision).replace('_2_', bi2.revision) %]">[% contents %]</a>
[% nouri = 0;
END;
END;
@ -284,7 +259,7 @@ BLOCK renderDiffUri;
url = res.0;
branch = res.1;
IF bi1.type == "hg" || bi1.type == "git" %]
<a target="_new" href="[% HTML.escape("/api/scmdiff?uri=$url&rev1=$bi1.revision&rev2=$bi2.revision&type=$bi1.type&branch=$branch") %]">[% contents %]</a>
<a target="_blank" href="[% HTML.escape("/api/scmdiff?uri=$url&rev1=$bi1.revision&rev2=$bi2.revision&type=$bi1.type&branch=$branch") %]">[% contents %]</a>
[% ELSE;
contents;
END;
@ -305,13 +280,15 @@ BLOCK renderInputs; %]
<td>
[% IF input.type == "build" || input.type == "sysbuild" %]
[% INCLUDE renderFullBuildLink build=input.dependency %]
[% ELSIF input.type == "string" || input.type == "boolean" %]
<tt>"[% input.value %]"</tt>
[% ELSIF input.type == "string" %]
<tt>"[% HTML.escape(input.value) %]"</tt>
[% ELSIF input.type == "nix" || input.type == "boolean" %]
<tt>[% HTML.escape(input.value) %]</tt>
[% ELSE %]
<tt>[% input.uri %]</tt>
<tt>[% HTML.escape(input.uri) %]</tt>
[% END %]
</td>
<td>[% IF input.revision %][% input.revision %][% END %]</td>
<td>[% IF input.revision %][% HTML.escape(input.revision) %][% END %]</td>
<td><tt>[% input.path %]</tt></td>
</tr>
[% END %]
@ -372,10 +349,10 @@ BLOCK renderInputDiff; %]
BLOCK renderPager %]
<ul class="pager">
<li [% IF page == 1 %]class="disabled"[% END %]><a href="[% "$baseUri?page=1" %]">&laquo; First</a></li>
<li [% IF page == 1 %]class="disabled"[% END %]><a href="[% "$baseUri?page="; (page - 1) %]">&lsaquo; Previous</a></li>
<li [% IF page * resultsPerPage >= total %]class="disabled"[% END %]><a href="[% "$baseUri?page="; (page + 1) %]">Next &rsaquo;</a></li>
<li [% IF page * resultsPerPage >= total %]class="disabled"[% END %]><a href="[% "$baseUri?page="; (total - 1) div resultsPerPage + 1 %]">Last &raquo;</a></li>
<li [% IF page == 1 %]class="disabled"[% END %]><a href="[% "$baseUri?page=1" %]">« First</a></li>
<li [% IF page == 1 %]class="disabled"[% END %]><a href="[% "$baseUri?page="; (page - 1) %]"> Previous</a></li>
<li [% IF page * resultsPerPage >= total %]class="disabled"[% END %]><a href="[% "$baseUri?page="; (page + 1) %]">Next </a></li>
<li [% IF page * resultsPerPage >= total %]class="disabled"[% END %]><a href="[% "$baseUri?page="; (total - 1) div resultsPerPage + 1 %]">Last »</a></li>
</ul>
[% END;
@ -458,22 +435,10 @@ BLOCK renderLogLinks %]
BLOCK makeLazyTab %]
<div id="[% tabName %]" class="tab-pane">
<center><img src="/static/images/ajax-loader.gif" alt="Loading..." /></center>
<center><img src="[% c.uri_for("/static/images/ajax-loader.gif") %]" alt="Loading..." /></center>
</div>
<script>
$(function() {
$('.nav-tabs').bind('show', function(e) {
var pattern = /#.+/gi;
var id = e.target.toString().match(pattern)[0];
if (id == "#[% tabName %]") {
$('#[% tabName %]').load("[% uri %]", function(response, status, xhr) {
if (status == "error") {
$('#[% tabName %]').html("<div class='alert alert-error'>Error loading tab: " + xhr.status + " " + xhr.statusText + "</div>");
}
});
}
});
});
$(function() { makeLazyTab("[% tabName %]", "[% uri %]"); });
</script>
[% END;
@ -485,4 +450,18 @@ BLOCK makePopover %]
[% END;
BLOCK menuItem %]
<li class="[% IF "${root}${curUri}" == uri %]active[% END %]" [% IF confirmmsg %]onclick="javascript:return confirm('[% confirmmsg %]')"[% END %]>
<a [% HTML.attributes(href => uri) %] [%+ IF modal %]data-toggle="modal"[% END %]>
[% IF icon %]<i class="[% icon %] icon-black"></i> [%+ END %]
[% title %]
</a>
</li>
[% END;
BLOCK makeStar %]
<span class="star" data-post="[% starUri %]">[% IF starred; "★"; ELSE; "☆"; END %]</span>
[% END;
%]

42
src/root/dashboard.tt Normal file
View file

@ -0,0 +1,42 @@
[% WRAPPER layout.tt title="Dashboard" %]
[% PROCESS common.tt %]
<ul class="nav nav-tabs">
<li class="active"><a href="#tabs-starred-jobs" data-toggle="tab">Starred jobs</a></li>
</ul>
<div id="generic-tabs" class="tab-content">
<div id="tabs-starred-jobs" class="tab-pane active">
[% IF starredJobs.size > 0 %]
<p>Below are the 20 most recent builds of your starred jobs.</p>
<table class="table table-striped table-condensed">
<thead>
<tr><th>Job</th></tr>
</thead>
<tdata>
[% FOREACH j IN starredJobs %]
<tr>
<td>[% INCLUDE renderFullJobName project=j.job.get_column('project') jobset=j.job.get_column('jobset') job=j.job.name %]</td>
[% FOREACH b IN j.builds %]
<td><a href="[% c.uri_for('/build' b.id) %]">[% INCLUDE renderBuildStatusIcon size=16 build=b %]</a></td>
[% END %]
</tr>
[% END %]
</tdata>
</table>
[% ELSE %]
<div class="alert alert-warning">You have no starred jobs. You can add them by visiting a job page and clicking on the ☆ icon.</div>
[% END %]
</div>
</div>
[% END %]

View file

@ -12,7 +12,7 @@
<span 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> [%
IF log_exists(node.buildStep.drvpath);
IF buildStepLogExists(node.buildStep);
INCLUDE renderLogLinks url=c.uri_for('/build' node.buildStep.get_column('build') 'nixlog' node.buildStep.stepnr);
END %]
[% ELSE %]

View file

@ -1,9 +1,10 @@
[% WRAPPER layout.tt title=(create ? "Create jobset in project $project.name" : "Editing jobset $project.name:$jobset.name") %]
[% WRAPPER layout.tt title=(create ? "Create jobset in project $project.name" : clone ? "Cloning jobset $project.name:$jobset.name" : "Editing jobset $project.name:$jobset.name") %]
[% PROCESS common.tt %]
[% USE format %]
[% BLOCK renderJobsetInputAlt %]
<button type="button" class="btn btn-warning" onclick='$(this).parents(".inputalt").remove()'><i class="icon-trash icon-white"></i></button>
<input type="text" [% HTML.attributes(value => alt.value, name => name) %]></input>
<input type="text" [% HTML.attributes(value => alt.value, name => name) %]/>
<br />
[% END %]
@ -11,51 +12,61 @@
<tr class="input [% extraClass %]" [% IF id %]id="[% id %]"[% END %]>
<td>
<button type="button" class="btn btn-warning" onclick='$(this).parents(".input").remove()'><i class="icon-trash icon-white"></i></button>
<tt><input type="text" id="[% baseName %]-name" name="[% baseName %]-name" [% HTML.attributes(value => input.name) %]></input>
<input type="text" id="[% baseName %]-name" name="[% baseName %]-name" [% HTML.attributes(value => input.name) %]/>
</td>
<td>
[% INCLUDE renderSelection curValue=input.type param="$baseName-type" options=inputTypes %]
</td>
<td class="inputalts" id="[% baseName %]">
[% FOREACH alt IN input.jobsetinputalts %]
[% FOREACH alt IN input.search_related('jobsetinputalts', {}, { order_by => 'altnr' }) %]
<span class="inputalt">
[% INCLUDE renderJobsetInputAlt alt=alt name="$baseName-values" %]
</span>
[% END %]
[% IF edit %]<button type="button" class="add-inputalt btn btn-success" onclick='return false'><i class="icon-plus icon-white"></i></button>[% END %]
</td>
<td>
<input type="checkbox" id="[% baseName %]-emailresponsible" name="[% baseName %]-emailresponsible" [% IF input.emailresponsible; 'checked="checked"'; END %]/>
</td>
</tr>
[% END %]
[% BLOCK renderJobsetInputs %]
<table class="table table-striped table-condensed">
<thead>
<tr><th>Input name</th><th>Type</th><th>Values</th></tr>
<tr><th>Input name</th><th>Type</th><th>Values</th><th>Notify committers</th></tr>
</thead>
<tbody class="inputs">
[% FOREACH input IN jobset.jobsetinputs %]
[% INCLUDE renderJobsetInput input=input baseName="input-$input.name" %]
[% END %]
<tr>
<td colspan="3" style="text-align: center;"><button type="button" class="add-input btn btn-success"><i class="icon-plus icon-white"></i> Add a new input</button></td
<td colspan="4" style="text-align: center;"><button type="button" class="add-input btn btn-success"><i class="icon-plus icon-white"></i> Add a new input</button></td>
</tr>
</tbody>
</table>
[% END %]
<form class="form-horizontal" action="[% IF create %][% c.uri_for('/project' project.name 'create-jobset/submit') %][% ELSE %][% c.uri_for('/jobset' project.name jobset.name 'submit') %][% END %]" method="post">
<form class="form-horizontal">
<fieldset>
<div class="control-group">
<label class="control-label">State</label>
<div class="controls">
<label class="checkbox">
<input type="checkbox" name="enabled" [% IF jobset.enabled; 'checked="checked"'; END %]></input>Enabled
</label>
<div class="btn-group" data-toggle="buttons-radio">
<input type="hidden" name="enabled" value="[% jobset.enabled %]" />
<button type="button" class="btn" value="1">Enabled</button>
<button type="button" class="btn" value="2">One-shot</button>
<button type="button" class="btn" value="0">Disabled</button>
</div>
</div>
</div>
<div class="control-group">
<div class="controls">
<label class="checkbox">
<input type="checkbox" name="visible" [% IF !jobset.hidden; 'checked="checked"'; END %]></input>Visible
<input type="checkbox" name="visible" [% IF !jobset.hidden; 'checked="checked"'; END %]/>Visible
</label>
</div>
</div>
@ -63,23 +74,23 @@
<div class="control-group">
<label class="control-label">Identifier</label>
<div class="controls">
<input type="text" class="span3" name="name" [% HTML.attributes(value => jobset.name) %]></input>
<input type="text" class="span3" name="name" [% HTML.attributes(value => clone ? "" : jobset.name) %]/>
</div>
</div>
<div class="control-group">
<label class="control-label">Description</label>
<div class="controls">
<input type="text" class="span3" name="description" [% HTML.attributes(value => jobset.description) %]></input>
<input type="text" class="span3" name="description" [% HTML.attributes(value => jobset.description) %]/>
</div>
</div>
<div class="control-group">
<label class="control-label">Nix expression</label>
<div class="controls">
<input type="text" class="span3" name="nixexprpath" [% HTML.attributes(value => jobset.nixexprpath) %]></input>
<input type="text" class="span3" name="nixexprpath" [% HTML.attributes(value => jobset.nixexprpath) %]/>
in
<input type="text" class="span3" name="nixexprinput" [% HTML.attributes(value => jobset.nixexprinput) %]></input>
<input type="text" class="span3" name="nixexprinput" [% HTML.attributes(value => jobset.nixexprinput) %]/>
</div>
</div>
@ -87,17 +98,29 @@
<label class="control-label">Check interval</label>
<div class="controls">
<div class="input-append">
<input type="number" class="span3" name="checkinterval" [% HTML.attributes(value => jobset.checkinterval) %]></input>
<input type="number" class="span3" name="checkinterval" [% HTML.attributes(value => jobset.checkinterval) %]/>
<span class="add-on">sec</span>
</div>
<span class="help-inline">(0 to disable polling)</span>
</div>
</div>
<div class="control-group">
<label class="control-label">Scheduling shares</label>
<div class="controls">
<div class="input-append">
<input type="number" class="span3" name="schedulingshares" [% HTML.attributes(value => jobset.schedulingshares) %]/>
</div>
[% IF totalShares %]
<span class="help-inline">([% f = format("%.2f"); f(jobset.schedulingshares / totalShares * 100) %]% out of [% totalShares %] shares)</span>
[% END %]
</div>
</div>
<div class="control-group">
<div class="controls">
<label class="checkbox">
<input type="checkbox" name="enableemail" [% IF jobset.enableemail; 'checked="checked"'; END %]></input>Email notification
<input type="checkbox" name="enableemail" [% IF jobset.enableemail; 'checked="checked"'; END %]/>Email notification
</label>
</div>
</div>
@ -105,33 +128,21 @@
<div class="control-group">
<label class="control-label">Email override</label>
<div class="controls">
<input type="text" class="span3" name="emailoverride" [% HTML.attributes(value => jobset.emailoverride) %]></input>
<input type="text" class="span3" name="emailoverride" [% HTML.attributes(value => jobset.emailoverride) %]/>
</div>
</div>
<div class="control-group">
<label class="control-label">Number of builds to keep</label>
<label class="control-label">Number of evaluations to keep</label>
<div class="controls">
<input type="number" class="span3" name="keepnr" [% HTML.attributes(value => jobset.keepnr) %]></input>
<input type="number" class="span3" name="keepnr" [% HTML.attributes(value => jobset.keepnr) %]/>
</div>
</div>
[% INCLUDE renderJobsetInputs %]
<div class="form-actions">
<button type="submit" class="btn btn-primary"><i class="icon-ok icon-white"></i> [%IF create %]Create[% ELSE %]Apply changes[% END %]</button>
[% IF !create %]
<button id="delete-jobset" type="submit" class="btn btn-danger" name="submit" value="delete">
<i class="icon-trash icon-white"></i>
Delete this jobset
</button>
<script type="text/javascript">
$("#delete-jobset").click(function() {
return confirm("Are you sure you want to delete this jobset?");
});
</script>
[% END %]
<button id="submit-jobset" type="submit" class="btn btn-primary"><i class="icon-ok icon-white"></i> [%IF create || clone %]Create jobset[% ELSE %]Apply changes[% END %]</button>
</div>
</fieldset>
@ -144,26 +155,68 @@
[% INCLUDE renderJobsetInputAlt alt=alt %]
</tt>
<script type="text/javascript">
$(document).ready(function() {
var id = 0;
$(".add-input").click(function() {
var newid = "input-" + id++;
var x = $("#input-template").clone(true).attr("id", "").insertBefore($(this).parents("tr")).show();
$("#input-template-name", x).attr("name", newid + "-name");
$("#input-template-type", x).attr("name", newid + "-type");
$("#input-template", x).attr("id", newid);
return false;
});
$(".add-inputalt").click(function() {
var x = $("#inputalt-template").clone(true).insertBefore($(this)).attr("id", "").show();
$("input", x).attr("name", x.parents(".inputalts").attr("id") + "-values");
});
});
</script>
</form>
<script type="text/javascript">
$(document).ready(function() {
var id = 0;
$(".add-input").click(function() {
var newid = "input-" + id++;
var x = $("#input-template").clone(true).attr("id", "").insertBefore($(this).parents("tr")).show();
$("#input-template-name", x).attr("name", newid + "-name");
$("#input-template-type", x).attr("name", newid + "-type");
$("#input-template-emailresponsible", x).attr("name", newid + "-emailresponsible");
$("#input-template", x).attr("id", newid);
return false;
});
$(".add-inputalt").click(function() {
var x = $("#inputalt-template").clone(true).insertBefore($(this)).attr("id", "").show();
$("input", x).attr("name", x.parents(".inputalts").attr("id") + "-values");
});
});
$("#submit-jobset").click(function() {
var formElements = $(this).parents("form").serializeArray();
var data = { 'inputs': {} };
var inputs = {};
for (var i = 0; i < formElements.length; i++) {
var elem = formElements[i];
var match = elem.name.match(/^input-(\w+)-(\w+)$/);
if (match === null) {
data[elem.name] = elem.value;
} else {
var baseName = match[1];
var param = match[2];
if (baseName === "template") {
continue;
}
if (!(baseName in inputs)) {
inputs[baseName] = {};
}
if (param === "name") {
data.inputs[elem.value] = inputs[baseName];
} else {
inputs[baseName][param] = elem.value;
}
}
}
redirectJSON({
[% IF create || clone %]
url: "[% c.uri_for('/jobset' project.name '.new') %]",
[% ELSE %]
url: "[% c.uri_for('/jobset' project.name jobset.name) %]",
[% END %]
data: JSON.stringify(data),
contentType: 'application/json',
type: 'PUT'
});
return false;
});
</script>
[% END %]

View file

@ -1,19 +1,19 @@
[% WRAPPER layout.tt title=(create ? "New project" : "Editing project $project.name") %]
[% PROCESS common.tt %]
<form class="form-horizontal" action="[% IF create %][% c.uri_for('/create-project/submit') %][% ELSE %][% c.uri_for('/project' project.name 'submit') %][% END %]" method="post">
<form class="form-horizontal">
<fieldset>
<div class="control-group">
<div class="controls">
<label class="checkbox">
<input type="checkbox" name="enabled" [% IF project.enabled; 'checked="checked"'; END %]></input>Enabled
<input type="checkbox" name="enabled" [% IF project.enabled; 'checked="checked"'; END %]/>Enabled
</label>
</div>
<div class="controls">
<label class="checkbox">
<input type="checkbox" name="visible" [% IF !project.hidden; 'checked="checked"'; END %]></input>Visible in the list of projects
<input type="checkbox" name="visible" [% IF !project.hidden; 'checked="checked"'; END %]/>Visible in the list of projects
</label>
</div>
</div>
@ -21,58 +21,63 @@
<div class="control-group">
<label class="control-label">Identifier</label>
<div class="controls">
<input type="text" class="span3" name="name" [% HTML.attributes(value => project.name) %]></input>
<input type="text" class="span3" name="name" [% HTML.attributes(value => project.name) %]/>
</div>
</div>
<div class="control-group">
<label class="control-label">Display name</label>
<div class="controls">
<input type="text" class="span3" name="displayname" [% HTML.attributes(value => project.displayname) %]></input>
<input type="text" class="span3" name="displayname" [% HTML.attributes(value => project.displayname) %]/>
</div>
</div>
<div class="control-group">
<label class="control-label">Description</label>
<div class="controls">
<input type="text" class="span3" name="description" [% HTML.attributes(value => project.description) %]></input>
<input type="text" class="span3" name="description" [% HTML.attributes(value => project.description) %]/>
</div>
</div>
<div class="control-group">
<label class="control-label">Homepage</label>
<div class="controls">
<input type="text" class="span3" name="homepage" [% HTML.attributes(value => project.homepage) %]></input>
<input type="text" class="span3" name="homepage" [% HTML.attributes(value => project.homepage) %]/>
</div>
</div>
<div class="control-group">
<label class="control-label">Owner</label>
<div class="controls">
<input type="text" class="span3" name="owner" [% HTML.attributes(value => project.owner.username || c.user.username) %]></input>
<input type="text" class="span3" name="owner" [% HTML.attributes(value => project.owner.username || c.user.username) %]/>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<button id="submit-project" type="submit" class="btn btn-primary">
<i class="icon-ok icon-white"></i>
[%IF create %]Create[% ELSE %]Apply changes[% END %]
[%IF create %]Create project[% ELSE %]Apply changes[% END %]
</button>
[% IF !create %]
<button id="delete-project" type="submit" class="btn btn-danger" name="submit" value="delete">
<i class="icon-trash icon-white"></i>
Delete this project
</button>
<script type="text/javascript">
$("#delete-project").click(function() {
return confirm("Are you sure you want to delete this project?");
});
</script>
[% END %]
</div>
</fieldset>
</form>
<script type="text/javascript">
$("#submit-project").click(function() {
redirectJSON({
[% IF create %]
url: "[% c.uri_for('/project' '.new') %]",
[% ELSE %]
url: "[% c.uri_for('/project' project.name) %]",
[% END %]
data: $(this).parents("form").serialize(),
type: 'PUT'
});
return false;
});
</script>
[% END %]

View file

@ -9,14 +9,14 @@
<div class="control-group">
<label class="control-label">Identifier</label>
<div class="controls">
<input type="text" class="span3" name="name" [% HTML.attributes(value => release.name) %]></input>
<input type="text" class="span3" name="name" [% HTML.attributes(value => release.name) %]/>
</div>
</div>
<div class="control-group">
<label class="control-label">Description</label>
<div class="controls">
<input type="text" class="span3" name="description" [% HTML.attributes(value => release.description) %]></input>
<input type="text" class="span3" name="description" [% HTML.attributes(value => release.description) %]/>
</div>
</div>
@ -30,7 +30,7 @@
<div class="releaseMember control-group">
<label class="control-label">Build [% m.build.id %] Label</label>
<div class="controls">
<input type="text" class="span3" name="member-[% m.build.id %]-description" [% HTML.attributes(value => m.description) %]></input>
<input type="text" class="span3" name="member-[% m.build.id %]-description" [% HTML.attributes(value => m.description) %]/>
<button class="btn btn-warning" type="button" onclick='$(this).parents(".releaseMember").remove()'><i class="icon-trash icon-white"></i></button>
</div>
</div>

View file

@ -26,14 +26,14 @@
<div class="control-group">
<label class="control-label">Identifier</label>
<div class="controls">
<input type="text" class="span3" name="name" [% HTML.attributes(value => view.name) %]></input>
<input type="text" class="span3" name="name" [% HTML.attributes(value => view.name) %]/>
</div>
</div>
<div class="control-group">
<label class="control-label">Description</label>
<div class="controls">
<input type="text" class="span3" name="description" [% HTML.attributes(value => view.description) %]></input>
<input type="text" class="span3" name="description" [% HTML.attributes(value => view.description) %]/>
</div>
</div>

View file

@ -2,7 +2,7 @@
[% USE HTML %]
[% FOREACH error IN errors %]
<div class="alert alert-error">[% HTML.escape(error) %]</div>
<div class="alert alert-error">[% HTML.escape(error).replace('\n', '<br/>') %]</div>
[% END %]
[% END %]

View file

@ -1,80 +0,0 @@
[% WRAPPER layout.tt title="Errors" %]
[% PROCESS common.tt %]
<p>This page provides a quick way to see how FUBARed your packages
are. It shows job expressions that dont evaluate properly and jobs
that dont build.</p>
[% haveErrors = 0 %]
[% IF brokenJobsets && brokenJobsets.size > 0; haveErrors = 1 %]
<h2>Evaluation errors in jobsets</h2>
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>Name</th>
<th>Error</th>
</tr>
</thead>
<tdata>
[% FOREACH j IN brokenJobsets %]
<tr>
<td>[% INCLUDE renderFullJobsetName project=j.project.name jobset=j.name %]</td>
<td>
<pre class="error">[% HTML.escape(j.errormsg) %]</pre>
</td>
</tr>
[% END %]
</tdata>
</table>
[% END %]
[% IF brokenJobs && brokenJobs.size > 0; haveErrors = 1 %]
<h2>Evaluation errors in jobs</h2>
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>Name</th>
<th>Error</th>
</tr>
</thead>
<tdata>
[% FOREACH j IN brokenJobs %]
<tr>
<td>[% INCLUDE renderFullJobName project=j.project.name jobset=j.jobset.name job=j.name %]</td>
<td>
<pre class="error">[% HTML.escape(j.errormsg) %]</pre>
</td>
</tr>
[% END %]
</tdata>
</table>
[% END %]
[% IF brokenBuilds && brokenBuilds.size > 0; haveErrors = 1 %]
<h2>Broken builds</h2>
[% INCLUDE renderBuildList builds=brokenBuilds showStatusChange=1 hideProjectName=project hideJobsetName=jobset hideJobName=job %]
[% END %]
[% IF !haveErrors %]
<p><strong>There are currently no problems.</strong></p>
[% END %]
[% END %]

View file

@ -1,9 +1,15 @@
[% WRAPPER layout.tt title="Job $project.name:$jobset.name:$job.name" %]
[% WRAPPER layout.tt
title="Job $project.name:$jobset.name:$job.name"
starUri=c.uri_for(c.controller('Job').action_for('star'), c.req.captures)
%]
[% PROCESS common.tt %]
[% hideProjectName=1 hideJobsetName=1 hideJobName=1 %]
<ul class="nav nav-tabs">
<li class="active"><a href="#tabs-status" data-toggle="tab">Status</a></li>
[% IF constituentJobs.size > 0 %]
<li><a href="#tabs-constituents" data-toggle="tab">Constituents</a></li>
[% END %]
<li><a href="#tabs-links" data-toggle="tab">Links</a></li>
</ul>
@ -12,7 +18,7 @@
<div id="tabs-status" class="tab-pane active">
[% IF lastBuilds.size != 0 %]
<h3>Lastest builds</h3>
[% INCLUDE renderBuildList builds=lastBuilds showStatusChange=0
[% INCLUDE renderBuildList builds=lastBuilds
linkToAll=c.uri_for('/job' project.name jobset.name job.name 'all') %]
[% END %]
[% IF queuedBuilds.size != 0 %]
@ -21,12 +27,57 @@
[% END %]
</div>
[% IF constituentJobs.size > 0 %]
<div id="tabs-constituents" class="tab-pane">
<div class="well well-small">This is an <em>aggregate job</em>:
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
aggregate.</div>
[% aggs = aggregates.keys.nsort.reverse %]
<table class="table table-striped table-condensed table-header-rotated">
<thead>
<tr>
<th>Job</th>
[% FOREACH agg IN aggs %]
<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>
</span></div></th>
[% END %]
</tr>
</thead>
<tbody>
[% FOREACH j IN constituentJobs %]
<tr>
<th style="width: 1em;">[% INCLUDE renderJobName project=project.name jobset=jobset.name job=j %]</th>
[% FOREACH agg IN aggs %]
<td>
[% r = aggregates.$agg.constituents.$j; IF r.id %]
<a href="[% c.uri_for('/build' r.id) %]">
[% INCLUDE renderBuildStatusIcon size=16 build=r %]
</a>
[% END %]
</td>
[% END %]
</tr>
[% END %]
</tbody>
</table>
</div>
[% END %]
<div id="tabs-links" class="tab-pane">
<ul>
<li><a href="[% c.uri_for('/job' project.name jobset.name job.name 'latest') %]">Latest successful build</a></li>
[% FOREACH system IN systems %]
<li><a href="[% c.uri_for('/job' project.name jobset.name job.name 'latest-for' system.system) %]">Latest successful build for <tt>[% system.system %]</tt></a></li>
[% END %]
<li><a href="[% c.uri_for('/job' project.name jobset.name job.name 'latest-finished') %]">Latest successful build from a finished evaluation</a></li>
</ul>
</div>

View file

@ -4,10 +4,14 @@
<div class="btn-group pull-right">
<a class="btn btn-primary dropdown-toggle" data-toggle="dropdown" href="#"><i class="icon-white icon-eye-open"></i> Compare to...</a>
<ul class="dropdown-menu">
<li><a href="?">Preceding evaluation in the same jobset</tt></a></li>
<li><a href="?">Preceding evaluation in this jobset</a></li>
<li class="divider"></li>
<li><a href="?compare=-[% 24 * 60 * 60 %]">This jobset <strong>one day</strong> earlier</a></li>
<li><a href="?compare=-[% 7 * 24 * 60 * 60 %]">This jobset <strong>one week</strong> earlier</a></li>
<li><a href="?compare=-[% 31 * 24 * 60 * 60 %]">This jobset <strong>one month</strong> earlier</a></li>
[% IF project.jobsets_rs.count > 1 %]
<li class="divider"></li>
[% FOREACH j IN project.jobsets; IF j.name != jobset.name %]
[% FOREACH j IN project.jobsets.sort('name'); IF j.name != jobset.name %]
<li><a href="?compare=[% j.name %]">Jobset <tt>[% project.name %]:[% j.name %]</tt></a></li>
[% END; END %]
[% END %]
@ -19,81 +23,117 @@
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">Couldn't find an evaluation to compare to.</div>
[% END %]
<form class="form-search">
<input name="filter" type="text" class="input-large search-query" placeholder="Search jobs by name..." [% HTML.attributes(value => filter) %]/>
<input name="compare" type="hidden" [% HTML.attributes(value => otherEval.id) %]/>
<input name="full" type="hidden" [% HTML.attributes(value => full) %]/>
</form>
<ul class="nav nav-tabs">
<li class="active"><a href="#tabs-status" data-toggle="tab">Job status</a></li>
[% IF c.user_exists %]
<li class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" href="#">
Actions
<b class="caret"></b>
</a>
<ul class="dropdown-menu">
<li><a href="[% c.uri_for(c.controller('JobsetEval').action_for('release'), [eval.id]) %]">Create a release from this evaluation</a></li>
<li><a href="[% c.uri_for(c.controller('JobsetEval').action_for('cancel'), [eval.id]) %]">Cancel all scheduled builds</a></li>
<li><a href="[% c.uri_for(c.controller('JobsetEval').action_for('restart_aborted'), [eval.id]) %]">Restart all aborted builds</a></li>
</ul>
</li>
[% END %]
[% IF aborted.size > 0 %]
<li><a href="#tabs-aborted" data-toggle="tab"><span class="text-warning">Aborted jobs ([% aborted.size %])</span></a></li>
[% END %]
[% IF nowFail.size > 0 %]
<li><a href="#tabs-now-fail" data-toggle="tab"><span class="text-warning">Newly-failing jobs ([% nowFail.size %])</span></a></li>
[% END %]
[% IF nowSucceed.size > 0 %]
<li><a href="#tabs-now-succeed" data-toggle="tab"><span class="text-success">Newly-succeeding jobs ([% nowSucceed.size %])</span></a></li>
[% END %]
[% IF new.size > 0 %]
<li><a href="#tabs-new" data-toggle="tab">New jobs ([% new.size %])</a></li>
[% END %]
[% IF removed.size > 0 %]
<li><a href="#tabs-removed" data-toggle="tab">Removed jobs ([% removed.size %])</a></li>
[% END %]
[% IF stillFail.size > 0 %]
<li><a href="#tabs-still-fail" data-toggle="tab">Still-failing jobs ([% stillFail.size %])</a></li>
[% END %]
[% IF stillSucceed.size > 0 %]
<li><a href="#tabs-still-succeed" data-toggle="tab">Still-succeeding jobs ([% stillSucceed.size %])</a></li>
[% END %]
[% IF unfinished.size > 0 %]
<li><a href="#tabs-unfinished" data-toggle="tab">Queued jobs ([% unfinished.size %])</a></li>
[% END %]
<li><a href="#tabs-inputs" data-toggle="tab">Inputs</a></li>
</ul>
[% BLOCK renderSome %]
[% INCLUDE renderBuildListHeader unsortable=1 %]
[% size = builds.size; max = full ? size : 250; %]
[% INCLUDE renderBuildListBody builds=builds.slice(0, (size > max ? max : size) - 1)
hideProjectName=1 hideJobsetName=1 %]
[% 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) %]"><em>([% size - max %] more builds omitted)</em></a></td></tr>
[% END %]
[% INCLUDE renderBuildListFooter %]
[% END %]
<div class="tab-content">
<div id="tabs-status" class="tab-pane active">
<div id="tabs-aborted" class="tab-pane">
[% INCLUDE renderSome builds=aborted %]
</div>
[% BLOCK renderSome %]
[% size = builds.size; max = full ? size : 30; %]
[% INCLUDE renderBuildListBody builds=builds.slice(0, (size > max ? max : size) - 1)
hideProjectName=1 hideJobsetName=1 %]
[% 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) %]"><em>([% size - max %] more builds omitted)</em></a></td></tr>
[% END %]
[% END %]
<div id="tabs-now-fail" class="tab-pane">
[% INCLUDE renderSome builds=nowFail %]
</div>
[% INCLUDE renderBuildListHeader unsortable=1 %]
<div id="tabs-now-succeed" class="tab-pane">
[% INCLUDE renderSome builds=nowSucceed %]
</div>
[% IF unfinished.size > 0 %]
<tr><th class="subheader" colspan="6"><strong>Queued</strong> jobs</th></tr>
[% INCLUDE renderSome builds=unfinished %]
[% END %]
<div id="tabs-new" class="tab-pane">
[% INCLUDE renderSome builds=new %]
</div>
[% IF new.size > 0 %]
<tr><th class="subheader" colspan="6"><strong>New</strong> jobs</th></tr>
[% INCLUDE renderSome builds=new %]
[% END %]
<div id="tabs-removed" class="tab-pane">
<table class="table table-striped table-condensed clickable-rows">
<thead>
<tr><th>Job</th><th>System</th></tr>
</thead>
<tbody>
[% size = removed.size; max = full ? size : 250; %]
[% 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>
</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) %]"><em>([% size - max %] more jobs omitted)</em></a></td></tr>
[% END %]
</tbody>
</table>
</div>
[% IF removed.size > 0 %]
<tr><th class="subheader" colspan="6"><strong>Removed</strong> jobs</th></tr>
[% size = removed.size; max = full ? size : 30; %]
[% FOREACH j IN removed.slice(0,(size > max ? max : size) - 1) %]
<tr>
<td colspan="2"></td>
<td colspan="2">[% INCLUDE renderJobName project=project.name jobset=jobset.name job=j.job %]</td>
<td colspan="2"><tt>[% j.system %]</tt></td>
</tr>
[% END %]
[% 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) %]"><em>([% size - max %] more jobs omitted)</em></a></td></tr>
[% END %]
[% END %]
<div id="tabs-still-fail" class="tab-pane">
[% INCLUDE renderSome builds=stillFail %]
</div>
[% IF nowFail.size > 0 %]
<tr><th class="subheader" colspan="6">Jobs that now <strong>fail</strong></th></tr>
[% INCLUDE renderSome builds=nowFail %]
[% END %]
[% IF nowSucceed.size > 0 %]
<tr><th class="subheader" colspan="6">Jobs that now <strong>succeed</strong></th></tr>
[% INCLUDE renderSome builds=nowSucceed %]
[% END %]
[% IF stillFail.size > 0 %]
<tr><th class="subheader" colspan="6">Jobs that still <strong>fail</strong></th></tr>
[% INCLUDE renderSome builds=stillFail %]
[% END %]
[% IF stillSucceed.size > 0 %]
<tr><th class="subheader" colspan="6">Jobs that still <strong>succeed</strong></th></tr>
[% INCLUDE renderSome builds=stillSucceed %]
[% END %]
[% INCLUDE renderBuildListFooter %]
[% IF c.user_exists %]
<p>
<a class="btn" href="[% c.uri_for(c.controller('JobsetEval').action_for('release'), [eval.id]) %]">Release</a>
</p>
[% END %]
<div id="tabs-still-succeed" class="tab-pane">
[% INCLUDE renderSome builds=stillSucceed %]
</div>
<div id="tabs-unfinished" class="tab-pane">
[% INCLUDE renderSome builds=unfinished %]
</div>
<div id="tabs-inputs" class="tab-pane">

View file

@ -1,15 +1,69 @@
[% PROCESS common.tt %]
[% PROCESS common.tt; USE Math %]
<p>This jobset currently contains the following [% activeJobs.size %] jobs:
<blockquote>
[% IF activeJobs.size == 0 %]<em>(none)</em>[% END %]
[% FOREACH j IN activeJobs %][% INCLUDE renderJobName project=project.name jobset=jobset.name job=j %]<br/>[% END %]
</blockquote>
</p>
<form class="form-search" id="filter-jobs">
<div class="input-append">
<input name="filter" type="text" class="input-large search-query" placeholder="Search jobs by name..." [% HTML.attributes(value => filter) %]/>
<button type="button" class="btn btn-info [% IF showInactive %]active[% END %]" id="active-toggle">Show inactive jobs</button>
</div>
&nbsp;
<img src="[% c.uri_for("/static/images/ajax-loader.gif") %]" alt="Loading..." style="display: none;" id="filter-loading" />
</form>
<p>This jobset used to contain the following [% inactiveJobs.size %] jobs:
<blockquote>
[% IF inactiveJobs.size == 0 %]<em>(none)</em>[% END %]
[% FOREACH j IN inactiveJobs %][% INCLUDE renderJobName project=project.name jobset=jobset.name job=j %]<br/>[% END %]
</blockquote>
</p>
<script>
function setFilter(filter) {
$('#filter-loading').show();
if ($('#active-toggle').hasClass('active')) filter += '&amp;showInactive=1';
$('#tabs-jobs').load("[% c.uri_for('/jobset' project.name jobset.name "jobs-tab") %]", filter, function(response, status, xhr) {
if (status == "error") {
$('#[% tabName %]').html("<div class='alert alert-error'>Error loading tab: " + xhr.status + " " + xhr.statusText + "</div>");
}
});
};
$('#filter-jobs').submit(function() {
setFilter($('#filter-jobs').serialize());
return false;
});
$('#active-toggle').click(function() {
$(this).toggleClass('active');
$('#filter-jobs').submit();
});
</script>
[% IF jobs.size == 0 %]
<div class="alert">There are no matching jobs.</div>
[% ELSE %]
[% IF nrJobs > jobs.size %]
<div class="alert">Showing the first [% jobs.size %] jobs. <a href="javascript:setFilter('filter=%')">Show all [% nrJobs %] jobs...</a></div>
[% END %]
[% evalIds = evals.keys.nsort.reverse %]
<table class="table table-striped table-condensed table-header-rotated">
<thead>
<tr>
<th style="width: 1em;">Job</th>
[% FOREACH eval IN evalIds %]
<th class="rotate-45">
<div><span>
<a href="[% c.uri_for('/eval' eval) %]">[% eval %]</a>
</span></div></th>
[% END %]
</tr>
</thead>
<tbody>
[% FOREACH j IN jobs-%]
<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.$j; IF r.id %]<a href="[% c.uri_for('/build' r.id) %]">[% INCLUDE renderBuildStatusIcon size=16 build=r %]</a>[% END %]</td>
[% END %]
</tr>
[% END %]
</tbody>
</table>
[% END %]

View file

@ -1,23 +0,0 @@
[% PROCESS common.tt %]
<table class="table table-striped table-condensed">
<thead><tr><th>Job</th>[% FOREACH s IN systems %]<th>[% s.system %]</th>[% END %]</tr></thead>
<tbody>
[% FOREACH j IN activeJobsStatus %]
<tr>
<td>[% INCLUDE renderJobName project=project.name jobset = jobset.name job = j.get_column('job') %]</td>
[% FOREACH s IN systems %]
[% system = s.system %]
[% systemStatus = j.get_column(system) %]
<td class="centered">
[% IF systemStatus != undef %]
<a href="[% c.uri_for('/build' j.get_column(system _ '-build') ) %]">
[% INCLUDE renderBuildStatusIcon buildstatus=systemStatus size=16 %]
</a>
[% END %]
</td>
[% END %]
</tr>
[% END %]
</tbody>
</table>

View file

@ -1,5 +1,6 @@
[% WRAPPER layout.tt title="Jobset $project.name:$jobset.name" %]
[% PROCESS common.tt %]
[% USE format %]
[% BLOCK renderJobsetInput %]
@ -11,7 +12,7 @@
[% INCLUDE renderSelection curValue=input.type param="$baseName-type" options=inputTypes %]
</td>
<td class="inputalts" id="[% baseName %]">
[% FOREACH alt IN input.jobsetinputalts %]
[% FOREACH alt IN input.search_related('jobsetinputalts', {}, { order_by => 'altnr' }) %]
<tt class="inputalt">
[% IF input.type == "string" %]
"[% HTML.escape(alt.value) %]"
@ -41,11 +42,25 @@
<ul class="nav nav-tabs">
<li class="active"><a href="#tabs-evaluations" data-toggle="tab">Evaluations</a></li>
[% IF jobset.errormsg %]
<li><a href="#tabs-errors" data-toggle="tab"><img src="/static/images/error_16.png" /> Evaluation errors</a></li>
[% IF c.user_exists %]
<li class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" href="#">
Actions
<b class="caret"></b>
</a>
<ul class="dropdown-menu">
[% INCLUDE menuItem title="Edit configuration" icon="icon-edit" uri=c.uri_for(c.controller('Jobset').action_for('edit'), c.req.captures) %]
[% INCLUDE menuItem title="Delete this jobset" icon="icon-trash" uri="javascript:deleteJobset()" %]
[% INCLUDE menuItem title="Clone this jobset" uri=c.uri_for(c.controller('Jobset').action_for('edit'), c.req.captures, { clone => 1 }) %]
[% INCLUDE menuItem title="Evaluate this jobset" uri="javascript:confirmEvaluateJobset()" %]
</ul>
</li>
[% END %]
<li class="active"><a href="#tabs-evaluations" data-toggle="tab">Evaluations</a></li>
[% IF jobset.errormsg || jobset.fetcherrormsg %]
<li><a href="#tabs-errors" data-toggle="tab"><span class="text-warning">Evaluation errors</span></a></li>
[% END %]
<li><a href="#tabs-status" data-toggle="tab">Job status</a></li>
<li><a href="#tabs-jobs" data-toggle="tab">Jobs</a></li>
<li><a href="#tabs-configuration" data-toggle="tab">Configuration</a></li>
</ul>
@ -59,7 +74,7 @@
<th>Last checked:</th>
<td>
[% IF jobset.lastcheckedtime %]
[% INCLUDE renderDateTime timestamp = jobset.lastcheckedtime %], [% IF jobset.errormsg %]<em class="text-warning">with errors!</em>[% ELSE %]<em>no errors</em>[% END %]
[% INCLUDE renderDateTime timestamp = jobset.lastcheckedtime %], [% IF jobset.errormsg || jobset.fetcherrormsg %]<em class="text-warning">with errors!</em>[% ELSE %]<em>no errors</em>[% END %]
[% ELSE %]
<em>never</em>
[% END %]
@ -91,20 +106,20 @@
</div>
[% INCLUDE makeLazyTab tabName="tabs-status" uri=c.uri_for('/jobset' project.name jobset.name "status-tab") %]
[% IF jobset.errormsg %]
[% IF jobset.errormsg || jobset.fetcherrormsg %]
<div id="tabs-errors" class="tab-pane">
<p>Errors occurred at [% INCLUDE renderDateTime timestamp=jobset.errortime %].</p>
<pre class="alert alert-error">[% HTML.escape(jobset.errormsg) %]</pre>
<pre class="alert alert-error">[% HTML.escape(jobset.fetcherrormsg || jobset.errormsg) %]</pre>
</div>
[% END %]
<div id="tabs-configuration" class="tab-pane">
<a class="btn pull-right" href="[% c.uri_for('/jobset' project.name jobset.name "edit") %]"><i class="icon-edit"></i> Edit</a>
<table class="info-table">
<tr>
<th>State:</th>
<td>[% IF jobset.enabled == 0; "Disabled"; ELSIF jobset.enabled == 1; "Enabled"; ELSIF jobset.enabled == 2; "One-shot"; END %]</td>
</tr>
<tr>
<th>Description:</th>
<td>[% HTML.escape(jobset.description) %]</td>
@ -116,14 +131,14 @@
<tt>[% HTML.escape(jobset.nixexprinput) %]</tt>
</td>
</tr>
<tr>
<th>Enabled:</th>
<td>[% jobset.enabled ? "Yes" : "No" %]</td>
</tr>
<tr>
<th>Check interval:</th>
<td>[% 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>
</tr>
<tr>
<th>Enable email notification:</th>
<td>[% jobset.enableemail ? "Yes" : "No" %]</td>
@ -133,7 +148,7 @@
<td>[% HTML.escape(jobset.emailoverride) %]</td>
</tr>
<tr>
<th>Number of builds to keep:</th>
<th>Number of evaluations to keep:</th>
<td>[% jobset.keepnr %]</td>
</tr>
</table>
@ -145,4 +160,32 @@
</div>
<script>
function confirmEvaluateJobset() {
bootbox.confirm(
'Are you sure you want to force evaluation of this jobset?',
function(c) {
if (!c) return;
requestJSON({
url: "[% HTML.escape(c.uri_for('/api/push', { jobsets = project.name _ ':' _ jobset.name, force = "1" })) %]",
success: function(data) {
bootbox.alert("The jobset has been scheduled for evaluation.");
}
});
});
};
function deleteJobset() {
bootbox.confirm(
'Are you sure you want to delete this jobset?',
function(c) {
if (!c) return;
redirectJSON({
url: "[% c.uri_for(c.controller('Jobset').action_for('jobset'), c.req.captures) %]",
type: 'DELETE'
});
});
};
</script>
[% END %]

View file

@ -1,15 +0,0 @@
[% WRAPPER layout.tt title="Job status" _
(job ? " of job $project.name:$jobset.name:$job.name" :
jobset ? " of jobset $project.name:$jobset.name" :
project ? " of project $project.name" : "") %]
[% PROCESS common.tt %]
<p>Below are the latest builds for each job. It is ordered by the status
change time (the timestamp of the last build that had a different
build result status). That is, it shows the jobs that most recently
changed from failed to successful or vice versa first.</p>
[% INCLUDE renderBuildList builds=latestBuilds showStatusChange=1
hideProjectName=project hideJobsetName=jobset hideJobName=job %]
[% END %]

View file

@ -1,9 +1,6 @@
[% USE date %]
[% USE HTML %]
<?xml version="1.0" encoding="UTF-8"?>
[% PROCESS common.tt %]
<!DOCTYPE html>
<html lang="en">
@ -19,24 +16,25 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script type="text/javascript" src="/static/bootstrap/js/bootstrap.min.js"></script>
<link href="/static/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
<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 must be included before bootstrap-responsive to
make the @media rule work. -->
<link rel="stylesheet" href="/static/css/hydra.css" type="text/css" />
<link href="/static/bootstrap/css/bootstrap-responsive.min.css" rel="stylesheet" />
<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" />
<link href="[% c.uri_for("/static/bootstrap/css/bootstrap-responsive.min.css") %]" rel="stylesheet" />
<style>
.popover { max-width: 40%; }
</style>
<script type="text/javascript" src="/static/js/bootbox.min.js"></script>
<script type="text/javascript" src="[% c.uri_for("/static/js/bootbox.min.js") %]"></script>
<link rel="stylesheet" href="/static/css/tree.css" type="text/css" />
<link rel="stylesheet" href="/static/css/logfile.css" type="text/css" />
<link rel="stylesheet" href="[% c.uri_for("/static/css/tree.css") %]" type="text/css" />
<link rel="stylesheet" href="[% c.uri_for("/static/css/logfile.css") %]" type="text/css" />
<script type="text/javascript" src="/static/js/common.js"></script>
<script type="text/javascript" src="[% c.uri_for("/static/js/common.js") %]"></script>
[% tracker %]
@ -68,24 +66,27 @@
<div class="container">
[% IF !hideHeader %]
<div class="page-header">
<h1><small>[% HTML.escape(title) %]</small></h1>
</div>
[% ELSE %]
<br />
[% END %]
[% IF flashMsg %]
<br />
<div class="alert alert-info">[% flashMsg %]</div>
[% END %]
[% IF successMsg %]
<br />
<div class="alert alert-success">[% successMsg %]</div>
[% END %]
[% IF errorMsg %]
<div class="alert alert-error">Error: [% errorMsg %]</div>
<br />
<div class="alert alert-warning">Error: [% errorMsg %]</div>
[% END %]
[% IF !hideHeader %]
<div class="page-header">
<h1><small>[% IF c.user_exists && starUri; INCLUDE makeStar; " "; END; HTML.escape(title) %]</small></h1>
</div>
[% ELSE %]
<br />
[% END %]
[% content %]
@ -93,7 +94,7 @@
<footer class="navbar">
<hr />
<small>
<em><a href="http://nixos.org/hydra" target="_new">Hydra</a> [% HTML.escape(version) %] (using [% HTML.escape(nixVersion) %]).</em>
<em><a href="http://nixos.org/hydra" target="_blank">Hydra</a> [% HTML.escape(version) %] (using [% HTML.escape(nixVersion) %]).</em>
[% IF c.user_exists %]
You are signed in as <tt>[% HTML.escape(c.user.username) %]</tt>.
[% END %]

View file

@ -13,14 +13,14 @@ You are already signed in as <tt>[% HTML.escape(c.user.username) %]</tt>.
<div class="control-group">
<label class="control-label">User name</label>
<div class="controls">
<input type="text" class="span3" name="username" value=""></input>
<input type="text" class="span3" name="username" value=""/>
</div>
</div>
<div class="control-group">
<label class="control-label">Password</label>
<div class="controls">
<input type="password" class="span3" name="password" value=""></input>
<input type="password" class="span3" name="password" value=""/>
</div>
</div>

View file

@ -40,7 +40,7 @@
<tr class="product">
<td>
<a href="[% contents %]">
<img src="/static/images/error_32.png" alt="Source" />
<img src="[% c.uri_for("/static/images/error_32.png") %]" alt="Source" />
Failed build produced output. Click here to inspect the output.
</a>
[% WRAPPER makePopover title="Help" classes="btn-mini" %]
@ -59,7 +59,7 @@
<td>
[% uri = c.uri_for('/build' build.id 'nix' 'pkg' "${build.nixname}-${build.system}.nixpkg") %]
<a href="[% uri %]">
<img src="/static/images/nix-build.png" alt="Source" />
<img src="[% c.uri_for("/static/images/nix-build.png") %]" alt="Source" />
One-click install of Nix package <tt>[% build.nixname %]</tt>
</a>
[% WRAPPER makePopover title="Help" classes="btn-mini" %]
@ -87,7 +87,7 @@
[% uri = c.uri_for('/build' build.id 'nix' 'closure' filename ) %]
<a href="[% uri %]">
<img src="/static/images/nix-build.png" alt="Source" />
<img src="[% c.uri_for("/static/images/nix-build.png") %]" alt="Source" />
Nix closure of path <tt>[% product.path %]</tt>
</a>
@ -127,17 +127,17 @@
<a href="[% uri %]">
[% SWITCH product.subtype %]
[% CASE "source-dist" %]
<img src="/static/images/source-dist.png" alt="Source" /> Source distribution <tt>[% product.name %]</tt>
<img src="[% c.uri_for("/static/images/source-dist.png") %]" alt="Source" /> Source distribution <tt>[% product.name %]</tt>
[% CASE "rpm" %]
<img src="/static/images/rpm.png" alt="RPM" /> RPM package <tt>[% product.name %]</tt>
<img src="[% c.uri_for("/static/images/rpm.png") %]" alt="RPM" /> RPM package <tt>[% product.name %]</tt>
[% CASE "srpm" %]
<img src="/static/images/rpm.png" alt="Source RPM" /> Source RPM package <tt>[% product.name %]</tt>
<img src="[% c.uri_for("/static/images/rpm.png") %]" alt="Source RPM" /> Source RPM package <tt>[% product.name %]</tt>
[% CASE "deb" %]
<img src="/static/images/debian.png" alt="RPM" /> Debian package <tt>[% product.name %]</tt>
<img src="[% c.uri_for("/static/images/debian.png") %]" alt="RPM" /> Debian package <tt>[% product.name %]</tt>
[% CASE "iso" %]
<img src="/static/images/iso.png" alt="ISO" /> ISO-9660 CD/DVD image <tt>[% product.name %]</tt>
<img src="[% c.uri_for("/static/images/iso.png") %]" alt="ISO" /> ISO-9660 CD/DVD image <tt>[% product.name %]</tt>
[% CASE "binary-dist" %]
<img src="/static/images/binary-dist.png" alt="Binary distribution" /> Binary distribution <tt>[% product.name %]</tt>
<img src="[% c.uri_for("/static/images/binary-dist.png") %]" alt="Binary distribution" /> Binary distribution <tt>[% product.name %]</tt>
[% CASE DEFAULT %]
File <tt>[% product.name %]</tt> of type <tt>[% product.subtype %]</tt>
[% END %]
@ -160,7 +160,7 @@
<tr class="product">
<td>
<a href="[% uri %]">
<img src="/static/images/report.png" alt="Report" />
<img src="[% c.uri_for("/static/images/report.png") %]" alt="Report" />
[% SWITCH product.subtype %]
[% CASE "coverage" %]
Code coverage analysis report
@ -177,9 +177,9 @@
<td>
<a href="[% uri %]">
[% IF product.type == "doc-pdf" %]
<img src="/static/images/pdf.png" alt="PDF document" />
<img src="[% c.uri_for("/static/images/pdf.png") %]" alt="PDF document" />
[% ELSE %]
<img src="/static/images/document.png" alt="Document" />
<img src="[% c.uri_for("/static/images/document.png") %]" alt="Document" />
[% END %]
[% SWITCH product.subtype %]
[% CASE "readme" %]

View file

@ -2,16 +2,33 @@
[% PROCESS common.tt %]
<ul class="nav nav-tabs">
[% IF c.user_exists %]
<li class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" href="#">
Actions
<b class="caret"></b>
</a>
<ul class="dropdown-menu">
[% INCLUDE menuItem title="Edit configuration" icon="icon-edit" uri=c.uri_for(c.controller('Project').action_for('edit'), c.req.captures) %]
[% INCLUDE menuItem title="Delete this project" icon="icon-trash" uri="javascript:deleteProject()" %]
[% INCLUDE menuItem title="Create jobset" icon="icon-plus" uri=c.uri_for(c.controller('Project').action_for('create_jobset'), c.req.captures) %]
[% INCLUDE menuItem title="Create release" icon="icon-plus" uri=c.uri_for(c.controller('Project').action_for('create_release'), c.req.captures) %]
</ul>
</li>
[% END %]
<li class="active"><a href="#tabs-project" data-toggle="tab">Jobsets</a></li>
<li><a href="#tabs-configuration" data-toggle="tab">Configuration</a></li>
<li><a href="#tabs-releases" data-toggle="tab">Releases</a></li>
<li><a href="#tabs-views" data-toggle="tab">Views</a></li>
[% IF views.size > 0 %]
<li><a href="#tabs-views" data-toggle="tab">Views</a></li>
[% END %]
</ul>
<div class="tab-content">
<div id="tabs-project" class="tab-pane active">
[% IF project.jobsets.size > 0 %]
[% IF project.jobsets %]
<p>This project has the following jobsets:</p>
<table class="table table-striped table-condensed clickable-rows">
@ -30,18 +47,18 @@
<tr>
<td>
[% IF j.get_column('nrscheduled') > 0 %]
<img src="/static/images/help_16.png" alt="Scheduled" />
<img src="[% c.uri_for("/static/images/help_16.png") %]" alt="Scheduled" />
[% ELSIF j.get_column('nrfailed') == 0 %]
<img src="/static/images/checkmark_16.png" alt="Succeeded" />
<img src="[% c.uri_for("/static/images/checkmark_16.png") %]" alt="Succeeded" />
[% ELSIF j.get_column('nrfailed') > 0 && j.get_column('nrsucceeded') > 0 %]
<img src="/static/images/error_some_16.png" alt="Some Failed" />
<img src="[% c.uri_for("/static/images/error_some_16.png") %]" alt="Some Failed" />
[% ELSE %]
<img src="/static/images/error_16.png" alt="All Failed" />
<img src="[% c.uri_for("/static/images/error_16.png") %]" alt="All Failed" />
[% END %]
</td>
<td><span class="[% IF !j.enabled %]disabled-jobset[% END %] [%+ IF j.hidden %]hidden-jobset[% END %]">[% INCLUDE renderJobsetName project=project.name jobset=j.name inRow=1 %]</span></td>
<td>[% HTML.escape(j.description) %]</td>
<td>[% INCLUDE renderDateTime timestamp = j.lastcheckedtime %]</td>
<td>[% IF j.lastcheckedtime; INCLUDE renderDateTime timestamp = j.lastcheckedtime; ELSE; "-"; END %]</td>
[% IF j.get_column('nrtotal') > 0 %]
[% successrate = ( j.get_column('nrsucceeded') / j.get_column('nrtotal') )*100 %]
[% IF j.get_column('nrscheduled') > 0 %]
@ -78,7 +95,6 @@
</div>
<div id="tabs-configuration" class="tab-pane">
<a class="btn pull-right" href="[% c.uri_for('/project' project.name "edit") %]"><i class="icon-edit"></i> Edit</a>
<table class="info-table">
<tr>
<th>Display name:</th>
@ -138,12 +154,6 @@
[% END %]
[% IF c.user_exists %]
<p><a class="btn" href="[% c.uri_for('/project' project.name 'create-release') %]">
<i class="icon-plus"></i> Create a release
</a></p>
[% END %]
</div>
<div id="tabs-views" class="tab-pane">
@ -176,4 +186,18 @@
</div>
<script>
function deleteProject() {
bootbox.confirm(
'Are you sure you want to delete this project?',
function(c) {
if (!c) return;
redirectJSON({
url: "[% c.uri_for('/project' project.name) %]",
type: 'DELETE'
});
});
};
</script>
[% END %]

View file

@ -141,11 +141,14 @@ fi
args+=(--arg '[% input.name %]' "{ outPath = $inputDir; rev = \"[% input.revision %]\"; }")
[% ELSIF input.type == "string" %]
args+=(--arg '[% input.name %]' '"[% input.value %]"')
args+=(--arg '[% input.name %]' '"[% input.value %]"') # FIXME: escape
[% ELSIF input.type == "boolean" %]
args+=(--arg '[% input.name %]' '[% input.value %]')
[% ELSIF input.type == "nix" %]
args+=(--arg '[% input.name %]' '[% input.value %]') # FIXME: escape
[% ELSE %]
echo "$0: input [% input.name %] has unsupported type [% input.type %]" >&2
exit 1

View file

@ -91,3 +91,20 @@ div.news-item:not(:first-child) {
td.nowrap {
white-space: nowrap;
}
.override-link a {
color: inherit;
}
.actions {
font-weight: bold;
}
.star {
color: black;
font-size: 110%;
}
.star:hover {
cursor: pointer;
}

View file

@ -0,0 +1,52 @@
/* Rotated table headers, borrowed from http://jimmybonney.com/articles/column_header_rotation_css */
.tab-content {
margin-right: 5em;
overflow: visible;
}
td.centered {
text-align: center;
}
.table-header-rotated th.rotate-45{
height: 80px;
width: 40px;
min-width: 40px;
max-width: 40px;
position: relative;
vertical-align: bottom;
padding: 0;
font-size: 100%;
line-height: 0.9;
}
.table-header-rotated th.rotate-45 > div {
position: relative;
top: 0px;
left: 40px; /* 80 * tan(45) / 2 = 40 where 80 is the height on the cell and 45 is the transform angle*/
height: 100%;
-ms-transform:skew(-45deg,0deg);
-moz-transform:skew(-45deg,0deg);
-webkit-transform:skew(-45deg,0deg);
-o-transform:skew(-45deg,0deg);
transform:skew(-45deg,0deg);
overflow: hidden;
border-left: 1px solid #dddddd;
}
.table-header-rotated th.rotate-45 span {
-ms-transform:skew(45deg,0deg) rotate(315deg);
-moz-transform:skew(45deg,0deg) rotate(315deg);
-webkit-transform:skew(45deg,0deg) rotate(315deg);
-o-transform:skew(45deg,0deg) rotate(315deg);
transform:skew(45deg,0deg) rotate(315deg);
position: absolute;
bottom: 30px; /* 40 cos(45) = 28 with an additional 2px margin*/
left: -25px; /*Because it looked good, but there is probably a mathematical link here as well*/
display: inline-block;
// width: 100%;
width: 85px; /* 80 / cos(45) - 40 cos (45) = 85 where 80 is the height of the cell, 40 the width of the cell and 45 the transform angle*/
text-align: left;
// white-space: nowrap; /*whether to display in one line or not*/
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 B

View file

@ -50,18 +50,98 @@ $(document).ready(function() {
$(".hydra-popover").popover({});
/* Ensure that pressing the back button on another page
navigates back to the previously selected tab on this
page. */
$(function() {
if (window.location.hash) {
$(".nav-tabs a[href='" + window.location.hash + "']").tab('show');
}
/* If no tab is active, show the first one. */
$(".nav-tabs").each(function() {
if ($("li.active", this).length > 0) return;
$("a", $(this).children("li:not(.dropdown)").first()).tab('show');
});
/* Ensure that pressing the back button on another page
navigates back to the previously selected tab on this
page. */
$('.nav-tabs').bind('show', function(e) {
var pattern = /#.+/gi;
var id = e.target.toString().match(pattern)[0];
history.replaceState(null, "", id);
});
});
/* Automatically set Bootstrap radio buttons from hidden form controls. */
$('div[data-toggle="buttons-radio"] input[type="hidden"]').map(function(){
$('button[value="' + $(this).val() + '"]', $(this).parent()).addClass('active');
});
/* Automatically update hidden form controls from Bootstrap radio buttons. */
$('div[data-toggle="buttons-radio"] .btn').click(function(){
$('input', $(this).parent()).val($(this).val());
});
$(".star").click(function(event) {
var star = $(this);
var active = star.text() != '★';
requestJSON({
url: star.attr("data-post"),
data: active ? "star=1" : "star=0",
type: 'POST',
success: function(res) {
if (active) {
star.text('★');
} else {
star.text('☆');
}
}
});
});
});
var tabsLoaded = {};
function makeLazyTab(tabName, uri) {
$('.nav-tabs').bind('show', function(e) {
var pattern = /#.+/gi;
var id = e.target.toString().match(pattern)[0];
history.replaceState(null, "", id);
});
$(function() {
if (window.location.hash) {
$(".nav a[href='" + window.location.hash + "']").tab('show');
if (id == '#' + tabName && !tabsLoaded[id]) {
tabsLoaded[id] = 1;
$('#' + tabName).load(uri, function(response, status, xhr) {
if (status == "error") {
$('#' + tabName).html("<div class='alert alert-error'>Error loading tab: " + xhr.status + " " + xhr.statusText + "</div>");
}
});
}
})
});
});
};
function escapeHTML(s) {
return $('<div/>').text(s).html();
};
function requestJSON(args) {
args.dataType = 'json';
args.error = function(data) {
json = {};
try {
if (data.responseText)
json = $.parseJSON(data.responseText);
} catch (err) {
}
if (json.error)
bootbox.alert(escapeHTML(json.error));
else if (data.responseText)
bootbox.alert("Server error: " + escapeHTML(data.responseText));
else
bootbox.alert("Unknown server error!");
};
return $.ajax(args);
};
function redirectJSON(args) {
args.success = function(data) {
window.location = data.redirect;
};
return requestJSON(args);
};

View file

@ -1,19 +1,17 @@
[% BLOCK menuItem %]
<li class="[% IF "${root}${curUri}" == uri %]active[% END %]" [% IF confirmmsg %]onclick="javascript:return confirm('[% confirmmsg %]')"[% END %]>
<a [% HTML.attributes(href => uri) %]>[% title %]</a>
</li>
[% END %]
[% BLOCK makeSubMenu %]
<li class="dropdown">
<a class="dropdown-toggle" href="#" data-toggle="dropdown">[% title %]<b class="caret"></b></a>
<ul id="left-menu" class="dropdown-menu">
<ul class="dropdown-menu">
[% content %]
</ul>
</li>
[% END %]
<ul class="nav pull-left" id="top-menu">
<ul class="nav pull-left">
[% IF c.user_exists %]
[% INCLUDE menuItem uri = c.uri_for(c.controller('User').action_for('dashboard'), [c.user.username]) title = "Dashboard" %]
[% END %]
[% WRAPPER makeSubMenu title="Status" %]
[% INCLUDE menuItem
@ -39,15 +37,7 @@
<li class="divider"></li>
[% 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(c.controller('Project').action_for('jobstatus'), [project.name]) title = "Job status" %]
[% INCLUDE menuItem uri = c.uri_for(c.controller('Project').action_for('errors'), [project.name]) title = "Errors" %]
<li class="divider"></li>
[% INCLUDE menuItem uri = c.uri_for('/project' project.name 'channel' 'latest') title = "Channel" %]
[% IF c.user_exists %]
<li class="divider"></li>
[% INCLUDE menuItem uri = c.uri_for('/project' project.name 'edit') title="Edit" %]
[% INCLUDE menuItem uri = c.uri_for(c.controller('Project').action_for('create_jobset'), [project.name]) title = "Create jobset" %]
[% END %]
[% END %]
[% END %]
@ -64,40 +54,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(c.controller('Jobset').action_for('jobstatus'), [project.name, jobset.name])
title = "Job status" %]
[% INCLUDE menuItem
uri = c.uri_for(c.controller('Jobset').action_for('errors'), [project.name, jobset.name])
title = "Errors" %]
<li class="divider"></li>
[% INCLUDE menuItem uri = c.uri_for('/jobset' project.name jobset.name 'channel' 'latest') title = "Channel" %]
[% IF c.user_exists %]
<li class="divider"></li>
[% INCLUDE menuItem uri = c.uri_for('/jobset' project.name jobset.name 'edit') title="Edit" %]
[% INCLUDE menuItem uri = c.uri_for('/jobset' project.name jobset.name 'clone') title="Clone" %]
<script>
function confirmEvaluateJobset() {
bootbox.confirm(
'Are you sure you want to force evaluation of this jobset?',
function(c) {
if (!c) return;
$.post("[% c.uri_for('/api/push', { jobsets = project.name _ ':' _ jobset.name, force = "1" }) %]")
.done(function(data) {
if (data.error)
bootbox.alert("Unable to schedule the jobset for evaluation: " + data.error);
else
bootbox.alert("The jobset has been scheduled for evaluation.");
})
.fail(function() { bootbox.alert("Server request failed!"); });
});
return;
};
</script>
[% INCLUDE menuItem title="Evaluate" uri = "javascript:confirmEvaluateJobset()" %]
[% END %]
[% END %]
[% END %]
@ -111,54 +68,10 @@
[% INCLUDE menuItem
uri = c.uri_for(c.controller('Job').action_for('all'), [project.name, jobset.name, job.name])
title = "Latest builds" %]
[% INCLUDE menuItem
uri = c.uri_for(c.controller('Job').action_for('jobstatus'), [project.name, jobset.name, job.name])
title = "Job status" %]
[% INCLUDE menuItem
uri = c.uri_for(c.controller('Job').action_for('errors'), [project.name, jobset.name, job.name])
title = "Errors" %]
<li class="divider"></li>
[% INCLUDE menuItem uri = c.uri_for('/job' project.name jobset.name job.name 'channel' 'latest') title = "Channel" %]
[% END %]
[% END %]
[% IF build %]
[% WRAPPER makeSubMenu title="Build" %]
<li class="nav-header">#[% build.id %]</li>
<li class="divider"></li>
[% INCLUDE menuItem
uri = c.uri_for('/build' build.id)
title = "Overview" %]
[% IF c.user_exists %]
<li class="divider"></li>
[% INCLUDE menuItem
uri = c.uri_for('/build' build.id 'clone')
title = "Clone build" %]
[% IF available %]
[% IF build.keep %]
[% INCLUDE menuItem
uri = c.uri_for('/build' build.id 'keep' 0)
title = "Unkeep build" %]
[% ELSE %]
[% INCLUDE menuItem
uri = c.uri_for('/build' build.id 'keep' 1)
title = "Keep build" %]
[% END %]
[% END %]
[% IF build.finished %]
[% INCLUDE menuItem
uri = c.uri_for('/build' build.id 'restart')
title = "Restart build" %]
[% END %]
[% IF !build.finished %]
[% INCLUDE menuItem
uri = c.uri_for('/build' build.id 'cancel')
title = "Cancel build" %]
[% END %]
[% END %]
[% END %]
[% END %]
[% IF c.user_exists && c.check_user_roles('admin') %]
[% WRAPPER makeSubMenu title="Admin" %]
[% IF c.check_user_roles('admin') %]
@ -182,18 +95,23 @@
class = "" %]
[% INCLUDE menuItem
uri = c.uri_for(c.controller('Admin').action_for('clear_queue_non_current'))
title = "Clear all non-running old builds from queue"
title = "Clear scheduled non-current builds from queue"
confirmmsg = "Are you sure you want to clear the queue?"
class = "" %]
[% INCLUDE menuItem
uri = c.uri_for(c.controller('Admin').action_for('clearvcscache'))
title = "Clear VCS caches"
confirmmsg = "Are you sure you want to clear the VCS cache?"
class = "" %]
[% END %]
[% END %]
</ul>
<ul class="nav pull-right" id="top-menu">
<ul class="nav pull-right">
<form class="navbar-search" action="[% c.uri_for('/search') %]">
<input name="query" type="text" class="search-query span2" placeholder="Search" [% HTML.attributes(value => c.req.params.query) %]></input>
<input name="query" type="text" class="search-query span2" placeholder="Search" [% HTML.attributes(value => c.req.params.query) %]/>
</form>
[% IF c.user_exists %]

View file

@ -9,7 +9,7 @@
[% BREAK IF checked %]
[% END %]
[% IF checked %]
SELECTED
selected="selected"
[% END %]
>[% role %]</option>
[% END %]
@ -22,7 +22,7 @@
<div class="control-group">
<label class="control-label">User name</label>
<div class="controls">
<input type="text" class="span3" name="username" [% HTML.attributes(value => username) %]></input>
<input type="text" class="span3" name="username" [% HTML.attributes(value => username) %]/>
</div>
</div>
[% END %]
@ -30,7 +30,7 @@
<div class="control-group">
<label class="control-label">Full name</label>
<div class="controls">
<input type="text" class="span3" name="fullname" [% HTML.attributes(value => fullname) %]></input>
<input type="text" class="span3" name="fullname" [% HTML.attributes(value => fullname) %]/>
</div>
</div>
@ -38,14 +38,14 @@
<div class="control-group">
<label class="control-label">Password</label>
<div class="controls">
<input type="password" class="span3" name="password" value=""></input>
<input type="password" class="span3" name="password" value=""/>
</div>
</div>
<div class="control-group">
<label class="control-label">Confirm password</label>
<div class="controls">
<input type="password" class="span3" name="password2" value=""></input>
<input type="password" class="span3" name="password2" value=""/>
</div>
</div>
[% END %]
@ -54,7 +54,7 @@
<div class="control-group">
<label class="control-label">Email</label>
<div class="controls">
<input type="text" class="span3" name="emailaddress" [% HTML.attributes(value => user.emailaddress) %]></input>
<input type="text" class="span3" name="emailaddress" [% HTML.attributes(value => user.emailaddress) %]/>
</div>
</div>
-->
@ -63,7 +63,7 @@
<div class="control-group">
<div class="controls">
<label class="checkbox">
<input type="checkbox" name="emailonerror" [% IF emailonerror; 'checked="checked"'; END %]></input>Receive evaluation error notifications
<input type="checkbox" name="emailonerror" [% IF emailonerror; 'checked="checked"'; END %]/>Receive evaluation error notifications
</label>
</div>
</div>
@ -73,7 +73,7 @@
<div class="control-group">
<label class="control-label">Roles</label>
<div class="controls">
<select multiple name="roles" class="span3" [% IF !c.check_user_roles('admin') %]disabled="1"[% END %]>
<select multiple="multiple" name="roles" class="span3" [% IF !c.check_user_roles('admin') %]disabled="1"[% END %]>
[% INCLUDE roleoption role="admin" %]
[% INCLUDE roleoption role="create-project" %]
</select>
@ -91,7 +91,7 @@
<div class="control-group">
<label class="control-label">Type the digits shown in the image above</label>
<div class="controls">
<input type="text" class="span3" name="captcha" value=""></input>
<input type="text" class="span3" name="captcha" value=""/>
</div>
</div>
[% END %]
@ -136,8 +136,9 @@
});
</script>
[% END %]
</div>
</p>
</div>
</fieldset>
</form>

View file

@ -27,11 +27,11 @@
<tr>
<td>
[% IF result.status == 0 %]
<img src="/static/images/checkmark_16.png" />
<img src="[% c.uri_for("/static/images/checkmark_16.png") %]" />
[% ELSIF result.status == 1 %]
<img src="/static/images/error_16.png" />
<img src="[% c.uri_for("/static/images/error_16.png") %]" />
[% ELSIF result.status == 2 %]
<img src="/static/images/help_16.png" />
<img src="[% c.uri_for("/static/images/help_16.png") %]" />
[% END %]
</td>
<td><a class="row-link" href="[% c.uri_for('/view' project.name view.name result.id) %]">[% result.id %]</a></td>
@ -48,9 +48,9 @@
[% IF j.build %]
<a href="[% c.uri_for('/build' j.build.id) %]">
[% IF j.build.get_column('buildstatus') == 0 %]
<img src="/static/images/checkmark_16.png" />
<img src="[% c.uri_for("/static/images/checkmark_16.png") %]" />
[% ELSE %]
<img src="/static/images/error_16.png" />
<img src="[% c.uri_for("/static/images/error_16.png") %]" />
[% END %]
</a>
[% END %]

View file

@ -10,6 +10,7 @@ distributable_scripts = \
hydra-queue-runner \
hydra-server \
hydra-update-gc-roots \
hydra-s3-backup-collect-garbage \
nix-prefetch-git \
nix-prefetch-bzr \
nix-prefetch-hg

View file

@ -8,8 +8,10 @@ use Nix::Store;
use Hydra::Plugin;
use Hydra::Schema;
use Hydra::Helper::Nix;
use Hydra::Helper::PluginHooks;
use Hydra::Model::DB;
use Hydra::Helper::AddBuilds;
use Set::Scalar;
STDOUT->autoflush();
@ -40,16 +42,18 @@ sub failDependents {
my ($drvPath, $status, $errorMsg, $dependents) = @_;
# Get the referrer closure of $drvPath.
my @dependentDrvs = computeFSClosure(1, 0, $drvPath);
my $dependentDrvs = Set::Scalar->new(computeFSClosure(1, 0, $drvPath));
my $time = time();
txn_do($db, sub {
my @dependentBuilds = $db->resultset('Builds')->search(
{ drvpath => [ @dependentDrvs ], finished => 0, busy => 0 });
{ finished => 0, busy => 0 },
{ columns => ["id", "project", "jobset", "job", "drvpath", "finished", "busy"] });
for my $d (@dependentBuilds) {
next unless $dependentDrvs->has($d->drvpath);
print STDERR "failing dependent build ", $d->id, " of ", $d->project->name, ":", $d->jobset->name, ":", $d->job->name, "\n";
$d->update(
{ finished => 1
@ -67,8 +71,8 @@ sub failDependents {
, drvpath => $drvPath
, busy => 0
, status => $status
, starttime => time
, stoptime => time
, starttime => $time
, stoptime => $time
, errormsg => $errorMsg
});
addBuildStepOutputs($step);
@ -80,19 +84,6 @@ sub failDependents {
}
sub notify {
my ($build, $dependents) = @_;
foreach my $plugin (@plugins) {
eval {
$plugin->buildFinished($build, $dependents);
};
if ($@) {
print STDERR "$plugin->buildFinished: $@\n";
}
}
}
sub doBuild {
my ($build) = @_;
@ -132,7 +123,9 @@ sub doBuild {
# associated log files, etc.
my $cmd = "nix-store --realise $drvPath " .
"--timeout $timeout " .
"--max-silent-time $maxsilent --keep-going --fallback " .
"--max-silent-time $maxsilent " .
"--option build-max-log-size 67108864 " .
"--keep-going --fallback " .
"--no-build-output --log-type flat --print-build-trace " .
"--add-root " . gcRootFor($outputs{out} // $outputs{(sort keys %outputs)[0]}) . " 2>&1";
@ -149,6 +142,22 @@ sub doBuild {
next;
}
# Hack to handle timeouts, which Nix doesn't report
# properly when they occur remotely. If we get a "hook
# failed" error and $maxsilent seconds have passed since
# the start of the build step, then assume that a timeout
# occured.
if (/^@\s+hook-failed\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)$/ && $3 eq "256") {
my $drvPathStep = $1;
if ($buildSteps{$drvPathStep}) {
my $step = $build->buildsteps->find({stepnr => $buildSteps{$drvPathStep}}) or die;
print STDERR $step->starttime, " ", time(), "\n";
if ($step->starttime + $maxsilent <= time) {
$_ = "@ build-failed $1 $2 timeout $4";
}
}
}
if (/^@\s+build-started\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)$/) {
my $drvPathStep = $1;
txn_do($db, sub {
@ -319,7 +328,7 @@ sub doBuild {
});
notify($build, $dependents);
notifyBuildFinished(\@plugins, $build, $dependents);
}
@ -328,7 +337,7 @@ print STDERR "performing build $buildId\n";
if ($ENV{'HYDRA_MAIL_TEST'}) {
my $build = $db->resultset('Builds')->find($buildId);
notify($build, []);
notifyBuildFinished(\@plugins, $build, []);
exit 0;
}
@ -345,8 +354,8 @@ txn_do($db, sub {
die "build $buildId is already being built";
}
$build->update({busy => 1, locker => $$});
$build->buildsteps->search({busy => 1})->delete_all;
$build->buildproducts->delete_all;
$build->buildsteps->search({busy => 1})->delete;
$build->buildproducts->delete;
});
die unless $build;

View file

@ -2,6 +2,7 @@
use strict;
use feature 'switch';
use utf8;
use Hydra::Schema;
use Hydra::Plugin;
use Hydra::Helper::Nix;
@ -33,7 +34,7 @@ sub fetchInputs {
foreach my $input ($jobset->jobsetinputs->all) {
foreach my $alt ($input->jobsetinputalts->all) {
push @{$$inputInfo{$input->name}}, $_
foreach fetchInput($plugins, $db, $project, $jobset, $input->name, $input->type, $alt->value);
foreach fetchInput($plugins, $db, $project, $jobset, $input->name, $input->type, $alt->value, $input->emailresponsible);
}
}
}
@ -41,12 +42,16 @@ sub fetchInputs {
sub setJobsetError {
my ($jobset, $errorMsg) = @_;
my $prevError = $jobset->errormsg;
eval {
txn_do($db, sub {
$jobset->update({errormsg => $errorMsg, errortime => time});
$jobset->update({ errormsg => $errorMsg, errortime => time, fetcherrormsg => undef });
});
};
sendJobsetErrorNotification($jobset, $errorMsg);
if (defined $errorMsg && $errorMsg ne ($prevError // "")) {
sendJobsetErrorNotification($jobset, $errorMsg);
}
}
@ -65,7 +70,7 @@ sub sendJobsetErrorNotification() {
my $body = "Hi,\n"
. "\n"
. "This is to let you know that Hydra jobset evalation of $projectName:$jobsetName "
. "This is to let you know that Hydra jobset evaluation of $projectName:$jobsetName "
. "resulted in the following error:\n"
. "\n"
. "$errorMsg"
@ -110,7 +115,17 @@ sub checkJobsetWrapped {
# Fetch all values for all inputs.
my $checkoutStart = time;
fetchInputs($project, $jobset, $inputInfo);
eval {
fetchInputs($project, $jobset, $inputInfo);
};
if ($@) {
my $msg = $@;
print STDERR $msg;
txn_do($db, sub {
$jobset->update({ lastcheckedtime => time, fetcherrormsg => $msg });
});
return;
}
my $checkoutStop = time;
# Hash the arguments to hydra-eval-jobs and check the
@ -122,14 +137,14 @@ sub checkJobsetWrapped {
if (defined $prevEval && $prevEval->hash eq $argsHash) {
print STDERR " jobset is unchanged, skipping\n";
txn_do($db, sub {
$jobset->update({lastcheckedtime => time});
$jobset->update({ lastcheckedtime => time, fetcherrormsg => undef });
});
return;
}
# Evaluate the job expression.
my $evalStart = time;
my ($jobs, $nixExprInput) = evalJobs($inputInfo, $exprType, $jobset->nixexprinput, $jobset->nixexprpath);
my ($jobs, $nixExprInput, $msg) = evalJobs($inputInfo, $exprType, $jobset->nixexprinput, $jobset->nixexprpath);
my $evalStop = time;
my $jobOutPathMap = {};
@ -144,11 +159,11 @@ sub checkJobsetWrapped {
$jobset->builds->search({iscurrent => 1})->update({iscurrent => 0});
# Schedule each successfully evaluated job.
my %buildIds;
my %buildMap;
foreach my $job (permute @{$jobs->{job}}) {
next if $job->{jobName} eq "";
print STDERR " considering job " . $project->name, ":", $jobset->name, ":", $job->{jobName} . "\n";
checkBuild($db, $project, $jobset, $inputInfo, $nixExprInput, $job, \%buildIds, $prevEval, $jobOutPathMap);
checkBuild($db, $jobset, $inputInfo, $nixExprInput, $job, \%buildMap, $prevEval, $jobOutPathMap, $plugins);
}
# Update the last checked times and error messages for each
@ -161,23 +176,51 @@ sub checkJobsetWrapped {
$_->update({ errormsg => $failedJobNames{$_->name} ? join '\n', @{$failedJobNames{$_->name}} : undef })
foreach $jobset->jobs->all;
my $hasNewBuilds = 0;
while (my ($id, $new) = each %buildIds) {
$hasNewBuilds = 1 if $new;
}
# Have any builds been added or removed since last time?
my $jobsetChanged =
(scalar(grep { $_->{new} } values(%buildMap)) > 0)
|| (defined $prevEval && $prevEval->jobsetevalmembers->count != scalar(keys %buildMap));
my $ev = $jobset->jobsetevals->create(
{ hash => $argsHash
, timestamp => time
, checkouttime => abs($checkoutStop - $checkoutStart)
, evaltime => abs($evalStop - $evalStart)
, hasnewbuilds => $hasNewBuilds
, nrbuilds => $hasNewBuilds ? scalar(keys %buildIds) : undef
, hasnewbuilds => $jobsetChanged ? 1 : 0
, nrbuilds => $jobsetChanged ? scalar(keys %buildMap) : undef
});
if ($hasNewBuilds) {
while (my ($id, $new) = each %buildIds) {
$ev->jobsetevalmembers->create({ build => $id, isnew => $new });
if ($jobsetChanged) {
# Create JobsetEvalMembers mappings.
while (my ($id, $x) = each %buildMap) {
$ev->jobsetevalmembers->create({ build => $id, isnew => $x->{new} });
}
# Create AggregateConstituents mappings. Since there can
# be jobs that alias each other, if there are multiple
# builds for the same derivation, pick the one with the
# shortest name.
my %drvPathToId;
while (my ($id, $x) = each %buildMap) {
my $y = $drvPathToId{$x->{drvPath}};
if (defined $y) {
next if length $x->{jobName} > length $y->{jobName};
next if length $x->{jobName} == length $y->{jobName} && $x->{jobName} ge $y->{jobName};
}
$drvPathToId{$x->{drvPath}} = $x;
}
foreach my $job (@{$jobs->{job}}) {
next unless $job->{constituents};
my $x = $drvPathToId{$job->{drvPath}} or die;
foreach my $drvPath (split / /, $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";
}
}
}
foreach my $name (keys %{$inputInfo}) {
@ -203,23 +246,16 @@ sub checkJobsetWrapped {
print STDERR " created cached eval ", $ev->id, "\n";
$prevEval->builds->update({iscurrent => 1}) if defined $prevEval;
}
# If this is a one-shot jobset, disable it now.
$jobset->update({ enabled => 0 }) if $jobset->enabled == 2;
});
# Store the error messages for jobs that failed to evaluate.
my $msg = "";
foreach my $error (@{$jobs->{error}}) {
my $bindings = "";
foreach my $arg (@{$error->{arg}}) {
my $input = $inputInfo->{$arg->{name}}->[$arg->{altnr}] or die "invalid input";
$bindings .= ", " if $bindings ne "";
$bindings .= $arg->{name} . " = ";
given ($input->{type}) {
when ("string") { $bindings .= "\"" . $input->{value} . "\""; }
when ("boolean") { $bindings .= $input->{value}; }
default { $bindings .= "..."; }
}
}
$msg .= "at `" . $error->{location} . "' [$bindings]:\n" . $error->{msg} . "\n\n";
$msg .=
($error->{location} ne "" ? "in job $error->{location}" : "at top-level") .
":\n" . $error->{msg} . "\n\n";
}
setJobsetError($jobset, $msg);
}
@ -241,7 +277,7 @@ sub checkJobset {
if ($@) {
my $msg = $@;
print STDERR "error evaluating jobset ", $jobset->name, ": $msg";
print STDERR $msg;
txn_do($db, sub {
$jobset->update({lastcheckedtime => time});
setJobsetError($jobset, $msg);
@ -272,7 +308,7 @@ sub checkSomeJobset {
# longest time (but don't check more often than the jobset's
# minimal check interval).
($jobset) = $db->resultset('Jobsets')->search(
{ 'project.enabled' => 1, 'me.enabled' => 1,
{ 'project.enabled' => 1, 'me.enabled' => { '!=' => 0 },
, 'checkinterval' => { '!=', 0 }
, -or => [ 'lastcheckedtime' => undef, 'lastcheckedtime' => { '<', \ (time() . " - me.checkinterval") } ] },
{ join => 'project', order_by => [ 'lastcheckedtime nulls first' ], rows => 1 })
@ -280,13 +316,10 @@ sub checkSomeJobset {
return 0 unless defined $jobset;
checkJobset($jobset);
return 1;
return system($0, $jobset->project->name, $jobset->name) == 0;
}
# For testing: evaluate a single jobset, then exit.
if (scalar @ARGV == 2) {
my $projectName = $ARGV[0];
my $jobsetName = $ARGV[1];

View file

@ -51,12 +51,12 @@ for (my $n = $schemaVersion; $n < $maxSchemaVersion; $n++) {
my @statements = $sql_splitter->split($schema);
eval {
$dbh->begin_work;
sub run {
sub run_ {
my ($stm) = @_;
print STDERR "executing SQL statement: $stm\n";
$dbh->do($_);
}
run($_) foreach @statements;
run_($_) foreach @statements;
$db->resultset('SchemaVersion')->update({version => $m});
$dbh->commit;
};

View file

@ -9,6 +9,7 @@ use Hydra::Helper::Nix;
use Hydra::Model::DB;
use IO::Handle;
use Nix::Store;
use Set::Scalar;
chdir Hydra::Model::DB::getHydraPath or die;
my $db = Hydra::Model::DB->new();
@ -36,7 +37,7 @@ sub unlockDeadBuilds {
}
if ($unlock) {
print "build ", $build->id, " pid $pid died, unlocking\n";
$build->update({ busy => 0, locker => ""});
$build->update({ busy => 0, locker => "" });
$build->buildsteps->search({ busy => 1 })->update({ busy => 0, status => 4, stoptime => time });
}
}
@ -52,14 +53,25 @@ sub findBuildDependencyInQueue {
my @deps = grep { /\.drv$/ && $_ ne $build->drvpath } computeFSClosure(0, 0, $build->drvpath);
return unless scalar @deps > 0;
foreach my $d (@deps) {
my $b = $buildsByDrv->{$d};
next unless defined $b;
return $db->resultset('Builds')->find($b);
my $bs = $buildsByDrv->{$d};
next unless defined $bs;
return $db->resultset('Builds')->find((@$bs)[0]);
}
return undef;
}
sub blockBuilds {
my ($buildsByDrv, $blockedBuilds, $build) = @_;
my @rdeps = grep { /\.drv$/ && $_ ne $build->drvpath } computeFSClosure(1, 0, $build->drvpath);
foreach my $drv (@rdeps) {
my $bs = $buildsByDrv->{$drv};
next if !defined $bs;
$blockedBuilds->insert($_) foreach @$bs;
}
}
sub checkBuilds {
# print "looking for runnable builds...\n";
@ -70,27 +82,34 @@ sub checkBuilds {
my %maxConcurrent;
foreach my $machineName (keys %{$machines}) {
foreach my $system (${$machines}{$machineName}{'systemTypes'}) {
foreach my $system (@{${$machines}{$machineName}{'systemTypes'}}) {
$maxConcurrent{$system} = (${$machines}{$machineName}{'maxJobs'} or 0) + ($maxConcurrent{$system} or 0)
}
}
txn_do($db, sub {
# Cache scheduled by derivation path to speed up
# Cache scheduled builds by derivation path to speed up
# findBuildDependencyInQueue.
my $buildsByDrv = {};
$buildsByDrv->{$_->drvpath} = $_->id
foreach $db->resultset('Builds')->search({ finished => 0, enabled => 1 }, { join => ['project'] });
push @{$buildsByDrv->{$_->drvpath}}, $_->id
foreach $db->resultset('Builds')->search({ finished => 0 });
# Builds in the queue of which a dependency is already building.
my $blockedBuilds = Set::Scalar->new();
blockBuilds($buildsByDrv, $blockedBuilds, $_)
foreach $db->resultset('Builds')->search({ finished => 0, busy => 1 });
# Get the system types for the runnable builds.
my @systemTypes = $db->resultset('Builds')->search(
{ finished => 0, busy => 0, enabled => 1 },
{ finished => 0, busy => 0 },
{ join => ['project'], select => ['system'], as => ['system'], distinct => 1 });
# Get the total number of scheduling shares.
my $totalShares = getTotalShares($db);
# For each system type, select up to the maximum number of
# concurrent build for that system type. Choose the highest
# priority builds first, then the oldest builds.
# concurrent build for that system type.
foreach my $system (@systemTypes) {
# How many builds are already currently executing for this
# system type?
@ -101,51 +120,111 @@ sub checkBuilds {
my $max = defined $systemTypeInfo ? $systemTypeInfo->maxconcurrent : $maxConcurrent{$system->system} // 2;
my $extraAllowed = $max - $nrActive;
$extraAllowed = 0 if $extraAllowed < 0;
next if $extraAllowed <= 0;
# Select the highest-priority builds to start.
my @builds = $extraAllowed == 0 ? () : $db->resultset('Builds')->search(
{ finished => 0, busy => 0, system => $system->system, enabled => 1 },
{ join => ['project'], order_by => ["priority DESC", "id"] });
print STDERR "starting at most $extraAllowed builds for system ${\$system->system}\n";
my $started = 0;
foreach my $build (@builds) {
# Find a dependency of $build that has no queued
# dependencies itself. This isn't strictly necessary,
# but it ensures that Nix builds are done as part of
# their corresponding Hydra builds, rather than as a
# dependency of some other Hydra build.
while (my $dep = findBuildDependencyInQueue($buildsByDrv, $build)) {
$build = $dep;
my $timeSpentPerJobset;
j: while ($extraAllowed-- > 0) {
my @runnableJobsets = $db->resultset('Builds')->search(
{ finished => 0, busy => 0, system => $system->system },
{ select => ['project', 'jobset'], distinct => 1 });
next if @runnableJobsets == 0;
my $windowSize = 24 * 3600;
my $costPerBuild = 30;
my $totalWindowSize = $windowSize * $max;
my @res;
foreach my $b (@runnableJobsets) {
my $jobset = $db->resultset('Jobsets')->find($b->get_column('project'), $b->get_column('jobset')) or die;
my $timeSpent = $timeSpentPerJobset->{$b->get_column('project')}->{$b->get_column('jobset')};
if (!defined $timeSpent) {
$timeSpent = $jobset->builds->search(
{ },
{ where => \ ("(finished = 0 or (me.stoptime >= " . (time() - $windowSize) . "))")
, join => 'buildsteps'
, select => \ "sum(coalesce(buildsteps.stoptime, ${\time}) - buildsteps.starttime)"
, as => "sum" })->single->get_column("sum") // 0;
# Add a 30s penalty for each started build. This
# is to account for jobsets that have running
# builds but no build steps yet.
$timeSpent += $jobset->builds->search({ finished => 0, busy => 1 })->count * $costPerBuild;
$timeSpentPerJobset->{$b->get_column('project')}->{$b->get_column('jobset')} = $timeSpent;
}
my $share = $jobset->schedulingshares || 1; # prevent division by zero
my $used = $timeSpent / ($totalWindowSize * ($share / $totalShares));
#printf STDERR "%s:%s: %d s, total used = %.2f%%, share used = %.2f%%\n", $jobset->get_column('project'), $jobset->name, $timeSpent, $timeSpent / $totalWindowSize * 100, $used * 100;
push @res, { jobset => $jobset, used => $used };
}
next if $build->busy;
my $logfile = getcwd . "/logs/" . $build->id;
mkdir(dirname $logfile);
unlink($logfile);
$build->update(
{ busy => 1
, locker => $$
, logfile => $logfile
, starttime => time()
});
push @buildsStarted, $build;
foreach my $r (sort { $a->{used} <=> $b->{used} } @res) {
my $jobset = $r->{jobset};
#print STDERR "selected ", $jobset->get_column('project'), ':', $jobset->name, "\n";
last if ++$started >= $extraAllowed;
}
# Select the highest-priority build for this jobset.
my @builds = $jobset->builds->search(
{ finished => 0, busy => 0, system => $system->system },
{ order_by => ["priority DESC", "id"] });
if ($started > 0) {
print STDERR "system type `", $system->system,
"': $nrActive active, $max allowed, started $started builds\n";
foreach my $build (@builds) {
next if $blockedBuilds->has($build->id);
# Find a dependency of $build that has no queued
# dependencies itself. This isn't strictly necessary,
# but it ensures that Nix builds are done as part of
# their corresponding Hydra builds, rather than as a
# dependency of some other Hydra build.
while (my $dep = findBuildDependencyInQueue($buildsByDrv, $build)) {
$build = $dep;
}
next if $build->busy;
printf STDERR "starting build %d (%s:%s:%s) on %s; jobset at %.2f%% of its share\n",
$build->id, $build->project->name, $build->jobset->name, $build->job->name, $build->system, $r->{used} * 100;
my $logfile = getcwd . "/logs/" . $build->id;
mkdir(dirname $logfile);
unlink($logfile);
$build->update(
{ busy => 1
, locker => $$
, logfile => $logfile
});
push @buildsStarted, $build;
$timeSpentPerJobset->{$jobset->get_column('project')}->{$jobset->name} += $costPerBuild;
blockBuilds($buildsByDrv, $blockedBuilds, $build);
next j;
}
}
last; # nothing found, give up on this system type
}
}
$lastTime = time();
$_->update({ starttime => time() }) foreach @buildsStarted;
});
# Actually start the builds we just selected. We need to do this
# outside the transaction in case it aborts or something.
foreach my $build (@buildsStarted) {
my $id = $build->id;
print "starting build $id (", $build->project->name, ":", $build->jobset->name, ':', $build->job->name, ") on ", $build->system, "\n";
eval {
my $logfile = $build->logfile;
my $child = fork();
@ -164,9 +243,7 @@ sub checkBuilds {
if ($@) {
warn $@;
txn_do($db, sub {
$build->busy(0);
$build->locker($$);
$build->update;
$build->update({ busy => 0, locker => $$ });
});
}
}
@ -187,8 +264,6 @@ while (1) {
unlockDeadBuilds;
checkBuilds;
$lastTime = time();
};
warn $@ if $@;

View file

@ -0,0 +1,58 @@
#! /var/run/current-system/sw/bin/perl -w
use strict;
use File::Basename;
use Fcntl;
use IO::File;
use Net::Amazon::S3;
use Net::Amazon::S3::Client;
use Nix::Config;
use Nix::Store;
use Hydra::Model::DB;
use Hydra::Helper::Nix;
my $cfg = getHydraConfig()->{s3backup};
my @config = defined $cfg ? ref $cfg eq "ARRAY" ? @$cfg : ($cfg) : ();
exit 0 unless @config;
my $lockfile = Hydra::Model::DB::getHydraPath . "/.hydra-s3backup.lock";
my $lockhandle = IO::File->new;
open($lockhandle, ">", $lockfile) or die "Opening $lockfile: $!";
flock($lockhandle, Fcntl::LOCK_EX) or die "Write-locking $lockfile: $!";
my $client = Net::Amazon::S3::Client->new( s3 => Net::Amazon::S3->new( retry => 1 ) );
my $db = Hydra::Model::DB->new();
my $gcRootsDir = getGCRootsDir;
opendir DIR, $gcRootsDir or die;
my @roots = readdir DIR;
closedir DIR;
my @actual_roots = ();
foreach my $link (@roots) {
next if $link eq "." || $link eq "..";
push @actual_roots, readlink "$gcRootsDir/$link";
}
# Don't delete a nix-cache-info file, if present
my %closure = ( "nix-cache-info" => undef );
foreach my $path (computeFSClosure(0, 0, @actual_roots)) {
my $hash = substr basename($path), 0, 32;
$closure{"$hash.narinfo"} = undef;
$closure{"$hash.nar"} = undef;
}
foreach my $bucket_config (@config) {
my $bucket = $client->bucket( name => $bucket_config->{name} );
my $prefix = exists $bucket_config->{prefix} ? $bucket_config->{prefix} : "";
my $cache_stream = $bucket->list({ prefix => $prefix });
until ($cache_stream->is_done) {
foreach my $object ($cache_stream->items) {
$object->delete unless exists $closure{basename($object->key)};
}
}
}
1;

View file

@ -56,6 +56,11 @@ my @roots = readdir DIR;
closedir DIR;
# For scheduled builds, we register the derivation as a GC root.
print STDERR "*** looking for scheduled builds\n";
keepBuild $_ foreach $db->resultset('Builds')->search({ finished => 0 }, { columns => [ @columns ] });
# Keep every build in every release of every project.
print STDERR "*** looking for release members\n";
keepBuild $_ foreach $db->resultset('Builds')->search_literal(
@ -84,50 +89,33 @@ foreach my $project ($db->resultset('Projects')->search({}, { order_by => ["name
next;
}
if ($keepnr <= 0 ) {
print STDERR "*** jobset ", $project->name, ":", $jobset->name, " set to keep 0 builds\n";
next;
}
# FIXME: base this on jobset evals?
print STDERR "*** looking for the $keepnr most recent successful builds of each job in jobset ",
print STDERR "*** looking for all builds in the unfinished and $keepnr most recent finished evaluations of jobset ",
$project->name, ":", $jobset->name, "\n";
keepBuild $_ foreach $jobset->builds->search(
{ 'me.id' => { 'in' => \
[ "select b2.id from Builds b2 join " .
" (select distinct job, system, coalesce( " .
" (select id from builds where project = b.project and jobset = b.jobset and job = b.job and system = b.system and finished = 1 and buildStatus = 0 order by id desc offset ? limit 1)" .
" , 0) as nth from builds b where project = ? and jobset = ? and isCurrent = 1) x " .
" on b2.project = ? and b2.jobset = ? and b2.job = x.job and b2.system = x.system and (id >= x.nth) where finished = 1 and buildStatus = 0"
, [ '', $keepnr - 1 ], [ '', $project->name ], [ '', $jobset->name ], [ '', $project->name ], [ '', $jobset->name ] ] }
},
{ order_by => ["job", "system", "id"], columns => [ @columns ] });
}
my @evals;
# Go over all views in this project.
foreach my $view ($project->views->all) {
print STDERR "*** looking for builds to keep in view ", $project->name, ":", $view->name, "\n";
# Get the unfinished evals.
push @evals, $_->get_column("eval") foreach $jobset->builds->search(
{ finished => 0 },
{ join => "jobsetevalmembers", select => "jobsetevalmembers.eval", as => "eval", distinct => 1 });
(my $primaryJob) = $view->viewjobs->search({isprimary => 1});
my $jobs = [$view->viewjobs->all];
# Keep all builds belonging to the most recent successful view result.
my $latest = getLatestSuccessfulViewResult($project, $primaryJob, $jobs, 0);
if (defined $latest) {
print STDERR " keeping latest successful view result ", $latest->id, " (", $latest->get_column('releasename'), ")\n";
my $result = getViewResult($latest, $jobs);
keepBuild $_->{build} foreach @{$result->{jobs}};
# Get the N most recent finished evals.
if ($keepnr) {
push @evals, $_->get_column("id") foreach $jobset->jobsetevals->search(
{ hasNewBuilds => 1 },
{ where => \ "not exists (select 1 from builds b join jobsetevalmembers m on b.id = m.build where m.eval = me.id and b.finished = 0)"
, order_by => "id desc", rows => $keepnr });
}
keepBuild $_ foreach $jobset->builds->search(
{ finished => 1, buildStatus => { -in => [0, 6] }
, id => { -in => $db->resultset('JobsetEvalMembers')->search({ eval => { -in => [@evals] } }, { select => "build" })->as_query }
},
{ order_by => ["job", "id"], columns => [ @columns ] });
}
}
# For scheduled builds, we register the derivation as a GC root.
print STDERR "*** looking for scheduled builds\n";
keepBuild $_ foreach $db->resultset('Builds')->search({ finished => 0 }, { columns => [ @columns ] });
# Remove existing roots that are no longer wanted.
print STDERR "*** removing unneeded GC roots\n";

View file

@ -55,12 +55,14 @@ create table Jobsets (
errorTime integer, -- timestamp associated with errorMsg
lastCheckedTime integer, -- last time the evaluator looked at this jobset
triggerTime integer, -- set if we were triggered by a push event
enabled integer not null default 1,
enabled integer not null default 1, -- 0 = disabled, 1 = enabled, 2 = one-shot
enableEmail integer not null default 1,
hidden integer not null default 0,
emailOverride text not null,
keepnr integer not null default 3,
checkInterval integer not null default 300, -- minimum time in seconds between polls (0 = disable polling)
schedulingShares integer not null default 100,
fetchErrorMsg text,
primary key (project, name),
foreign key (project) references Projects(name) on delete cascade on update cascade
#ifdef SQLITE
@ -74,7 +76,8 @@ create table JobsetInputs (
project text not null,
jobset text not null,
name text not null,
type text not null, -- "svn", "path", "uri", "string", "boolean"
type text not null, -- "svn", "path", "uri", "string", "boolean", "nix"
emailResponsible integer not null default 0, -- whether to email committers to this input who change a build
primary key (project, jobset, name),
foreign key (project, jobset) references Jobsets(project, name) on delete cascade on update cascade
);
@ -140,7 +143,7 @@ create table Builds (
isCurrent integer default 0,
-- Copy of the nixExprInput/nixExprPath fields of the jobset that
-- instantiated this build. Needed if we want to clone this
-- instantiated this build. Needed if we want to reproduce this
-- build.
nixExprInput text,
nixExprPath text,
@ -255,6 +258,7 @@ create table BuildInputs (
uri text,
revision text,
value text,
emailResponsible integer not null default 0,
dependency integer, -- build ID of the input, for type == 'build'
path text,
@ -322,6 +326,15 @@ create table CachedGitInputs (
primary key (uri, branch, revision)
);
create table CachedDarcsInputs (
uri text not null,
revision text not null,
sha256hash text not null,
storePath text not null,
revCount integer not null,
primary key (uri, revision)
);
create table CachedHgInputs (
uri text not null,
branch text not null,
@ -514,6 +527,56 @@ create table NewsItems (
);
create table AggregateConstituents (
aggregate integer not null references Builds(id) on delete cascade,
constituent integer not null references Builds(id) on delete cascade,
primary key (aggregate, constituent)
);
create table StarredJobs (
userName text not null,
project text not null,
jobset text not null,
job text not null,
primary key (userName, project, jobset, job),
foreign key (userName) references Users(userName) on update cascade on delete cascade,
foreign key (project) references Projects(name) on update cascade on delete cascade,
foreign key (project, jobset) references Jobsets(project, name) on update cascade on delete cascade,
foreign key (project, jobset, job) references Jobs(project, jobset, name) on update cascade on delete cascade
);
-- Cache of the number of finished builds.
create table NrBuilds (
what text primary key not null,
count integer not null
);
insert into NrBuilds(what, count) values('finished', 0);
#ifdef POSTGRESQL
create function modifyNrBuildsFinished() returns trigger as $$
begin
if ((tg_op = 'INSERT' and new.finished = 1) or
(tg_op = 'UPDATE' and old.finished = 0 and new.finished = 1)) then
update NrBuilds set count = count + 1 where what = 'finished';
elsif ((tg_op = 'DELETE' and old.finished = 1) or
(tg_op = 'UPDATE' and old.finished = 1 and new.finished = 0)) then
update NrBuilds set count = count - 1 where what = 'finished';
end if;
return null;
end;
$$ language plpgsql;
create trigger NrBuildsFinished after insert or update or delete on Builds
for each row
execute procedure modifyNrBuildsFinished();
#endif
-- Some indices.
create index IndexBuildInputsOnBuild on BuildInputs(build);
@ -534,7 +597,8 @@ create index IndexBuildsOnJobAndSystem on Builds(project, jobset, job, system);
create index IndexBuildsOnJobset on Builds(project, jobset);
create index IndexBuildsOnProject on Builds(project);
create index IndexBuildsOnTimestamp on Builds(timestamp);
create index IndexBuildsOnJobsetFinishedTimestamp on Builds(project, jobset, finished, timestamp DESC);
create index IndexBuildsOnFinishedStopTime on Builds(finished, stoptime DESC);
create index IndexBuildsOnJobsetFinishedTimestamp on Builds(project, jobset, finished, timestamp DESC); -- obsolete?
create index IndexBuildsOnJobFinishedId on builds(project, jobset, job, system, finished, id DESC);
create index IndexBuildsOnJobSystemCurrent on Builds(project, jobset, job, system, isCurrent);
create index IndexBuildsOnDrvPath on Builds(drvPath);

23
src/sql/upgrade-17.sql Normal file
View file

@ -0,0 +1,23 @@
create table NrBuilds (
what text primary key not null,
count integer not null
);
create function modifyNrBuildsFinished() returns trigger as $$
begin
if ((tg_op = 'INSERT' and new.finished = 1) or
(tg_op = 'UPDATE' and old.finished = 0 and new.finished = 1)) then
update NrBuilds set count = count + 1 where what = 'finished';
elsif ((tg_op = 'DELETE' and old.finished = 1) or
(tg_op = 'UPDATE' and old.finished = 1 and new.finished = 0)) then
update NrBuilds set count = count - 1 where what = 'finished';
end if;
return null;
end;
$$ language plpgsql;
create trigger NrBuildsFinished after insert or update or delete on Builds
for each row
execute procedure modifyNrBuildsFinished();
insert into NrBuilds(what, count) select 'finished', count(*) from Builds where finished = 1;

1
src/sql/upgrade-18.sql Normal file
View file

@ -0,0 +1 @@
create index IndexBuildsOnFinishedStopTime on Builds(finished, stoptime DESC);

5
src/sql/upgrade-19.sql Normal file
View file

@ -0,0 +1,5 @@
create table AggregateConstituents (
aggregate integer not null references Builds(id) on delete cascade,
constituent integer not null references Builds(id) on delete cascade,
primary key (aggregate, constituent)
);

8
src/sql/upgrade-20.sql Normal file
View file

@ -0,0 +1,8 @@
create table CachedDarcsInputs (
uri text not null,
revision text not null,
sha256hash text not null,
storePath text not null,
revCount integer not null,
primary key (uri, revision)
);

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