forked from GithubBackups/healthchecks
Webhooks support POST, cleanup.
This commit is contained in:
parent
891efc720a
commit
c16eeda004
@ -269,9 +269,6 @@ class Channel(models.Model):
|
|||||||
|
|
||||||
return error
|
return error
|
||||||
|
|
||||||
def test(self):
|
|
||||||
return self.transport().test()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def po_value(self):
|
def po_value(self):
|
||||||
assert self.kind == "po"
|
assert self.kind == "po"
|
||||||
@ -289,7 +286,13 @@ class Channel(models.Model):
|
|||||||
def value_up(self):
|
def value_up(self):
|
||||||
assert self.kind == "webhook"
|
assert self.kind == "webhook"
|
||||||
parts = self.value.split("\n")
|
parts = self.value.split("\n")
|
||||||
return parts[1] if len(parts) == 2 else ""
|
return parts[1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def post_data(self):
|
||||||
|
assert self.kind == "webhook"
|
||||||
|
parts = self.value.split("\n")
|
||||||
|
return parts[2] if len(parts) > 2 else ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def slack_team(self):
|
def slack_team(self):
|
||||||
@ -321,18 +324,12 @@ class Channel(models.Model):
|
|||||||
@property
|
@property
|
||||||
def discord_webhook_url(self):
|
def discord_webhook_url(self):
|
||||||
assert self.kind == "discord"
|
assert self.kind == "discord"
|
||||||
if not self.value.startswith("{"):
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
doc = json.loads(self.value)
|
doc = json.loads(self.value)
|
||||||
return doc["webhook"]["url"]
|
return doc["webhook"]["url"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def discord_webhook_id(self):
|
def discord_webhook_id(self):
|
||||||
assert self.kind == "discord"
|
assert self.kind == "discord"
|
||||||
if not self.value.startswith("{"):
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
doc = json.loads(self.value)
|
doc = json.loads(self.value)
|
||||||
return doc["webhook"]["id"]
|
return doc["webhook"]["id"]
|
||||||
|
|
||||||
|
@ -80,8 +80,25 @@ class NotifyTestCase(BaseTestCase):
|
|||||||
url = u"http://host/%s/down/foo/bar/?name=Hello%%20World" \
|
url = u"http://host/%s/down/foo/bar/?name=Hello%%20World" \
|
||||||
% self.check.code
|
% self.check.code
|
||||||
|
|
||||||
mock_get.assert_called_with(
|
args, kwargs = mock_get.call_args
|
||||||
"get", url, headers={"User-Agent": "healthchecks.io"}, timeout=5)
|
self.assertEqual(args[0], "get")
|
||||||
|
self.assertEqual(args[1], url)
|
||||||
|
self.assertEqual(kwargs["headers"], {"User-Agent": "healthchecks.io"})
|
||||||
|
self.assertEqual(kwargs["timeout"], 5)
|
||||||
|
|
||||||
|
@patch("hc.api.transports.requests.request")
|
||||||
|
def test_webhooks_support_post(self, mock_request):
|
||||||
|
template = "http://example.com\n\nThe Time Is $NOW"
|
||||||
|
self._setup_data("webhook", template)
|
||||||
|
self.check.save()
|
||||||
|
|
||||||
|
self.channel.notify(self.check)
|
||||||
|
args, kwargs = mock_request.call_args
|
||||||
|
self.assertEqual(args[0], "post")
|
||||||
|
self.assertEqual(args[1], "http://example.com")
|
||||||
|
|
||||||
|
# spaces should not have been urlencoded:
|
||||||
|
self.assertTrue(kwargs["data"].startswith("The Time Is 2"))
|
||||||
|
|
||||||
@patch("hc.api.transports.requests.request")
|
@patch("hc.api.transports.requests.request")
|
||||||
def test_webhooks_dollarsign_escaping(self, mock_get):
|
def test_webhooks_dollarsign_escaping(self, mock_get):
|
||||||
@ -267,3 +284,15 @@ class NotifyTestCase(BaseTestCase):
|
|||||||
attachment = payload["attachments"][0]
|
attachment = payload["attachments"][0]
|
||||||
fields = {f["title"]: f["value"] for f in attachment["fields"]}
|
fields = {f["title"]: f["value"] for f in attachment["fields"]}
|
||||||
self.assertEqual(fields["Last Ping"], "Never")
|
self.assertEqual(fields["Last Ping"], "Never")
|
||||||
|
|
||||||
|
@patch("hc.api.transports.requests.request")
|
||||||
|
def test_pushbullet(self, mock_post):
|
||||||
|
self._setup_data("pushbullet", "fake-token")
|
||||||
|
mock_post.return_value.status_code = 200
|
||||||
|
|
||||||
|
self.channel.notify(self.check)
|
||||||
|
assert Notification.objects.count() == 1
|
||||||
|
|
||||||
|
_, kwargs = mock_post.call_args
|
||||||
|
self.assertEqual(kwargs["json"]["type"], "note")
|
||||||
|
self.assertEqual(kwargs["headers"]["Access-Token"], "fake-token")
|
||||||
|
@ -83,14 +83,43 @@ class HttpTransport(Transport):
|
|||||||
def get(self, url):
|
def get(self, url):
|
||||||
return self.request("get", url)
|
return self.request("get", url)
|
||||||
|
|
||||||
def post(self, url, json, **kwargs):
|
def post(self, url, **kwargs):
|
||||||
return self.request("post", url, json=json, **kwargs)
|
return self.request("post", url, **kwargs)
|
||||||
|
|
||||||
def post_form(self, url, data):
|
|
||||||
return self.request("post", url, data=data)
|
|
||||||
|
|
||||||
|
|
||||||
class Webhook(HttpTransport):
|
class Webhook(HttpTransport):
|
||||||
|
def prepare(self, template, check, urlencode=False):
|
||||||
|
""" Replace variables with actual values.
|
||||||
|
|
||||||
|
There should be no bad translations if users use $ symbol in
|
||||||
|
check's name or tags, because $ gets urlencoded to %24
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def safe(s):
|
||||||
|
return quote(s) if urlencode else s
|
||||||
|
|
||||||
|
result = template
|
||||||
|
if "$CODE" in result:
|
||||||
|
result = result.replace("$CODE", str(check.code))
|
||||||
|
|
||||||
|
if "$STATUS" in result:
|
||||||
|
result = result.replace("$STATUS", check.status)
|
||||||
|
|
||||||
|
if "$NOW" in result:
|
||||||
|
s = timezone.now().replace(microsecond=0).isoformat()
|
||||||
|
result = result.replace("$NOW", safe(s))
|
||||||
|
|
||||||
|
if "$NAME" in result:
|
||||||
|
result = result.replace("$NAME", safe(check.name))
|
||||||
|
|
||||||
|
if "$TAG" in result:
|
||||||
|
for i, tag in enumerate(check.tags_list()):
|
||||||
|
placeholder = "$TAG%d" % (i + 1)
|
||||||
|
result = result.replace(placeholder, safe(tag))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def notify(self, check):
|
def notify(self, check):
|
||||||
url = self.channel.value_down
|
url = self.channel.value_down
|
||||||
if check.status == "up":
|
if check.status == "up":
|
||||||
@ -100,35 +129,19 @@ class Webhook(HttpTransport):
|
|||||||
# If the URL is empty then we do nothing
|
# If the URL is empty then we do nothing
|
||||||
return "no-op"
|
return "no-op"
|
||||||
|
|
||||||
# Replace variables with actual values.
|
url = self.prepare(url, check, urlencode=True)
|
||||||
# There should be no bad translations if users use $ symbol in
|
if self.channel.post_data:
|
||||||
# check's name or tags, because $ gets urlencoded to %24
|
payload = self.prepare(self.channel.post_data, check)
|
||||||
|
return self.post(url, data=payload)
|
||||||
if "$CODE" in url:
|
else:
|
||||||
url = url.replace("$CODE", str(check.code))
|
return self.get(url)
|
||||||
|
|
||||||
if "$STATUS" in url:
|
|
||||||
url = url.replace("$STATUS", check.status)
|
|
||||||
|
|
||||||
if "$NAME" in url:
|
|
||||||
url = url.replace("$NAME", quote(check.name))
|
|
||||||
|
|
||||||
if "$TAG" in url:
|
|
||||||
for i, tag in enumerate(check.tags_list()):
|
|
||||||
placeholder = "$TAG%d" % (i + 1)
|
|
||||||
url = url.replace(placeholder, quote(tag))
|
|
||||||
|
|
||||||
return self.get(url)
|
|
||||||
|
|
||||||
def test(self):
|
|
||||||
return self.get(self.channel.value)
|
|
||||||
|
|
||||||
|
|
||||||
class Slack(HttpTransport):
|
class Slack(HttpTransport):
|
||||||
def notify(self, check):
|
def notify(self, check):
|
||||||
text = tmpl("slack_message.json", check=check)
|
text = tmpl("slack_message.json", check=check)
|
||||||
payload = json.loads(text)
|
payload = json.loads(text)
|
||||||
return self.post(self.channel.slack_webhook_url, payload)
|
return self.post(self.channel.slack_webhook_url, json=payload)
|
||||||
|
|
||||||
|
|
||||||
class HipChat(HttpTransport):
|
class HipChat(HttpTransport):
|
||||||
@ -138,7 +151,7 @@ class HipChat(HttpTransport):
|
|||||||
"message": text,
|
"message": text,
|
||||||
"color": "green" if check.status == "up" else "red",
|
"color": "green" if check.status == "up" else "red",
|
||||||
}
|
}
|
||||||
return self.post(self.channel.value, payload)
|
return self.post(self.channel.value, json=payload)
|
||||||
|
|
||||||
|
|
||||||
class OpsGenie(HttpTransport):
|
class OpsGenie(HttpTransport):
|
||||||
@ -159,7 +172,7 @@ class OpsGenie(HttpTransport):
|
|||||||
if check.status == "up":
|
if check.status == "up":
|
||||||
url += "/close"
|
url += "/close"
|
||||||
|
|
||||||
return self.post(url, payload)
|
return self.post(url, json=payload)
|
||||||
|
|
||||||
|
|
||||||
class PagerDuty(HttpTransport):
|
class PagerDuty(HttpTransport):
|
||||||
@ -176,7 +189,7 @@ class PagerDuty(HttpTransport):
|
|||||||
"client_url": settings.SITE_ROOT
|
"client_url": settings.SITE_ROOT
|
||||||
}
|
}
|
||||||
|
|
||||||
return self.post(self.URL, payload)
|
return self.post(self.URL, json=payload)
|
||||||
|
|
||||||
|
|
||||||
class Pushbullet(HttpTransport):
|
class Pushbullet(HttpTransport):
|
||||||
@ -193,7 +206,7 @@ class Pushbullet(HttpTransport):
|
|||||||
"body": text
|
"body": text
|
||||||
}
|
}
|
||||||
|
|
||||||
return self.post(url, payload, headers=headers)
|
return self.post(url, json=payload, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
class Pushover(HttpTransport):
|
class Pushover(HttpTransport):
|
||||||
@ -222,7 +235,7 @@ class Pushover(HttpTransport):
|
|||||||
payload["retry"] = settings.PUSHOVER_EMERGENCY_RETRY_DELAY
|
payload["retry"] = settings.PUSHOVER_EMERGENCY_RETRY_DELAY
|
||||||
payload["expire"] = settings.PUSHOVER_EMERGENCY_EXPIRATION
|
payload["expire"] = settings.PUSHOVER_EMERGENCY_EXPIRATION
|
||||||
|
|
||||||
return self.post_form(self.URL, payload)
|
return self.post(self.URL, data=payload)
|
||||||
|
|
||||||
|
|
||||||
class VictorOps(HttpTransport):
|
class VictorOps(HttpTransport):
|
||||||
@ -236,11 +249,12 @@ class VictorOps(HttpTransport):
|
|||||||
"monitoring_tool": "healthchecks.io",
|
"monitoring_tool": "healthchecks.io",
|
||||||
}
|
}
|
||||||
|
|
||||||
return self.post(self.channel.value, payload)
|
return self.post(self.channel.value, json=payload)
|
||||||
|
|
||||||
|
|
||||||
class Discord(HttpTransport):
|
class Discord(HttpTransport):
|
||||||
def notify(self, check):
|
def notify(self, check):
|
||||||
text = tmpl("slack_message.json", check=check)
|
text = tmpl("slack_message.json", check=check)
|
||||||
payload = json.loads(text)
|
payload = json.loads(text)
|
||||||
return self.post(self.channel.discord_webhook_url + "/slack", payload)
|
url = self.channel.discord_webhook_url + "/slack"
|
||||||
|
return self.post(url, json=payload)
|
||||||
|
@ -60,5 +60,8 @@ class AddWebhookForm(forms.Form):
|
|||||||
value_up = forms.URLField(max_length=1000, required=False,
|
value_up = forms.URLField(max_length=1000, required=False,
|
||||||
validators=[WebhookValidator()])
|
validators=[WebhookValidator()])
|
||||||
|
|
||||||
|
post_data = forms.CharField(max_length=1000, required=False)
|
||||||
|
|
||||||
def get_value(self):
|
def get_value(self):
|
||||||
return "{value_down}\n{value_up}".format(**self.cleaned_data)
|
d = self.cleaned_data
|
||||||
|
return "\n".join((d["value_down"], d["value_up"], d["post_data"]))
|
||||||
|
@ -8,7 +8,7 @@ class AddWebhookTestCase(BaseTestCase):
|
|||||||
def test_instructions_work(self):
|
def test_instructions_work(self):
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
r = self.client.get(self.url)
|
r = self.client.get(self.url)
|
||||||
self.assertContains(r, "Webhooks are a simple way")
|
self.assertContains(r, "Runs a HTTP GET or HTTP POST")
|
||||||
|
|
||||||
def test_it_adds_two_webhook_urls_and_redirects(self):
|
def test_it_adds_two_webhook_urls_and_redirects(self):
|
||||||
form = {"value_down": "http://foo.com", "value_up": "https://bar.com"}
|
form = {"value_down": "http://foo.com", "value_up": "https://bar.com"}
|
||||||
@ -18,7 +18,7 @@ class AddWebhookTestCase(BaseTestCase):
|
|||||||
self.assertRedirects(r, "/integrations/")
|
self.assertRedirects(r, "/integrations/")
|
||||||
|
|
||||||
c = Channel.objects.get()
|
c = Channel.objects.get()
|
||||||
self.assertEqual(c.value, "http://foo.com\nhttps://bar.com")
|
self.assertEqual(c.value, "http://foo.com\nhttps://bar.com\n")
|
||||||
|
|
||||||
def test_it_adds_webhook_using_team_access(self):
|
def test_it_adds_webhook_using_team_access(self):
|
||||||
form = {"value_down": "http://foo.com", "value_up": "https://bar.com"}
|
form = {"value_down": "http://foo.com", "value_up": "https://bar.com"}
|
||||||
@ -30,7 +30,7 @@ class AddWebhookTestCase(BaseTestCase):
|
|||||||
|
|
||||||
c = Channel.objects.get()
|
c = Channel.objects.get()
|
||||||
self.assertEqual(c.user, self.alice)
|
self.assertEqual(c.user, self.alice)
|
||||||
self.assertEqual(c.value, "http://foo.com\nhttps://bar.com")
|
self.assertEqual(c.value, "http://foo.com\nhttps://bar.com\n")
|
||||||
|
|
||||||
def test_it_rejects_bad_urls(self):
|
def test_it_rejects_bad_urls(self):
|
||||||
urls = [
|
urls = [
|
||||||
@ -59,4 +59,14 @@ class AddWebhookTestCase(BaseTestCase):
|
|||||||
self.client.post(self.url, form)
|
self.client.post(self.url, form)
|
||||||
|
|
||||||
c = Channel.objects.get()
|
c = Channel.objects.get()
|
||||||
self.assertEqual(c.value, "\nhttp://foo.com")
|
self.assertEqual(c.value, "\nhttp://foo.com\n")
|
||||||
|
|
||||||
|
def test_it_adds_post_data(self):
|
||||||
|
form = {"value_down": "http://foo.com", "post_data": "hello"}
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, form)
|
||||||
|
self.assertRedirects(r, "/integrations/")
|
||||||
|
|
||||||
|
c = Channel.objects.get()
|
||||||
|
self.assertEqual(c.value, "http://foo.com\n\nhello")
|
||||||
|
@ -22,3 +22,28 @@ class ChannelsTestCase(BaseTestCase):
|
|||||||
r = self.client.get("/integrations/")
|
r = self.client.get("/integrations/")
|
||||||
self.assertContains(r, "foo-team", status_code=200)
|
self.assertContains(r, "foo-team", status_code=200)
|
||||||
self.assertContains(r, "#bar")
|
self.assertContains(r, "#bar")
|
||||||
|
|
||||||
|
def test_it_shows_webhook_post_data(self):
|
||||||
|
ch = Channel(kind="webhook", user=self.alice)
|
||||||
|
ch.value = "http://down.example.com\nhttp://up.example.com\nfoobar"
|
||||||
|
ch.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.get("/integrations/")
|
||||||
|
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertContains(r, "<td>http://down.example.com</td>")
|
||||||
|
self.assertContains(r, "<td>http://up.example.com</td>")
|
||||||
|
self.assertContains(r, "<td>foobar</td>")
|
||||||
|
|
||||||
|
def test_it_shows_pushover_details(self):
|
||||||
|
ch = Channel(kind="po", user=self.alice)
|
||||||
|
ch.value = "fake-key|0"
|
||||||
|
ch.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.get("/integrations/")
|
||||||
|
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertContains(r, "fake-key")
|
||||||
|
self.assertContains(r, "(normal priority)")
|
||||||
|
@ -410,7 +410,11 @@ def add_webhook(request):
|
|||||||
else:
|
else:
|
||||||
form = AddWebhookForm()
|
form = AddWebhookForm()
|
||||||
|
|
||||||
ctx = {"page": "channels", "form": form}
|
ctx = {
|
||||||
|
"page": "channels",
|
||||||
|
"form": form,
|
||||||
|
"now": timezone.now().replace(microsecond=0).isoformat()
|
||||||
|
}
|
||||||
return render(request, "integrations/add_webhook.html", ctx)
|
return render(request, "integrations/add_webhook.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@ -81,6 +81,12 @@
|
|||||||
<td>{{ ch.value_up }}</td>
|
<td>{{ ch.value_up }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if ch.post_data %}
|
||||||
|
<tr>
|
||||||
|
<td class="preposition">body </td>
|
||||||
|
<td>{{ ch.post_data }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
{% elif ch.kind == "pushbullet" %}
|
{% elif ch.kind == "pushbullet" %}
|
||||||
<span class="preposition">API key</span>
|
<span class="preposition">API key</span>
|
||||||
|
@ -9,9 +9,10 @@
|
|||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<h1>Webhook</h1>
|
<h1>Webhook</h1>
|
||||||
|
|
||||||
<p>Webhooks are a simple way to notify an external system when a check
|
<p>Runs a HTTP GET or HTTP POST to your specified URL when a check
|
||||||
goes up or down. healthcheks.io will run a normal HTTP GET call to your
|
goes up or down. Uses GET by default, and uses POST if you specify
|
||||||
specified URL.</p>
|
any POST data.</p>
|
||||||
|
|
||||||
<p>You can use the following variables in webhook URLs:</p>
|
<p>You can use the following variables in webhook URLs:</p>
|
||||||
<table class="table webhook-variables">
|
<table class="table webhook-variables">
|
||||||
<tr>
|
<tr>
|
||||||
@ -24,7 +25,14 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><code>$NAME</code></th>
|
<th><code>$NAME</code></th>
|
||||||
<td>Urlencoded name of the check</td>
|
<td>Name of the check</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><code>$NOW</code></th>
|
||||||
|
<td>
|
||||||
|
Current UTC time in ISO8601 format.
|
||||||
|
Example: "{{ now }}"
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><code>$STATUS</code></th>
|
<th><code>$STATUS</code></th>
|
||||||
@ -32,7 +40,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><code>$TAG1, $TAG2, …</code></th>
|
<th><code>$TAG1, $TAG2, …</code></th>
|
||||||
<td>Urlencoded value of the first tag, the second tag, …</td>
|
<td>Value of the first tag, the second tag, …</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@ -81,6 +89,22 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group {{ form.post_data.css_classes }}">
|
||||||
|
<label class="col-sm-2 control-label">POST data</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
name="post_data"
|
||||||
|
placeholder='{"status": "$STATUS"}'
|
||||||
|
value="{{ form.post_data.value|default:"" }}">
|
||||||
|
{% if form.post_data.errors %}
|
||||||
|
<div class="help-block">
|
||||||
|
{{ form.post_data.errors|join:"" }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-offset-2 col-sm-10">
|
<div class="col-sm-offset-2 col-sm-10">
|
||||||
<button type="submit" class="btn btn-primary">Save Integration</button>
|
<button type="submit" class="btn btn-primary">Save Integration</button>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user