diff --git a/CHANGELOG.md b/CHANGELOG.md index d9a20912..18787787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. - Improve the handling of unknown email addresses in the Sign In form - Add support for "... is UP" SMS notifications - Add an option for weekly reports (in addition to monthly) +- Implement PagerDuty Simple Install Flow ## v1.20.0 - 2020-04-22 diff --git a/README.md b/README.md index d4be1714..23624333 100644 --- a/README.md +++ b/README.md @@ -371,6 +371,16 @@ MATRIX_USER_ID=@mychecks:matrix.org MATRIX_ACCESS_TOKEN=[a long string of characters returned by the login call] ``` +### PagerDuty Simple Install Flow + +To enable PagerDuty [Simple Install Flow](https://developer.pagerduty.com/docs/app-integration-development/events-integration/), + +* Register a PagerDuty app at [PagerDuty](https://pagerduty.com/) › Developer Mode › My Apps +* In the newly created app, add the "Events Integration" functionality +* Specify a Redirect URL: `https://your-domain.com/integrations/add_pagerduty/` +* Copy the displayed app_id value (PXXXXX) and put it in the `PD_APP_ID` environment + variable + ## Running in Production Here is a non-exhaustive list of pointers and things to check before launching a Healthchecks instance diff --git a/docker/.env b/docker/.env index 4b39c3a2..413ce21a 100644 --- a/docker/.env +++ b/docker/.env @@ -29,6 +29,7 @@ MATTERMOST_ENABLED=True MSTEAMS_ENABLED=True OPSGENIE_ENABLED=True PAGERTREE_ENABLED=True +PD_APP_ID= PD_ENABLED=True PD_VENDOR_KEY= PING_BODY_LIMIT=10000 diff --git a/hc/api/models.py b/hc/api/models.py index a20674dd..7bc49db6 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -650,12 +650,19 @@ class Channel(models.Model): doc = json.loads(self.value) return doc["service_key"] + @property + def pd_service_name(self): + assert self.kind == "pd" + if self.value.startswith("{"): + doc = json.loads(self.value) + return doc.get("name") + @property def pd_account(self): assert self.kind == "pd" if self.value.startswith("{"): doc = json.loads(self.value) - return doc["account"] + return doc.get("account") def latest_notification(self): return Notification.objects.filter(channel=self).latest() diff --git a/hc/front/tests/test_add_pagerduty_complete.py b/hc/front/tests/test_add_pagerduty_complete.py new file mode 100644 index 00000000..d66ace16 --- /dev/null +++ b/hc/front/tests/test_add_pagerduty_complete.py @@ -0,0 +1,63 @@ +import json + +from django.test.utils import override_settings +from hc.api.models import Channel +from hc.test import BaseTestCase +from urllib.parse import urlencode + + +@override_settings(PD_APP_ID="FOOBAR") +class AddPagerDutyCompleteTestCase(BaseTestCase): + def setUp(self): + super().setUp() + + session = self.client.session + session["pagerduty"] = ("ABC", str(self.project.code)) + session.save() + + def _url(self, state="ABC"): + config = { + "account": {"name": "Foo"}, + "integration_keys": [{"integration_key": "foo", "name": "bar"}], + } + + url = "/integrations/add_pagerduty/?" + url += urlencode({"state": state, "config": json.dumps(config)}) + return url + + def test_it_adds_channel(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self._url()) + self.assertRedirects(r, self.channels_url) + + channel = Channel.objects.get() + self.assertEqual(channel.kind, "pd") + self.assertEqual(channel.pd_service_key, "foo") + self.assertEqual(channel.pd_account, "Foo") + + def test_it_validates_state(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self._url(state="XYZ")) + self.assertEqual(r.status_code, 403) + + @override_settings(PD_APP_ID=None) + def test_it_requires_app_id(self): + self.client.login(username="alice@example.org", password="password") + + r = self.client.get(self._url()) + self.assertEqual(r.status_code, 404) + + @override_settings(PD_ENABLED=False) + def test_it_requires_pd_enabled(self): + self.client.login(username="alice@example.org", password="password") + + r = self.client.get(self._url()) + self.assertEqual(r.status_code, 404) + + def test_it_requires_rw_access(self): + self.bobs_membership.rw = False + self.bobs_membership.save() + + self.client.login(username="bob@example.org", password="password") + r = self.client.get(self._url()) + self.assertEqual(r.status_code, 403) diff --git a/hc/front/tests/test_add_pd.py b/hc/front/tests/test_add_pd.py index fda6388d..c3f8ccea 100644 --- a/hc/front/tests/test_add_pd.py +++ b/hc/front/tests/test_add_pd.py @@ -3,6 +3,7 @@ from hc.api.models import Channel from hc.test import BaseTestCase +@override_settings(PD_APP_ID=None) class AddPdTestCase(BaseTestCase): def setUp(self): super().setUp() @@ -47,3 +48,10 @@ class AddPdTestCase(BaseTestCase): self.client.login(username="alice@example.org", password="password") r = self.client.get(self.url) self.assertEqual(r.status_code, 404) + + @override_settings(PD_APP_ID="FOOBAR") + def test_it_handles_pd_app_id(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertContains(r, "app_id=FOOBAR") + self.assertIn("pagerduty", self.client.session) diff --git a/hc/front/urls.py b/hc/front/urls.py index 32db7814..be827b9e 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -27,6 +27,7 @@ channel_urls = [ path("add_pushbullet/", views.add_pushbullet_complete), path("add_discord/", views.add_discord_complete), path("add_linenotify/", views.add_linenotify_complete), + path("add_pagerduty/", views.add_pd_complete, name="hc-add-pd-complete"), path("add_pushover/", views.pushover_help, name="hc-pushover-help"), path("telegram/", views.telegram_help, name="hc-telegram-help"), path("telegram/bot/", views.telegram_bot, name="hc-telegram-webhook"), diff --git a/hc/front/views.py b/hc/front/views.py index 40c9808c..76b0da23 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -1089,6 +1089,21 @@ def add_shell(request, code): def add_pd(request, code): project = _get_rw_project_for_user(request, code) + # Simple Install Flow + if settings.PD_APP_ID: + state = token_urlsafe() + + redirect_url = settings.SITE_ROOT + reverse("hc-add-pd-complete") + redirect_url += "?" + urlencode({"state": state}) + + install_url = "https://app.pagerduty.com/install/integration?" + urlencode( + {"app_id": settings.PD_APP_ID, "redirect_url": redirect_url, "version": "2"} + ) + + ctx = {"page": "channels", "project": project, "install_url": install_url} + request.session["pagerduty"] = (state, str(project.code)) + return render(request, "integrations/add_pd_simple.html", ctx) + if request.method == "POST": form = forms.AddPdForm(request.POST) if form.is_valid(): @@ -1101,10 +1116,37 @@ def add_pd(request, code): else: form = forms.AddPdForm() - ctx = {"page": "channels", "form": form} + ctx = {"page": "channels", "project": project, "form": form} return render(request, "integrations/add_pd.html", ctx) +@require_setting("PD_ENABLED") +@require_setting("PD_APP_ID") +@login_required +def add_pd_complete(request): + if "pagerduty" not in request.session: + return HttpResponseBadRequest() + + state, code = request.session.pop("pagerduty") + if request.GET.get("state") != state: + return HttpResponseForbidden() + + project = _get_rw_project_for_user(request, code) + + doc = json.loads(request.GET["config"]) + for item in doc["integration_keys"]: + channel = Channel(kind="pd", project=project) + channel.name = item["name"] + channel.value = json.dumps( + {"service_key": item["integration_key"], "account": doc["account"]["name"]} + ) + channel.save() + channel.assign_all_checks() + + messages.success(request, "The PagerDuty integration has been added!") + return redirect("hc-channels", project.code) + + @require_setting("PD_ENABLED") @require_setting("PD_VENDOR_KEY") def pdc_help(request): diff --git a/hc/settings.py b/hc/settings.py index 2f24b3d7..a6258dc2 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -215,6 +215,7 @@ PAGERTREE_ENABLED = envbool("PAGERTREE_ENABLED", "True") # PagerDuty PD_ENABLED = envbool("PD_ENABLED", "True") PD_VENDOR_KEY = os.getenv("PD_VENDOR_KEY") +PD_APP_ID = os.getenv("PD_APP_ID") # Prometheus PROMETHEUS_ENABLED = envbool("PROMETHEUS_ENABLED", "True") diff --git a/static/img/integrations/setup_pd_simple_0.png b/static/img/integrations/setup_pd_simple_0.png new file mode 100644 index 00000000..fbde09e7 Binary files /dev/null and b/static/img/integrations/setup_pd_simple_0.png differ diff --git a/static/img/integrations/setup_pd_simple_1.png b/static/img/integrations/setup_pd_simple_1.png new file mode 100644 index 00000000..e3128951 Binary files /dev/null and b/static/img/integrations/setup_pd_simple_1.png differ diff --git a/static/img/integrations/setup_pd_simple_2.png b/static/img/integrations/setup_pd_simple_2.png new file mode 100644 index 00000000..e2ed6dc3 Binary files /dev/null and b/static/img/integrations/setup_pd_simple_2.png differ diff --git a/static/img/integrations/setup_pd_simple_3.png b/static/img/integrations/setup_pd_simple_3.png new file mode 100644 index 00000000..9d784184 Binary files /dev/null and b/static/img/integrations/setup_pd_simple_3.png differ diff --git a/templates/docs/self_hosted_configuration.html b/templates/docs/self_hosted_configuration.html index 0ff9d78e..1b9815e9 100644 --- a/templates/docs/self_hosted_configuration.html +++ b/templates/docs/self_hosted_configuration.html @@ -152,12 +152,23 @@ integration.

