Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions doc/manual/src/webhooks.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,40 @@
# Webhooks

Hydra can be notified by github's webhook to trigger a new evaluation when a
jobset has a github repo in its input.
To set up a github webhook go to `https://github.com/<yourhandle>/<yourrepo>/settings` and in the `Webhooks` tab
## GitHub
Hydra can be notified by GitHub's webhook to trigger a new evaluation when a
jobset has a GitHub repo in its input.

GitHub's webhook can be triggered on [various events](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads). Hydra recognizes the following events.

- [`push`](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#push): triggers a new evaluation for every jobset that have the GitHub repository as a "Git Checkout" input.
- [`create`](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#create) and [`delete`](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#deleta): triggers a new evaluation for every jobset that have the GitHub repository as a "github_refs" input.
- [`pull_request`](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#pull_request): triggers a new evaluation for every jobset that have the GitHub repository as a "githubpulls" input.

### Guide

To set up a GitHub webhook go to `https://github.com/<yourhandle>/<yourrepo>/settings` and in the `Webhooks` tab
click on `Add webhook`.

- In `Payload URL` fill in `https://<your-hydra-domain>/api/push-github`.
- In `Payload URL` fill in `https://<your-hydra-domain>/api/webhook-github`.
- In `Content type` switch to `application/json`.
- The `Secret` field can stay empty.
- For `Which events would you like to trigger this webhook?` keep the default option for events on `Just the push event.`.
- The `Secret` field can stay empty (see below to configure a secret).
- For `Which events would you like to trigger this webhook?` either keep the default option, or select the ones you are interested in (see above for the supported events).

Then add the hook with `Add webhook`.

### Securing GitHub's webhooks
Secrets for webhooks can be configured by adding `github_webhook` keys in your Hydra configuration.
Each `github_webhook` provides a secret (`secret`, a string) for a certain range of repository name (`repo`, a regex) and repository owner (`owner`, a regex).

For instance below we declare one secret, `foo`, for the repositories whose owner is `someone` or `someother` and is named `somerepo`.

**IMPORTANT**: note that secrets should **never** be included directly in your `hydra.conf`, otherwise they will be exposed in plain text in the store. Instead, use includes [as described here](./configuration.html#including-files).

```xml
<github_webhook>
owner = (someone|someother)
repo = somerepo
secret = foo
</github_webhook>
```

80 changes: 68 additions & 12 deletions src/lib/Hydra/Controller/API.pm
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ use Hydra::Helper::CatalystUtils;
use Hydra::Controller::Project;
use JSON::MaybeXS;
use DateTime;
use Digest::SHA qw(sha256_hex);
use Digest::SHA qw(sha256 sha256_hex);
use Digest::HMAC qw(hmac_hex);
use String::Compare::ConstantTime;
use File::Slurper qw(read_text);
use Text::Diff;
use IPC::Run qw(run);
use List::Util 'first';


sub api : Chained('/') PathPart('api') CaptureArgs(0) {
Expand Down Expand Up @@ -267,24 +271,76 @@ sub push : Chained('api') PathPart('push') Args(0) {
);
}

sub push_github : Chained('api') PathPart('push-github') Args(0) {
sub webhook_github : Chained('api') PathPart('webhook-github') Args(0) {
my ($self, $c) = @_;

$c->{stash}->{json}->{jobsetsTriggered} = [];

my $in = $c->request->{data};
my $owner = $in->{repository}->{owner}->{name} or die;
my $repo = $in->{repository}->{name} or die;
print STDERR "got push from GitHub repository $owner/$repo\n";

triggerJobset($self, $c, $_, 0) foreach $c->model('DB::Jobsets')->search(
{ 'project.enabled' => 1, 'me.enabled' => 1 },
{ join => 'project'
, where => \ [ 'me.flake like ? or exists (select 1 from JobsetInputAlts where project = me.project and jobset = me.name and value like ?)', [ 'flake', "%github%$owner/$repo%"], [ 'value', "%github.com%$owner/$repo%" ] ]
});

# every GitHub webhook payload has the `repository` key and a `X-GitHub-Event` header
my $event = $c->req->header('X-GitHub-Event') or die;
my $owner = ( $in->{repository}->{owner}->{name}
// $in->{repository}->{owner}->{login}) or die;
my $repo = $in->{repository}->{name} or die;

print STDERR "got event '$event' from GitHub repository $owner/$repo\n";

{ # Verify X-Hub-Signature-256 if secret was defined in config
my $cfg = $c->config->{github_webhook};
my @config = defined $cfg ? ref $cfg eq "ARRAY" ? @$cfg : ($cfg) : ();
my $rule = first { $owner =~ /^$_->{owner}$/ && $repo =~ /^$_->{repo}$/ } @config;

if (defined $rule) {
my $sig = $c->req->header('X-Hub-Signature-256');
die "X-Hub-Signature-256 is missing, but a secret was defined for GitHub repository $owner/$repo"
unless defined $sig;
my $body = read_text($c->req->body) or die;
my $secret = $rule->{secret} or die;
my $digest = hmac_hex($body, $secret, \&sha256);
die "Request body digest (${digest}) did not match X-Hub-Signature-256 (${sig})"
unless String::Compare::ConstantTime::equals($sig, "sha256=$digest");
} else {
print STDERR "no secret given for webhook comming from GitHub repository $owner/$repo";
}
}

# `jobsetsOfInputs type value` finds the jobsets that have an input of type `type` and of value LIKE `value`
my $jobsetsOfInputs = sub {
my ($type, $value) = @_;
$c->model('DB::Jobsets')->search(
{ 'jobsetinputs.type' => $type, 'project.enabled' => 1, 'me.enabled' => 1 },
{ join => ['project', {'jobsetinputs' => 'jobsetinputalts'}],
where => \ [ ' LOWER( jobsetinputalts.value ) LIKE LOWER ( ? ) ', [ 'value', $value] ]
})
};

# Different SQL queries according the kind of `$event` we are dealing with
my $actions = {
create => sub {$jobsetsOfInputs->('github_refs', "$owner $repo %")},
delete => sub {$jobsetsOfInputs->('github_refs', "$owner $repo %")},
pull_request => sub {$jobsetsOfInputs->('githubpulls', "$owner $repo")},
push => sub {
$c->model('DB::Jobsets')->search(
{ 'project.enabled' => 1, 'me.enabled' => 1 },
{ join => 'project', where => \ [
'me.flake like ? or exists (select 1 from JobsetInputAlts where project = me.project and jobset = me.name and value like ?)',
[ 'flake', "%github%$owner/$repo%"],
[ 'value', "%github.com%$owner/$repo%" ]
] })
}
};

triggerJobset($self, $c, $_, 0) foreach ($actions->{$event} // sub {
die "Cannot handle GitHub event [$event]";
})->();

$c->response->body("");
}


sub push_github : Chained('api') PathPart('push-github') Args(0) {
print STDERR 'The endpoint [/api/push_github] is deprecated in favor of [/api/webhook_github]';
webhook_github @_
}

1;
5 changes: 4 additions & 1 deletion src/lib/Hydra/Controller/Root.pm
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ sub noLoginNeeded {

return $whitelisted ||
$c->request->path eq "api/push-github" ||
$c->request->path eq "api/webhook-github" ||
$c->request->path eq "google-login" ||
$c->request->path eq "github-redirect" ||
$c->request->path eq "github-login" ||
Expand Down Expand Up @@ -77,7 +78,9 @@ sub begin :Private {
$_->supportedInputTypes($c->stash->{inputTypes}) foreach @{$c->hydra_plugins};

# XSRF protection: require POST requests to have the same origin.
if ($c->req->method eq "POST" && $c->req->path ne "api/push-github") {
if ($c->req->method eq "POST" && (
$c->req->path ne "api/push-github" && $c->req->path ne "api/webhook-github"
)) {
my $referer = $c->req->header('Referer');
$referer //= $c->req->header('Origin');
my $base = $c->req->base;
Expand Down
2 changes: 2 additions & 0 deletions t/Hydra/Controller/API/checks.t
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ subtest "/api/push-github" => sub {

my $req = POST '/api/push-github',
"Content-Type" => "application/json",
"X-GitHub-Event" => "push",
"Content" => encode_json({
repository => {
owner => {
Expand All @@ -195,6 +196,7 @@ subtest "/api/push-github" => sub {

my $req = POST '/api/push-github',
"Content-Type" => "application/json",
"X-GitHub-Event" => "push",
"Content" => encode_json({
repository => {
owner => {
Expand Down