PAGERTREE_ENABLED

Default: True

A boolean that turns on/off the PagerTree integration. Enabled by default.

+

PD_APP_ID

+

Default: None

+

PagerDuty application ID. If set, enables the PagerDuty +Simple Install Flow. +If None, Healthchecks will fall back to the even simpler flow where users manually +copy integration keys from PagerDuty and paste them in Healthchecks.

+

To set up:

+

PD_ENABLED

Default: True

A boolean that turns on/off the PagerDuty integration. Enabled by default.

-

PD_VENDOR_KEY

-

Default: None

-

PagerDuty vendor key, used by the PagerDuty integration.

PING_BODY_LIMIT

Default: 10000

The upper size limit in bytes for logged ping request bodies. diff --git a/templates/docs/self_hosted_configuration.md b/templates/docs/self_hosted_configuration.md index 6959e10f..83274447 100644 --- a/templates/docs/self_hosted_configuration.md +++ b/templates/docs/self_hosted_configuration.md @@ -254,17 +254,28 @@ Default: `True` A boolean that turns on/off the PagerTree integration. Enabled by default. -## `PD_ENABLED` {: #PD_ENABLED } +## `PD_APP_ID` {: #PD_APP_ID } -Default: `True` +Default: `None` -A boolean that turns on/off the PagerDuty integration. Enabled by default. +PagerDuty application ID. If set, enables the PagerDuty +[Simple Install Flow](https://developer.pagerduty.com/docs/app-integration-development/events-integration/). +If `None`, Healthchecks will fall back to the even simpler flow where users manually +copy integration keys from PagerDuty and paste them in Healthchecks. -## `PD_VENDOR_KEY` {: #PD_VENDOR_KEY } +To set up: -Default: `None` +* Register a PagerDuty app at [PagerDuty](https://pagerduty.com/) › Developer Mode › My Apps +* In the newly created app, add the "Events Integration" functionality +* Specify a Redirect URL: `https://your-domain.com/integrations/add_pagerduty/` +* Copy the displayed app_id value (PXXXXX) and put it in the `PD_APP_ID` environment + variable -[PagerDuty](https://www.pagerduty.com/) vendor key, used by the PagerDuty integration. +## `PD_ENABLED` {: #PD_ENABLED } + +Default: `True` + +A boolean that turns on/off the PagerDuty integration. Enabled by default. ## `PING_BODY_LIMIT` {: #PING_BODY_LIMIT } diff --git a/templates/integrations/add_pd_simple.html b/templates/integrations/add_pd_simple.html new file mode 100644 index 00000000..a2e69c45 --- /dev/null +++ b/templates/integrations/add_pd_simple.html @@ -0,0 +1,124 @@ +{% extends "base.html" %} +{% load humanize static hc_extras %} + +{% block title %}PagerDuty Integration for {{ site_name }}{% endblock %} + +{% block description %} + +{% endblock %} + +{% block content %} +

+
+

PagerDuty

+ +
+ {% if request.user.is_authenticated %} +

If your team uses PagerDuty, + you can set up {{ site_name }} to create a PagerDuty incident when + a check goes down, and resolve it when a check goes back up.

+ + {% if install_url %} + + {% endif %} + + {% else %} +

+ {{ site_name }} is a free and + open source + service for monitoring your cron jobs, background processes and + scheduled tasks. Before adding PagerDuty integration, please log into + {{ site_name }}:

+ +
+ Sign In +
+ {% endif %} +
+ +

Setup Guide

+ + {% if not connect_url %} +
+
+ +

+ {% if request.user.is_authenticated %} + Go + {% else %} + After logging in, go + {% endif %} + + to the Integrations page, + and click on Add Integration next to the + PagerDuty integration. +

+
+
+ Screenshot +
+
+ {% endif %} + +
+
+ +

+ Click on "Connect PagerDuty", and you will be + asked to log into your PagerDuty account. +

+
+
+ Screenshot +
+
+ +
+
+ +

+ Next, PagerDuty will let set the services + for this integration. +

+
+
+ Screenshot +
+
+ +
+
+ +

+ And that is all! You will then be redirected back to + "Integrations" page on {{ site_name }} and see + the new integration! +

+
+
+ Screenshot +
+
+
+
+{% endblock %}